Skip to content

Commit 1524a57

Browse files
mcp-tool-shopclaude
andcommitted
feat(cli): refresh ritual (compensator) + per-command help + rules split passthrough
Completes the loadout-os Phase-3 surface. refresh folds the Index Freshness Ritual into one command: index store -> ANDON-halt validate -> path-rewrite + copy to dest -> re-validate; NAMED_COMPENSATOR backs up dest to <dest>.bak before the (irreversible, hook-read-every-prompt) write and restores on failure, printing an undo line; --dry-run writes nothing. Per-command --help for every leaf (Hard Gate C; documents the dead <index> <jsonl> ordering + the flat-vs-namespaced validate collision). rules split now wraps via subprocess passthrough (inherited stdio for the readline prompt). Old-bin deprecating shims + the live refresh run remain deferred to Phase 6 (supervised). Tests: cli 51->78; all scratch-isolated, no live ~/.ai-loadout or canonical-store writes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b3bc8ff commit 1524a57

10 files changed

Lines changed: 1349 additions & 33 deletions

File tree

packages/cli/src/cli.ts

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ import {
3232
DIM,
3333
RESET,
3434
RED,
35-
YELLOW,
3635
log,
3736
hasFlag,
3837
flagValue,
@@ -47,7 +46,7 @@ import {
4746
rulesAnalyze,
4847
rulesValidate,
4948
rulesStats,
50-
rulesSplitNotice,
49+
rulesSplit,
5150
kernelResolve,
5251
kernelExplain,
5352
kernelUsage,
@@ -60,6 +59,8 @@ import {
6059
import { runDoctor, printDoctor, defaultDoctorPaths } from "./doctor.js";
6160
import { buildReport, printReport } from "./report.js";
6261
import { runHookTest, printHookTest, defaultHookPath } from "./hook.js";
62+
import { dispatchRefresh } from "./refresh.js";
63+
import { interceptHelp } from "./help.js";
6364

6465
// ── Version ───────────────────────────────────────────────────
6566
export function getVersion(): string {
@@ -94,7 +95,7 @@ ${BOLD}Rituals:${RESET}
9495
loadout-os doctor [--json] Read-only health screen
9596
loadout-os report [--index <p>] [--jsonl <p>] Observability over usage.jsonl
9697
loadout-os hook test [--prompt "<text>"] Drive the runtime hook on a sample prompt
97-
loadout-os refresh (not yet implemented — use the Index Freshness Ritual)
98+
loadout-os refresh [--store <d>] [--dest <p>] [--dry-run] Index Freshness Ritual (index → validate → write global)
9899
99100
${BOLD}Options:${RESET}
100101
--json Machine-readable output (doctor/report/most verbs)
@@ -129,18 +130,23 @@ ${BOLD}loadout-os rules${RESET} — wraps @mcptoolshop/claude-rules
129130
loadout-os rules analyze <CLAUDE.md> [--rules-dir <dir>] [--json]
130131
loadout-os rules validate [--rules-dir <dir>] [--lazy] [--repo-root <dir>] [--json]
131132
loadout-os rules stats <CLAUDE.md> [--rules-dir <dir>] [--json]
132-
loadout-os rules split (interactive — not wrapped; use the claude-rules bin)
133+
loadout-os rules split [CLAUDE.md] [--yes] [--dry-run] (interactive — passes through to claude-rules)
133134
`;
134135
}
135136

136137
// ── Namespace dispatchers ─────────────────────────────────────
137138
function dispatchMemories(args: string[]): void {
138-
if (hasFlag(args, "help") || args.length === 0) {
139+
// Namespace-level help: `memories --help` or `memories` (no subcommand).
140+
// A help flag WITH a subcommand falls through to per-command help below.
141+
const firstIsSub = args.length > 0 && !args[0].startsWith("-");
142+
if (args.length === 0 || (hasFlag(args, "help") && !firstIsSub)) {
139143
log(memoriesHelp());
140144
return;
141145
}
142146
const sub = args[0];
143147
const rest = args.slice(1);
148+
// Per-command help: `memories index --help`, etc.
149+
if (interceptHelp(`memories ${sub}`, rest)) return;
144150
switch (sub) {
145151
case "index":
146152
return memoriesIndex(rest);
@@ -160,12 +166,20 @@ function dispatchMemories(args: string[]): void {
160166
}
161167

162168
function dispatchRules(args: string[]): void {
163-
if (hasFlag(args, "help") || args.length === 0) {
169+
// Namespace-level help: `rules --help` or `rules` (no subcommand). A help flag
170+
// WITH a subcommand falls through to per-command help below — except `split`,
171+
// whose --help we own here (we never forward --help into the interactive bin).
172+
const firstIsSub = args.length > 0 && !args[0].startsWith("-");
173+
if (args.length === 0 || (hasFlag(args, "help") && !firstIsSub)) {
164174
log(rulesHelp());
165175
return;
166176
}
167177
const sub = args[0];
168178
const rest = args.slice(1);
179+
// Per-command help: `rules analyze --help`, `rules split --help`, etc.
180+
// For split we intercept --help BEFORE the passthrough so the readline prompt
181+
// is never spawned just to ask for usage.
182+
if (interceptHelp(`rules ${sub}`, rest)) return;
169183
switch (sub) {
170184
case "analyze":
171185
return rulesAnalyze(rest);
@@ -174,7 +188,7 @@ function dispatchRules(args: string[]): void {
174188
case "stats":
175189
return rulesStats(rest);
176190
case "split":
177-
return rulesSplitNotice();
191+
return rulesSplit(rest);
178192
default:
179193
throw new CliError(
180194
"UNKNOWN_COMMAND",
@@ -231,6 +245,8 @@ function dispatchReport(args: string[]): void {
231245

232246
function dispatchHook(args: string[]): void {
233247
const sub = args[0];
248+
// `hook test --help` (or `hook --help`) → per-command help for "hook test".
249+
if (interceptHelp("hook test", args)) return;
234250
if (sub !== "test") {
235251
throw new CliError(
236252
"UNKNOWN_COMMAND",
@@ -255,14 +271,6 @@ function dispatchHook(args: string[]): void {
255271
}
256272
}
257273

258-
function refreshStub(): void {
259-
log();
260-
log(` ${YELLOW}!${RESET} ${BOLD}loadout-os refresh${RESET} is not yet implemented.`);
261-
log(` ${DIM}It writes the live global index and needs a named compensator; deferred to a later wave.${RESET}`);
262-
log(` ${DIM}For now, run the Index Freshness Ritual (claude-memories index → validate → copy to ~/.ai-loadout/index.json).${RESET}`);
263-
log();
264-
}
265-
266274
// ── Main dispatch ─────────────────────────────────────────────
267275
const FLAT_KERNEL = new Set([
268276
"resolve",
@@ -287,14 +295,29 @@ export function dispatch(args: string[]): void {
287295
return;
288296
}
289297

290-
if (args.length === 0 || (hasFlag(args, "help") && !["memories", "rules"].includes(args[0]))) {
298+
// Top-level help: no args, or a help flag with NO command in front of it
299+
// (the first token is itself a flag). A help flag AFTER a command routes to
300+
// that command's per-command help, handled inside each dispatcher / below.
301+
const firstIsCmd = args.length > 0 && !args[0].startsWith("-");
302+
if (args.length === 0 || (hasFlag(args, "help") && !firstIsCmd)) {
291303
log(topLevelHelp());
292304
return;
293305
}
294306

295307
const cmd = args[0];
296308
const rest = args.slice(1);
297309

310+
// Per-command help for the flat verbs + rituals (namespaces handle their own
311+
// inside dispatchMemories/dispatchRules; hook handles "hook test" below).
312+
if (
313+
cmd !== "memories" &&
314+
cmd !== "rules" &&
315+
cmd !== "hook" &&
316+
interceptHelp(cmd, rest)
317+
) {
318+
return;
319+
}
320+
298321
switch (cmd) {
299322
case "memories":
300323
return dispatchMemories(rest);
@@ -307,7 +330,7 @@ export function dispatch(args: string[]): void {
307330
case "hook":
308331
return dispatchHook(rest);
309332
case "refresh":
310-
return refreshStub();
333+
return dispatchRefresh(rest);
311334
case "resolve":
312335
return kernelResolve(rest);
313336
case "explain":

packages/cli/src/commands.ts

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313
*/
1414

1515
import { readFileSync, existsSync, statSync } from "node:fs";
16-
import { resolve } from "node:path";
16+
import { resolve, dirname, join } from "node:path";
17+
import { createRequire } from "node:module";
18+
import { spawnSync } from "node:child_process";
1719

1820
// memories
1921
import {
@@ -362,9 +364,72 @@ export function rulesStats(args: string[]): void {
362364
log("");
363365
}
364366

365-
export function rulesSplitNotice(): void {
366-
warn("`rules split` is interactive and not yet wrapped by loadout-os.");
367-
info("Use the `claude-rules split` bin directly for the interactive extraction workflow.");
367+
/**
368+
* Resolve the absolute path to the `claude-rules` bin (its `split` command is
369+
* interactive). We resolve via the package's exported package.json — the only
370+
* subpath the package's `exports` map exposes — then read `bin.claude-rules`
371+
* and join it against the package dir. This works whether the dependency is the
372+
* published node_modules copy or a symlinked workspace package; both expose
373+
* `./package.json`. Returns null when the package (or its built bin) is absent.
374+
*/
375+
export function resolveRulesBin(): string | null {
376+
try {
377+
const req = createRequire(import.meta.url);
378+
const pkgJsonPath = req.resolve("@mcptoolshop/claude-rules/package.json");
379+
const pkg = req("@mcptoolshop/claude-rules/package.json") as {
380+
bin?: string | Record<string, string>;
381+
};
382+
const binRel =
383+
typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.["claude-rules"];
384+
if (!binRel) return null;
385+
const binAbs = join(dirname(pkgJsonPath), binRel);
386+
return existsSync(binAbs) ? binAbs : null;
387+
} catch {
388+
return null;
389+
}
390+
}
391+
392+
/**
393+
* Build the argv for the subprocess passthrough: `node <rules-bin> split <args>`.
394+
* Pure + exported so a test can assert the argv is constructed correctly
395+
* (right bin, the literal "split", forwarded user args) WITHOUT spawning the
396+
* interactive readline prompt.
397+
*/
398+
export function buildSplitArgv(rulesBin: string, args: string[]): string[] {
399+
return [rulesBin, "split", ...args];
400+
}
401+
402+
/**
403+
* `rules split` — subprocess passthrough to the interactive `claude-rules split`.
404+
*
405+
* split drives a readline Y/n/skip prompt per proposed extraction, so it cannot
406+
* be wrapped as a pure library call. We spawn the rules bin with INHERITED
407+
* stdio (stdin/stdout/stderr) so the prompt works exactly as it does standalone,
408+
* forwarding the user's args/flags verbatim ([CLAUDE.md], --dry-run, --yes, …).
409+
* The child's exit code becomes ours.
410+
*/
411+
export function rulesSplit(args: string[]): void {
412+
const rulesBin = resolveRulesBin();
413+
if (!rulesBin) {
414+
fail(
415+
"RULES_BIN_NOT_FOUND",
416+
"Could not resolve the @mcptoolshop/claude-rules bin for the interactive split.",
417+
"Ensure @mcptoolshop/claude-rules is installed/built, then retry; or run `claude-rules split` directly.",
418+
);
419+
}
420+
const argv = buildSplitArgv(rulesBin, args);
421+
info(`Handing off to the interactive claude-rules split (${argv[0]}) …`);
422+
const res = spawnSync(process.execPath, argv, { stdio: "inherit" });
423+
if (res.error) {
424+
fail(
425+
"RULES_SPLIT_SPAWN_FAILED",
426+
`Failed to spawn the claude-rules split bin: ${res.error.message}`,
427+
);
428+
}
429+
// Forward the child's exit code as our own (split owns the outcome).
430+
if (typeof res.status === "number" && res.status !== 0) {
431+
process.exitCode = res.status;
432+
}
368433
}
369434

370435
// ══════════════════════════════════════════════════════════════

0 commit comments

Comments
 (0)