Skip to content

Commit 4c24dac

Browse files
Shahinyanmclaude
andcommitted
feat(cli): install-hooks --classifier-command flag (v0.2.5)
0.2.4 shipped TJ_CLASSIFIER_CLI but expected users to set it via ~/.bashrc, which Claude Code never sources for hook subprocesses. Move the wiring into install-hooks so the same command that writes the hook entries also writes env.TJ_CLASSIFIER_CLI to settings.json. Behavior: - `task-journal install-hooks --classifier-command "aimux run dt"` → adds env.TJ_CLASSIFIER_CLI=aimux run dt to settings.json - flag omitted → no env block touched (default classifier = bare claude) - --uninstall removes TJ_CLASSIFIER_CLI; preserves unrelated env keys; drops env block if it becomes empty 3 new integration tests cover the flag, the no-flag default, and uninstall preservation. Closes claude-memory-tvx. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 500501f commit 4c24dac

10 files changed

Lines changed: 165 additions & 11 deletions

File tree

.claude-plugin/marketplace.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@
66
},
77
"metadata": {
88
"description": "Task Journal — append-only reasoning chain memory for AI-coding tasks",
9-
"version": "0.2.4"
9+
"version": "0.2.5"
1010
},
1111
"plugins": [
1212
{
1313
"name": "task-journal",
1414
"source": "./plugin",
1515
"description": "Append-only journal of AI-coding task reasoning chains. Captures hypotheses, decisions, rejections, evidence — renders compact resume packs so an agent can pick up a 2-week-old task with full context.",
16-
"version": "0.2.4",
16+
"version": "0.2.5",
1717
"author": {
1818
"name": "Digital-Threads"
1919
},

CHANGELOG.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.2.5] - 2026-05-07
11+
12+
DX improvement: ship classifier-wrapper config through `install-hooks`
13+
no more manual `bashrc` / `settings.json` edits to use `aimux`, `direnv`,
14+
`nix run`, etc.
15+
16+
### Added
17+
- `task-journal install-hooks --classifier-command "<CMD>"` flag.
18+
Writes `env.TJ_CLASSIFIER_CLI=<CMD>` into the same `settings.json`
19+
that already gets the hook entries. Claude Code reads the `env` block
20+
at startup and propagates the variable to hook subprocesses.
21+
Example:
22+
```bash
23+
task-journal install-hooks --classifier-command "aimux run dt"
24+
```
25+
When the flag is omitted, no `env` block is touched — default
26+
classifier remains the bare `claude` binary.
27+
- `--uninstall` now also strips `TJ_CLASSIFIER_CLI` from `env`,
28+
preserving any unrelated env keys and dropping the `env` block
29+
if it becomes empty.
30+
31+
### Fixed
32+
- 0.2.4's instructions told users to set `TJ_CLASSIFIER_CLI` via
33+
`~/.bashrc`, but Claude Code starts hook subprocesses outside an
34+
interactive bash, so the env var was invisible to the classifier
35+
and 401s kept piling up in `pending/`. The `--classifier-command`
36+
flag closes that loop end-to-end.
37+
1038
## [0.2.4] - 2026-05-07
1139

1240
Hotfix: support workspace-orchestrator wrappers (aimux, nix-shell, etc).

Cargo.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ members = [
77
]
88

99
[workspace.package]
10-
version = "0.2.4"
10+
version = "0.2.5"
1111
edition = "2021"
1212
rust-version = "1.83"
1313
license = "MIT"

crates/tj-cli/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ name = "task-journal"
1616
path = "src/main.rs"
1717

1818
[dependencies]
19-
tj-core = { package = "task-journal-core", version = "0.2.4", path = "../tj-core" }
19+
tj-core = { package = "task-journal-core", version = "0.2.5", path = "../tj-core" }
2020
anyhow = { workspace = true }
2121
clap = { workspace = true }
2222
tracing = { workspace = true }

crates/tj-cli/src/main.rs

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -650,6 +650,11 @@ enum Commands {
650650
/// Remove our hook entries instead of installing.
651651
#[arg(long)]
652652
uninstall: bool,
653+
/// Override classifier command. Writes env.TJ_CLASSIFIER_CLI into settings.json
654+
/// so wrappers (aimux, litellm, etc.) work without manual env setup.
655+
/// Default: classifier uses `claude -p`.
656+
#[arg(long)]
657+
classifier_command: Option<String>,
653658
},
654659
/// Show local classifier and journal statistics.
655660
Stats,
@@ -941,7 +946,11 @@ fn main() -> Result<()> {
941946
writer.flush_durable()?;
942947
println!("{}", event.event_id);
943948
}
944-
Commands::InstallHooks { scope, uninstall } => {
949+
Commands::InstallHooks {
950+
scope,
951+
uninstall,
952+
classifier_command,
953+
} => {
945954
let settings_path = match scope.as_str() {
946955
"user" => {
947956
let home =
@@ -971,6 +980,14 @@ fn main() -> Result<()> {
971980
.ok_or_else(|| anyhow::anyhow!("settings is not a JSON object"))?;
972981
if uninstall {
973982
hooks_obj.remove("hooks");
983+
// Remove our env key too — preserve other env entries.
984+
if let Some(env) = hooks_obj.get_mut("env").and_then(|v| v.as_object_mut()) {
985+
env.remove("TJ_CLASSIFIER_CLI");
986+
// Drop empty env block to keep settings.json clean.
987+
if env.is_empty() {
988+
hooks_obj.remove("env");
989+
}
990+
}
974991
} else {
975992
// Wrap with `|| true` so a failed classifier (network down, rate limit,
976993
// missing API key) NEVER breaks Claude Code. Failures land in pending/
@@ -984,6 +1001,23 @@ fn main() -> Result<()> {
9841001
"Stop": [{ "matcher": "", "hooks": [{ "type": "command", "command": cmd }] }],
9851002
});
9861003
hooks_obj.insert("hooks".into(), entries);
1004+
1005+
// Optional: set env.TJ_CLASSIFIER_CLI for users running classifier
1006+
// through a wrapper (aimux, litellm, etc.). Claude Code reads this
1007+
// env block and propagates the var to hook subprocesses, so users
1008+
// don't need to mess with bashrc.
1009+
if let Some(cmd) = classifier_command {
1010+
let env = hooks_obj
1011+
.entry("env".to_string())
1012+
.or_insert_with(|| serde_json::json!({}));
1013+
let env_obj = env
1014+
.as_object_mut()
1015+
.ok_or_else(|| anyhow::anyhow!("settings.env is not a JSON object"))?;
1016+
env_obj.insert(
1017+
"TJ_CLASSIFIER_CLI".to_string(),
1018+
serde_json::Value::String(cmd),
1019+
);
1020+
}
9871021
}
9881022
std::fs::write(&settings_path, serde_json::to_string_pretty(&current)?)?;
9891023
println!("{}", settings_path.display());

crates/tj-cli/tests/cli.rs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -850,6 +850,98 @@ fn install_hooks_is_idempotent_and_uninstall_works() {
850850
assert!(!after_uninstall.contains("UserPromptSubmit"));
851851
}
852852

853+
#[test]
854+
fn install_hooks_with_classifier_command_writes_env() {
855+
let dir = assert_fs::TempDir::new().unwrap();
856+
Command::cargo_bin("task-journal")
857+
.unwrap()
858+
.env("HOME", dir.path())
859+
.args([
860+
"install-hooks",
861+
"--scope",
862+
"user",
863+
"--classifier-command",
864+
"aimux run dt",
865+
])
866+
.assert()
867+
.success();
868+
let content = std::fs::read_to_string(dir.path().join(".claude/settings.json")).unwrap();
869+
let v: serde_json::Value = serde_json::from_str(&content).unwrap();
870+
assert_eq!(
871+
v.get("env")
872+
.and_then(|e| e.get("TJ_CLASSIFIER_CLI"))
873+
.and_then(|s| s.as_str()),
874+
Some("aimux run dt"),
875+
"env.TJ_CLASSIFIER_CLI must be set: {content}"
876+
);
877+
}
878+
879+
#[test]
880+
fn install_hooks_without_classifier_command_does_not_set_env() {
881+
let dir = assert_fs::TempDir::new().unwrap();
882+
Command::cargo_bin("task-journal")
883+
.unwrap()
884+
.env("HOME", dir.path())
885+
.args(["install-hooks", "--scope", "user"])
886+
.assert()
887+
.success();
888+
let content = std::fs::read_to_string(dir.path().join(".claude/settings.json")).unwrap();
889+
let v: serde_json::Value = serde_json::from_str(&content).unwrap();
890+
assert!(
891+
v.get("env")
892+
.and_then(|e| e.get("TJ_CLASSIFIER_CLI"))
893+
.is_none(),
894+
"TJ_CLASSIFIER_CLI must NOT be present when flag not passed: {content}"
895+
);
896+
}
897+
898+
#[test]
899+
fn install_hooks_uninstall_removes_classifier_env_but_preserves_others() {
900+
let dir = assert_fs::TempDir::new().unwrap();
901+
let claude_dir = dir.path().join(".claude");
902+
std::fs::create_dir_all(&claude_dir).unwrap();
903+
std::fs::write(
904+
claude_dir.join("settings.json"),
905+
serde_json::json!({ "env": { "OTHER_KEY": "keep_me" } }).to_string(),
906+
)
907+
.unwrap();
908+
909+
Command::cargo_bin("task-journal")
910+
.unwrap()
911+
.env("HOME", dir.path())
912+
.args([
913+
"install-hooks",
914+
"--scope",
915+
"user",
916+
"--classifier-command",
917+
"aimux run dt",
918+
])
919+
.assert()
920+
.success();
921+
Command::cargo_bin("task-journal")
922+
.unwrap()
923+
.env("HOME", dir.path())
924+
.args(["install-hooks", "--scope", "user", "--uninstall"])
925+
.assert()
926+
.success();
927+
928+
let after = std::fs::read_to_string(claude_dir.join("settings.json")).unwrap();
929+
let v: serde_json::Value = serde_json::from_str(&after).unwrap();
930+
assert!(
931+
v.get("env")
932+
.and_then(|e| e.get("TJ_CLASSIFIER_CLI"))
933+
.is_none(),
934+
"TJ_CLASSIFIER_CLI must be removed on uninstall: {after}"
935+
);
936+
assert_eq!(
937+
v.get("env")
938+
.and_then(|e| e.get("OTHER_KEY"))
939+
.and_then(|s| s.as_str()),
940+
Some("keep_me"),
941+
"unrelated env keys must be preserved: {after}"
942+
);
943+
}
944+
853945
#[test]
854946
fn ingest_hook_drains_pending_queue_via_mock() {
855947
let dir = assert_fs::TempDir::new().unwrap();

crates/tj-mcp/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ name = "task-journal-mcp"
1616
path = "src/main.rs"
1717

1818
[dependencies]
19-
tj-core = { package = "task-journal-core", version = "0.2.4", path = "../tj-core" }
19+
tj-core = { package = "task-journal-core", version = "0.2.5", path = "../tj-core" }
2020
anyhow = { workspace = true }
2121
tokio = { workspace = true }
2222
tracing = { workspace = true }

plugin/.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "task-journal",
3-
"version": "0.2.4",
3+
"version": "0.2.5",
44
"description": "Append-only journal of AI-coding task reasoning chains: hypotheses, decisions, rejections, evidence. Renders compact resume packs so an agent can pick up a 2-week-old task with full context.",
55
"author": {
66
"name": "Mher Shahinyan"

plugin/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "task-journal",
3-
"version": "0.2.4",
3+
"version": "0.2.5",
44
"description": "Append-only journal of AI-coding task reasoning chains. Captures hypotheses, decisions, rejections, evidence — renders compact resume packs so an agent can pick up a 2-week-old task with full context.",
55
"author": {
66
"name": "Mher Shahinyan",

0 commit comments

Comments
 (0)