Skip to content

Commit 33bab27

Browse files
mcp-tool-shopclaude
andcommitted
refactor(rules): make claude-rules importable as a library (FT-MR1/2)
Prerequisite for the unified loadout-os CLI. Extracted shared helpers (colors/log/fail/arg-parsers) into a side-effect-free src/console.ts; logic modules import from there instead of cli.ts; guarded main() behind an import.meta entrypoint check so importing no longer fires the CLI. Added src/index.ts barrel (pure logic + types only, no cmd wrappers) + main/types/exports in package.json mirroring memories. Bin still runs; barrel import verified side-effect-free. Tests: rules 98->105. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 384d9ac commit 33bab27

11 files changed

Lines changed: 312 additions & 100 deletions

File tree

packages/rules/package.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@
66
"bin": {
77
"claude-rules": "./dist/cli.js"
88
},
9+
"main": "dist/index.js",
10+
"types": "dist/index.d.ts",
11+
"exports": {
12+
".": {
13+
"types": "./dist/index.d.ts",
14+
"import": "./dist/index.js"
15+
},
16+
"./package.json": "./package.json"
17+
},
918
"scripts": {
1019
"build": "tsc",
1120
"verify": "tsc --noEmit && node --test dist/**/*.test.js",

packages/rules/src/analyze.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
import { readFileSync, existsSync } from "node:fs";
99
import { resolve } from "node:path";
1010
import { parseSections, estimateTokens, headingToId } from "./parser.js";
11-
import { log, ok, warn, info, BOLD, DIM, RESET, CYAN } from "./cli.js";
12-
import { hasFlag, positionalArgs, flagValue } from "./cli.js";
11+
import { log, ok, warn, info, fail, BOLD, DIM, RESET, CYAN } from "./console.js";
12+
import { hasFlag, positionalArgs, flagValue } from "./console.js";
1313
import { loadSignals, DEFAULT_SIGNALS } from "./signals.js";
1414
import type { Section, SplitProposal, AnalysisReport, Priority, SignalsConfig } from "./types.js";
1515

@@ -190,7 +190,6 @@ export async function cmdAnalyze(args: string[]): Promise<void> {
190190
const signals = loadSignals(flagValue(args, "--signals") ?? undefined);
191191

192192
if (!existsSync(filePath)) {
193-
const { fail } = await import("./cli.js");
194193
fail(
195194
"IO_FILE_NOT_FOUND",
196195
`File not found: ${filePath}`,

packages/rules/src/cli.ts

Lines changed: 17 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -2,103 +2,20 @@
22

33
import { readFileSync } from "node:fs";
44
import { resolve, dirname, join } from "node:path";
5-
import { fileURLToPath } from "node:url";
5+
import { fileURLToPath, pathToFileURL } from "node:url";
66
import { cmdAnalyze } from "./analyze.js";
77
import { cmdSplit } from "./split.js";
88
import { cmdValidate } from "./validate.js";
99
import { cmdStats } from "./stats.js";
1010
import { cmdInitSignals } from "./signals.js";
11+
import { log, fail, BOLD, DIM, RESET } from "./console.js";
1112

1213
// ── Package metadata ───────────────────────────────────────────
1314
const __dirname = dirname(fileURLToPath(import.meta.url));
1415
const PKG_ROOT = resolve(__dirname, "..");
1516
const pkg = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8"));
1617
const VERSION: string = pkg.version;
1718

18-
// ── Colors ─────────────────────────────────────────────────────
19-
export const BOLD = "\x1b[1m";
20-
export const GREEN = "\x1b[32m";
21-
export const YELLOW = "\x1b[33m";
22-
export const RED = "\x1b[31m";
23-
export const CYAN = "\x1b[36m";
24-
export const DIM = "\x1b[2m";
25-
export const RESET = "\x1b[0m";
26-
27-
export function log(msg: string): void {
28-
console.log(msg);
29-
}
30-
export function ok(msg: string): void {
31-
log(`${GREEN}${RESET} ${msg}`);
32-
}
33-
export function warn(msg: string): void {
34-
log(`${YELLOW}!${RESET} ${msg}`);
35-
}
36-
export function skip(msg: string): void {
37-
log(`${DIM} skip${RESET} ${msg}`);
38-
}
39-
export function info(msg: string): void {
40-
log(`${CYAN}i${RESET} ${msg}`);
41-
}
42-
43-
// ── Structured error + exit ────────────────────────────────────
44-
export function fail(
45-
code: string,
46-
message: string,
47-
hint: string,
48-
exitCode = 1,
49-
): never {
50-
console.error(`${RED}Error [${code}]:${RESET} ${message}`);
51-
console.error(`${DIM}Hint: ${hint}${RESET}`);
52-
process.exit(exitCode);
53-
}
54-
55-
// ── Flag parsing helpers ───────────────────────────────────────
56-
export function hasFlag(args: string[], flag: string): boolean {
57-
return args.includes(flag);
58-
}
59-
60-
export function flagValue(
61-
args: string[],
62-
flag: string,
63-
): string | undefined {
64-
const idx = args.indexOf(flag);
65-
if (idx === -1 || idx + 1 >= args.length) return undefined;
66-
const next = args[idx + 1];
67-
// Guard against a missing value: `split --rules-dir --dry-run` must not treat
68-
// the following flag as a directory name (which would create a dir literally
69-
// named "--dry-run"). A value-flag whose next token is itself a flag has no
70-
// value — return undefined so callers fall back to their default.
71-
if (next.startsWith("--")) return undefined;
72-
return next;
73-
}
74-
75-
// Every flag in the CLI surface that consumes the following argument as its
76-
// value. Kept here as the single source of truth so positionalArgs can always
77-
// skip a value-flag's argument even if a caller forgets to list it — otherwise
78-
// `--rules-dir foo path` would mis-parse `foo` as a positional.
79-
const VALUE_FLAGS = ["--rules-dir", "--signals"];
80-
// Boolean flags take no argument.
81-
const BOOL_FLAGS = ["--memory", "--dry-run", "--help", "-h", "--yes", "--lazy", "--json"];
82-
83-
export function positionalArgs(
84-
args: string[],
85-
flags: string[],
86-
): string[] {
87-
const flagIndices = new Set<number>();
88-
// Union of the caller-supplied value-flags and the known CLI value-flags,
89-
// so a value-flag's argument can never be mis-parsed as a positional.
90-
const valueFlags = new Set([...flags, ...VALUE_FLAGS]);
91-
for (let i = 0; i < args.length; i++) {
92-
if (valueFlags.has(args[i])) {
93-
flagIndices.add(i);
94-
flagIndices.add(i + 1); // skip the flag's value (if present)
95-
} else if (BOOL_FLAGS.includes(args[i])) {
96-
flagIndices.add(i);
97-
}
98-
}
99-
return args.filter((_, i) => !flagIndices.has(i) && !args[i].startsWith("--"));
100-
}
101-
10219
// ── Help text ──────────────────────────────────────────────────
10320
function helpCommand(): void {
10421
log(`
@@ -182,6 +99,18 @@ async function main(): Promise<void> {
18299
}
183100
}
184101

185-
main().catch((err: Error) => {
186-
fail("RUNTIME_FATAL", err.message, "This is a bug. Please report it.", 2);
187-
});
102+
// ── Entrypoint guard ───────────────────────────────────────────
103+
// Only run the CLI when this module is executed directly as a binary
104+
// (`claude-rules ...` / `node dist/cli.js ...`), NOT when imported. The library
105+
// barrel (src/index.ts) re-exports the pure logic modules, which import shared
106+
// helpers from ./console.js — but the dispatch chain still reaches cli.ts via
107+
// the bin. Without this guard, `import "@mcptoolshop/claude-rules"` would parse
108+
// argv and run a command as an import side effect.
109+
if (
110+
process.argv[1] &&
111+
import.meta.url === pathToFileURL(process.argv[1]).href
112+
) {
113+
main().catch((err: Error) => {
114+
fail("RUNTIME_FATAL", err.message, "This is a bug. Please report it.", 2);
115+
});
116+
}

packages/rules/src/console.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/**
2+
* Console UI + arg-parsing helpers (side-effect-free).
3+
*
4+
* Extracted from cli.ts so the logic modules (analyze/validate/split/stats/
5+
* signals) can import these without transitively pulling in cli.ts — which
6+
* self-executes main() as a binary entrypoint. Importing this module performs
7+
* no I/O, parses no argv, and exits no process at load time; only `fail()`
8+
* exits, and only when explicitly called.
9+
*/
10+
11+
// ── Colors ─────────────────────────────────────────────────────
12+
export const BOLD = "\x1b[1m";
13+
export const GREEN = "\x1b[32m";
14+
export const YELLOW = "\x1b[33m";
15+
export const RED = "\x1b[31m";
16+
export const CYAN = "\x1b[36m";
17+
export const DIM = "\x1b[2m";
18+
export const RESET = "\x1b[0m";
19+
20+
export function log(msg: string): void {
21+
console.log(msg);
22+
}
23+
export function ok(msg: string): void {
24+
log(`${GREEN}${RESET} ${msg}`);
25+
}
26+
export function warn(msg: string): void {
27+
log(`${YELLOW}!${RESET} ${msg}`);
28+
}
29+
export function skip(msg: string): void {
30+
log(`${DIM} skip${RESET} ${msg}`);
31+
}
32+
export function info(msg: string): void {
33+
log(`${CYAN}i${RESET} ${msg}`);
34+
}
35+
36+
// ── Structured error + exit ────────────────────────────────────
37+
export function fail(
38+
code: string,
39+
message: string,
40+
hint: string,
41+
exitCode = 1,
42+
): never {
43+
console.error(`${RED}Error [${code}]:${RESET} ${message}`);
44+
console.error(`${DIM}Hint: ${hint}${RESET}`);
45+
process.exit(exitCode);
46+
}
47+
48+
// ── Flag parsing helpers ───────────────────────────────────────
49+
export function hasFlag(args: string[], flag: string): boolean {
50+
return args.includes(flag);
51+
}
52+
53+
export function flagValue(
54+
args: string[],
55+
flag: string,
56+
): string | undefined {
57+
const idx = args.indexOf(flag);
58+
if (idx === -1 || idx + 1 >= args.length) return undefined;
59+
const next = args[idx + 1];
60+
// Guard against a missing value: `split --rules-dir --dry-run` must not treat
61+
// the following flag as a directory name (which would create a dir literally
62+
// named "--dry-run"). A value-flag whose next token is itself a flag has no
63+
// value — return undefined so callers fall back to their default.
64+
if (next.startsWith("--")) return undefined;
65+
return next;
66+
}
67+
68+
// Every flag in the CLI surface that consumes the following argument as its
69+
// value. Kept here as the single source of truth so positionalArgs can always
70+
// skip a value-flag's argument even if a caller forgets to list it — otherwise
71+
// `--rules-dir foo path` would mis-parse `foo` as a positional.
72+
const VALUE_FLAGS = ["--rules-dir", "--signals"];
73+
// Boolean flags take no argument.
74+
const BOOL_FLAGS = ["--memory", "--dry-run", "--help", "-h", "--yes", "--lazy", "--json"];
75+
76+
export function positionalArgs(
77+
args: string[],
78+
flags: string[],
79+
): string[] {
80+
const flagIndices = new Set<number>();
81+
// Union of the caller-supplied value-flags and the known CLI value-flags,
82+
// so a value-flag's argument can never be mis-parsed as a positional.
83+
const valueFlags = new Set([...flags, ...VALUE_FLAGS]);
84+
for (let i = 0; i < args.length; i++) {
85+
if (valueFlags.has(args[i])) {
86+
flagIndices.add(i);
87+
flagIndices.add(i + 1); // skip the flag's value (if present)
88+
} else if (BOOL_FLAGS.includes(args[i])) {
89+
flagIndices.add(i);
90+
}
91+
}
92+
return args.filter((_, i) => !flagIndices.has(i) && !args[i].startsWith("--"));
93+
}

packages/rules/src/index.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/**
2+
* Library entry point for @mcptoolshop/claude-rules.
3+
*
4+
* Re-exports the PURE logic + types for programmatic consumers (e.g. the
5+
* unified loadout-os CLI). The `cmdX` CLI wrappers, `main`, and the bin
6+
* entrypoint (cli.ts) are intentionally NOT exported — importing this barrel
7+
* is side-effect-free (no argv parsing, no command execution).
8+
*/
9+
10+
// ── Types ──────────────────────────────────────────────────────
11+
export type {
12+
// Re-exported routing types (sourced from @mcptoolshop/ai-loadout)
13+
Priority,
14+
Triggers,
15+
LoadoutEntry,
16+
LoadoutIndex,
17+
Budget,
18+
Frontmatter,
19+
IssueSeverity,
20+
ValidationIssue,
21+
// claude-rules aliases
22+
RuleEntry,
23+
RuleIndex,
24+
// CLAUDE.md document types
25+
Section,
26+
SplitProposal,
27+
AnalysisReport,
28+
FsValidationIssue,
29+
SignalsConfig,
30+
} from "./types.js";
31+
32+
export { DEFAULT_TRIGGERS } from "./types.js";
33+
34+
// ── Parser ─────────────────────────────────────────────────────
35+
export { parseSections, estimateTokens, headingToId } from "./parser.js";
36+
37+
// ── Analyzer ───────────────────────────────────────────────────
38+
export {
39+
analyzeFile,
40+
classifyPriority,
41+
extractKeywords,
42+
generateSummary,
43+
suggestPatterns,
44+
} from "./analyze.js";
45+
46+
// ── Split file generators ──────────────────────────────────────
47+
export { generateRuleFile, generateIndex, generateClaudeMd } from "./split.js";
48+
49+
// ── Validator ──────────────────────────────────────────────────
50+
export { validateRules } from "./validate.js";
51+
52+
// ── Signals ────────────────────────────────────────────────────
53+
export { loadSignals, DEFAULT_SIGNALS } from "./signals.js";

packages/rules/src/signals.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77

88
import { readFileSync, existsSync, writeFileSync, mkdirSync } from "node:fs";
99
import { resolve, dirname } from "node:path";
10-
import { ok, warn, info, fail } from "./cli.js";
11-
import { flagValue } from "./cli.js";
10+
import { ok, warn, info, fail } from "./console.js";
11+
import { flagValue } from "./console.js";
1212
import type { SignalsConfig } from "./types.js";
1313

1414
// ── Built-in defaults (moved from analyze.ts) ───────────────

packages/rules/src/split.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ import { createInterface } from "node:readline";
1616
import { tmpdir } from "node:os";
1717
import { analyzeFile, resolveClaudeMd, resolveMemoryMd } from "./analyze.js";
1818
import { serializeFrontmatter, estimateTokens } from "./parser.js";
19-
import { log, ok, warn, info, fail, BOLD, DIM, RESET, CYAN, GREEN, YELLOW } from "./cli.js";
20-
import { hasFlag, positionalArgs, flagValue } from "./cli.js";
19+
import { log, ok, warn, info, fail, BOLD, DIM, RESET, CYAN, GREEN, YELLOW } from "./console.js";
20+
import { hasFlag, positionalArgs, flagValue } from "./console.js";
2121
import { loadSignals } from "./signals.js";
2222
import type {
2323
SplitProposal,

packages/rules/src/stats.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
99
import { resolve, join } from "node:path";
1010
import { estimateTokens, parseFrontmatter } from "./parser.js";
1111
import { resolveClaudeMd } from "./analyze.js";
12-
import { log, info, ok, warn, fail, BOLD, DIM, RESET, CYAN, GREEN, YELLOW } from "./cli.js";
13-
import { positionalArgs, flagValue, hasFlag } from "./cli.js";
12+
import { log, info, ok, warn, fail, BOLD, DIM, RESET, CYAN, GREEN, YELLOW } from "./console.js";
13+
import { positionalArgs, flagValue, hasFlag } from "./console.js";
1414
import type { RuleIndex, RuleEntry } from "./types.js";
1515

1616
// ── CLI command: stats ─────────────────────────────────────────

packages/rules/src/tests/cli.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { resolve, dirname, join } from "node:path";
55
import { fileURLToPath } from "node:url";
66
import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs";
77
import { tmpdir } from "node:os";
8-
import { positionalArgs, flagValue } from "../cli.js";
8+
import { positionalArgs, flagValue } from "../console.js";
99

1010
const __dirname = dirname(fileURLToPath(import.meta.url));
1111
const CLI = resolve(__dirname, "..", "cli.js");

0 commit comments

Comments
 (0)