@narrativetrace/cli 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli-bin.cjs CHANGED
@@ -1,373 +1,135 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
3
 
4
- // src/doctor/doc-urls.ts
5
- var BASE = "https://github.com/narrativetrace/narrativetrace-typescript/blob/main/documentation/";
6
- var DOC = {
7
- installationPrerequisites: `${BASE}installation-guide.md#prerequisites`,
8
- installationDependencies: `${BASE}installation-guide.md#1-add-dependencies`,
9
- vitestConfiguration: `${BASE}configuration-guide.md#2-vitest-configuration`,
10
- whereSettingsComeFrom: `${BASE}configuration-guide.md#2b-where-settings-come-from`,
11
- proxyOptions: `${BASE}configuration-guide.md#6-proxy-options`,
12
- eventPipelineBuffering: `${BASE}configuration-guide.md#8-event-pipeline-buffering-bufferedeventconsumer`,
13
- noTraceFilesWritten: `${BASE}troubleshooting.md#no-trace-files-are-written`,
14
- manualParameterNames: `${BASE}troubleshooting.md#parameters-show-as-arg0-arg1`,
15
- redactionSurfaceBySurface: `${BASE}privacy-and-redaction.md#redaction-surface-by-surface`,
16
- approvalTracesEndToEnd: `${BASE}structural-trace-format.md#approval-traces-end-to-end`,
17
- sixtySecondsNewProject: `${BASE}sixty-seconds.md#1-new-project-add-the-packages`
18
- };
4
+ // ../../node_modules/.pnpm/tsup@8.5.1_@swc+core@1.15.18_postcss@8.5.6_tsx@4.21.0_typescript@5.9.3_yaml@2.9.0/node_modules/tsup/assets/cjs_shims.js
5
+ var getImportMetaUrl = () => typeof document === "undefined" ? new URL(`file:${__filename}`).href : document.currentScript && document.currentScript.tagName.toUpperCase() === "SCRIPT" ? document.currentScript.src : new URL("main.js", document.baseURI).href;
6
+ var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
19
7
 
20
- // src/doctor/finding.ts
21
- function pass(id, message, docUrl) {
22
- return { id, status: "pass", message, fix: "", docUrl };
23
- }
24
- function fail(id, message, fix, docUrl) {
25
- return { id, status: "fail", message, fix, docUrl };
26
- }
27
-
28
- // src/doctor/checks/approval-traces.ts
29
- var ID = "trap.approval-traces";
30
- var FIX = "Review each .received.nt diff against its .approved.nt baseline, then run pnpm run approve-narratives (or narrativetrace-approve) to promote it, or delete it if the change was wrong \u2014 never commit a .received.nt file.";
31
- var checkApprovalTraces = (snapshot) => {
32
- const paths = [...snapshot.approvedDirFiles.keys()];
33
- if (paths.length === 0) {
34
- return pass(
35
- ID,
36
- "no approval traces configured yet \u2014 nothing to check",
37
- DOC.approvalTracesEndToEnd
38
- );
39
- }
40
- const received = paths.filter((p) => p.endsWith(".received.nt"));
41
- if (received.length > 0) {
42
- const message2 = `${received.length} stale received trace(s) found: ${received.join(", ")}`;
43
- return fail(ID, message2, FIX, DOC.approvalTracesEndToEnd);
44
- }
45
- const approved = paths.filter((p) => p.endsWith(".approved.nt"));
46
- const message = `${approved.length} approved trace(s) found, no pending received diffs`;
47
- return pass(ID, message, DOC.approvalTracesEndToEnd);
48
- };
49
-
50
- // src/doctor/checks/llms-before-you-start.ts
51
- var ID2 = "trap.llms-before-you-start";
52
- var PLAIN_JS = /\.js$/;
53
- var ESM_IMPORT = /^\s*import\s/m;
54
- var FIX2 = "Run `npm pkg set type=module` before `npm add` \u2014 without it, Node throws SyntaxError: Cannot use import statement outside a module (pnpm/yarn default to it and never hit this).";
55
- var checkLlmsBeforeYouStart = (snapshot) => {
56
- if (snapshot.rootPackageJson?.type === "module") {
57
- return pass(ID2, 'package.json declares "type": "module"', DOC.sixtySecondsNewProject);
58
- }
59
- const offender = [...snapshot.sourceFiles].find(
60
- ([path2, content]) => PLAIN_JS.test(path2) && ESM_IMPORT.test(content)
61
- );
62
- if (!offender) {
63
- const message2 = 'no plain .js file uses ESM import syntax without "type": "module"';
64
- return pass(ID2, message2, DOC.sixtySecondsNewProject);
65
- }
66
- const [path] = offender;
67
- const message = `${path} uses ESM import syntax but package.json has no "type": "module"`;
68
- return fail(ID2, message, FIX2, DOC.sixtySecondsNewProject);
69
- };
70
-
71
- // src/semver-lite.ts
72
- function parseVersion(raw) {
73
- const cleaned = raw.trim().replace(/^v/, "");
74
- const match = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(cleaned);
75
- if (!match) return void 0;
76
- return {
77
- major: Number(match[1]),
78
- minor: match[2] === void 0 ? 0 : Number(match[2]),
79
- patch: match[3] === void 0 ? 0 : Number(match[3])
80
- };
81
- }
82
- function compareVersions(left, right) {
83
- if (left.major !== right.major) return left.major - right.major;
84
- if (left.minor !== right.minor) return left.minor - right.minor;
85
- return left.patch - right.patch;
86
- }
87
- function satisfiesGte(version, floor) {
88
- return compareVersions(version, floor) >= 0;
89
- }
90
- function satisfiesCaret(version, base) {
91
- if (compareVersions(version, base) < 0) return false;
92
- if (base.major > 0) return version.major === base.major;
93
- if (base.minor > 0) return version.major === 0 && version.minor === base.minor;
94
- return version.major === 0 && version.minor === 0 && version.patch === base.patch;
95
- }
96
- function satisfiesClause(version, trimmed) {
97
- if (trimmed.startsWith("^")) {
98
- const base = parseVersion(trimmed.slice(1));
99
- return base !== void 0 && satisfiesCaret(version, base);
100
- }
101
- if (trimmed.startsWith(">=")) {
102
- const floor = parseVersion(trimmed.slice(2));
103
- return floor !== void 0 && satisfiesGte(version, floor);
104
- }
105
- if (/\.x\b/.test(trimmed)) {
106
- const base = parseVersion(trimmed.replace(/\.x/g, ".0"));
107
- return base !== void 0 && satisfiesCaret(version, base);
108
- }
109
- const exact = parseVersion(trimmed);
110
- return exact !== void 0 && compareVersions(version, exact) === 0;
111
- }
112
- function satisfiesRange(versionRaw, range) {
113
- const version = parseVersion(versionRaw);
114
- if (!version) return false;
115
- return range.split("||").map((clause) => clause.trim()).filter(Boolean).some((clause) => satisfiesClause(version, clause));
116
- }
117
-
118
- // src/doctor/checks/node-engine.ts
119
- var ID3 = "toolchain.node-engine";
120
- var DEFAULT_ENGINE_RANGE = ">=20";
121
- function requiredRange(snapshot) {
122
- const core = snapshot.installedPackages.get("@narrativetrace/core");
123
- const coreNode = snapshot.installedPackages.get("@narrativetrace/core-node");
124
- return core?.engines?.node ?? coreNode?.engines?.node ?? DEFAULT_ENGINE_RANGE;
125
- }
126
- var checkNodeEngine = (snapshot) => {
127
- const required = requiredRange(snapshot);
128
- if (satisfiesRange(snapshot.nodeVersion, required)) {
129
- return pass(
130
- ID3,
131
- `Node ${snapshot.nodeVersion} satisfies the required ${required}`,
132
- DOC.installationPrerequisites
133
- );
134
- }
135
- return fail(
136
- ID3,
137
- `Node ${snapshot.nodeVersion} does not satisfy the required ${required}`,
138
- `Upgrade Node to a version satisfying ${required} (nvm, volta, asdf, or your CI image).`,
139
- DOC.installationPrerequisites
140
- );
141
- };
142
-
143
- // src/doctor/checks/output-env.ts
144
- var ID4 = "config.output-env";
145
- var VALID_VALUES = /* @__PURE__ */ new Set(["true", "false"]);
146
- var checkOutputEnv = (snapshot) => {
147
- const raw = snapshot.env.NARRATIVETRACE_OUTPUT;
148
- if (raw === void 0) {
149
- return pass(
150
- ID4,
151
- "NARRATIVETRACE_OUTPUT is not set \u2014 output stays on its default (on)",
152
- DOC.whereSettingsComeFrom
153
- );
154
- }
155
- if (VALID_VALUES.has(raw.toLowerCase())) {
156
- return pass(ID4, `NARRATIVETRACE_OUTPUT=${raw}`, DOC.whereSettingsComeFrom);
157
- }
158
- return fail(
159
- ID4,
160
- `NARRATIVETRACE_OUTPUT is set to '${raw}', which is neither "true" nor "false"`,
161
- "Set NARRATIVETRACE_OUTPUT=true or NARRATIVETRACE_OUTPUT=false (or unset it) \u2014 any other value is read as a truthy string, most likely turning output on when you meant to turn it off.",
162
- DOC.whereSettingsComeFrom
163
- );
164
- };
165
-
166
- // src/doctor/checks/parameter-arg0.ts
167
- var ID5 = "trap.parameter-arg0";
168
- var ARG_PLACEHOLDER = /\barg0\b/;
169
- var FIX3 = 'Pass parameter names explicitly: traceObject(target, context, { methodName: ["paramA", "paramB"] }) \u2014 required for classes you do not own, or when a build tool strips them.';
170
- var checkParameterArg0 = (snapshot) => {
171
- if (snapshot.outputFiles.size === 0) {
172
- const message2 = "no rendered output found yet \u2014 run your tests or app once to check this";
173
- return pass(ID5, message2, DOC.manualParameterNames);
174
- }
175
- const offender = [...snapshot.outputFiles].find(([, content]) => ARG_PLACEHOLDER.test(content));
176
- if (!offender) {
177
- const message2 = "rendered output carries real parameter names \u2014 no arg0 placeholders found";
178
- return pass(ID5, message2, DOC.manualParameterNames);
179
- }
180
- const [path] = offender;
181
- const message = `rendered output shows arg0-style placeholders (first seen in ${path}) \u2014 parameter names were not captured`;
182
- return fail(ID5, message, FIX3, DOC.manualParameterNames);
183
- };
184
-
185
- // src/doctor/checks/redaction-proof.ts
186
- var ID6 = "trap.redaction-proof";
187
- var TEST_FILE = /\.(test|spec)\.[cm]?[jt]sx?$/;
188
- var REDACTED_ASSERTION = /\[REDACTED\]/;
189
- var checkRedactionProof = (snapshot) => {
190
- const testFiles = [...snapshot.sourceFiles].filter(([path]) => TEST_FILE.test(path));
191
- const proven = testFiles.some(([, content]) => REDACTED_ASSERTION.test(content));
192
- if (proven) {
193
- return pass(
194
- ID6,
195
- "a test asserts [REDACTED] for a deny-listed parameter name",
196
- DOC.redactionSurfaceBySurface
197
- );
198
- }
199
- return fail(
200
- ID6,
201
- "no test asserts [REDACTED] \u2014 redaction is unproven",
202
- 'Render a call with a deny-listed parameter name (e.g. "password", "token") in a test and assert the output contains "[REDACTED]" \u2014 and that a neighboring, non-sensitive value is still present, so an over-broad redaction also fails.',
203
- DOC.redactionSurfaceBySurface
204
- );
205
- };
206
-
207
- // src/doctor/checks/reporter-subpath.ts
208
- var ID7 = "config.reporter-subpath";
209
- var VITEST_CONFIG_NAME = /(^|\/)vitest\.config\.[cm]?[jt]s$/;
210
- var REPORTER_NAMES = /\b(ClaritySuiteReporter|GlossarySuiteReporter|StructuralSuiteReporter)\b/;
211
- var ROOT_IMPORT = /from\s+["']@narrativetrace\/vitest["']/;
212
- var SUBPATH_IMPORT = /from\s+["']@narrativetrace\/vitest\/reporters["']/;
213
- function importsFromRoot(content) {
214
- return REPORTER_NAMES.test(content) && ROOT_IMPORT.test(content) && !SUBPATH_IMPORT.test(content);
215
- }
216
- var checkReporterSubpath = (snapshot) => {
217
- const configs = [...snapshot.sourceFiles].filter(([path]) => VITEST_CONFIG_NAME.test(path));
218
- const offender = configs.find(([, content]) => importsFromRoot(content));
219
- if (offender) {
220
- const [path] = offender;
221
- return fail(
222
- ID7,
223
- `${path} imports a NarrativeTrace vitest reporter from the package root, not the /reporters subpath`,
224
- 'Import reporters from "@narrativetrace/vitest/reporters" \u2014 importing from the package root also loads vitest itself and crashes config loading.',
225
- DOC.vitestConfiguration
226
- );
227
- }
228
- const message = configs.length === 0 ? "no vitest.config found \u2014 nothing to check" : "every registered reporter is imported from the /reporters subpath";
229
- return pass(ID7, message, DOC.vitestConfiguration);
230
- };
231
-
232
- // src/doctor/checks/sibling-packages.ts
233
- var ID8 = "toolchain.sibling-packages";
234
- var checkSiblingPackages = (snapshot) => {
235
- const ntVitest = snapshot.installedPackages.get("@narrativetrace/vitest");
236
- if (!ntVitest) {
237
- const message2 = "@narrativetrace/vitest is not installed \u2014 nothing to check";
238
- return pass(ID8, message2, DOC.installationDependencies);
239
- }
240
- const siblings = Object.keys(ntVitest.dependencies ?? {}).filter(
241
- (n) => n.startsWith("@narrativetrace/")
242
- );
243
- const unresolved = siblings.filter((name) => !snapshot.installedPackages.has(name));
244
- if (unresolved.length === 0) {
245
- const message2 = `all ${siblings.length} sibling package(s) of @narrativetrace/vitest resolve from the consumer`;
246
- return pass(ID8, message2, DOC.installationDependencies);
247
- }
248
- const message = `${unresolved.length} sibling package(s) of @narrativetrace/vitest do not resolve from the consumer: ${unresolved.join(", ")}`;
249
- const fix = `Add ${unresolved.join(", ")} as explicit direct dependencies \u2014 pnpm's strict layout does not hoist a dependency's own transitive dependencies to your project root.`;
250
- return fail(ID8, message, fix, DOC.installationDependencies);
251
- };
252
-
253
- // src/doctor/checks/silent-sink.ts
254
- var ID9 = "trap.silent-sink";
255
- var TRACE_OBJECT_CALL = /\btraceObject\s*\(/;
256
- var SINK_SIGNAL = /\b(BufferedEventConsumer|captureTrace|registerConsumer|createNarrativeTest|narrativeTest)\b|@narrativetrace\/(vitest|pino|winston|opentelemetry|observability)\b/;
257
- var checkSilentSink = (snapshot) => {
258
- const contents = [...snapshot.sourceFiles.values()];
259
- if (!contents.some((content) => TRACE_OBJECT_CALL.test(content))) {
260
- return pass(ID9, "traceObject() is not used \u2014 nothing to check", DOC.noTraceFilesWritten);
261
- }
262
- if (contents.some((content) => SINK_SIGNAL.test(content))) {
263
- return pass(
264
- ID9,
265
- "traceObject() is used and a consumer/sink is attached",
266
- DOC.noTraceFilesWritten
267
- );
268
- }
269
- return fail(
270
- ID9,
271
- "traceObject() is used but no consumer or sink (BufferedEventConsumer, captureTrace(), a log/OTel bridge, or @narrativetrace/vitest) was found",
272
- "Attach a sink: pass a BufferedEventConsumer to your pipeline, call captureTrace() and do something with the tree, or wire a log/OTel bridge \u2014 otherwise every traced call narrates to nowhere.",
273
- DOC.noTraceFilesWritten
274
- );
275
- };
276
-
277
- // src/doctor/checks/trace-object-keys.ts
278
- var ID10 = "config.trace-object-keys";
279
- var OLD_SHAPE = /\bmethods\s*:\s*\{[^{}]*:\s*\[/;
280
- var checkTraceObjectKeys = (snapshot) => {
281
- const offender = [...snapshot.sourceFiles].find(([, content]) => OLD_SHAPE.test(content));
282
- if (offender) {
283
- const [path] = offender;
284
- return fail(
285
- ID10,
286
- `${path} calls traceObject(...) with a method entry that skips the per-method config object`,
287
- "Nest the parameter names under params: traceObject(target, context, { methods: { methodName: { params: [...] } } }) \u2014 a bare array under the method name throws.",
288
- DOC.proxyOptions
289
- );
290
- }
291
- return pass(
292
- ID10,
293
- "no traceObject(...) call uses the old { methods: { ... } } option shape",
294
- DOC.proxyOptions
295
- );
296
- };
8
+ // src/cli-bin.ts
9
+ var import_tooling4 = require("@narrativetrace/tooling");
297
10
 
298
- // src/doctor/checks/vitest-peer.ts
299
- var ID11 = "toolchain.vitest-peer";
300
- function noVitestInstalled(range) {
301
- const message = `@narrativetrace/vitest requires a peer vitest@${range}, but no vitest install was found`;
302
- const fix = `Install a vitest version satisfying ${range} (npm add -D vitest, then npm ci for a clean lockfile install).`;
303
- return fail(ID11, message, fix, DOC.vitestConfiguration);
304
- }
305
- function mismatchedVitest(range, installed) {
306
- const message = `vitest@${installed} does not satisfy @narrativetrace/vitest's declared peer range ${range}`;
307
- const fix = `Install a vitest version satisfying ${range}, then reinstall clean (rm -rf node_modules && npm ci) \u2014 a mismatched peer here is the one failure mode that breaks the library's own build.`;
308
- return fail(ID11, message, fix, DOC.vitestConfiguration);
11
+ // src/carrier-locator.ts
12
+ var import_node_path = require("path");
13
+ var import_node_url = require("url");
14
+ var import_tooling = require("@narrativetrace/tooling");
15
+ function cliPackageDirectory() {
16
+ return (0, import_node_path.dirname)((0, import_node_path.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl)));
309
17
  }
310
- function evaluateInstalledVitest(range, vitest) {
311
- if (!vitest?.version) return noVitestInstalled(range);
312
- if (satisfiesRange(vitest.version, range)) {
313
- const message = `vitest@${vitest.version} satisfies the declared peer range ${range}`;
314
- return pass(ID11, message, DOC.vitestConfiguration);
315
- }
316
- return mismatchedVitest(range, vitest.version);
18
+ function openCarrierFor(projectDirectory, from) {
19
+ return (0, import_tooling.resolveCarrier)({ projectDirectory, bundledDirectory: cliPackageDirectory(), from });
317
20
  }
318
- var checkVitestPeer = (snapshot) => {
319
- const ntVitest = snapshot.installedPackages.get("@narrativetrace/vitest");
320
- const range = ntVitest?.peerDependencies?.vitest;
321
- if (!ntVitest || !range) {
322
- return pass(
323
- ID11,
324
- "@narrativetrace/vitest is not installed \u2014 nothing to check",
325
- DOC.vitestConfiguration
326
- );
327
- }
328
- return evaluateInstalledVitest(range, snapshot.installedPackages.get("vitest"));
329
- };
330
21
 
331
- // src/doctor/doctor.ts
332
- var DOCTOR_CHECKS = [
333
- checkNodeEngine,
334
- checkVitestPeer,
335
- checkSiblingPackages,
336
- checkOutputEnv,
337
- checkReporterSubpath,
338
- checkTraceObjectKeys,
339
- checkSilentSink,
340
- checkParameterArg0,
341
- checkRedactionProof,
342
- checkApprovalTraces,
343
- checkLlmsBeforeYouStart
344
- ];
345
- function runDoctor(snapshot) {
346
- const findings = DOCTOR_CHECKS.map((check) => check(snapshot));
347
- const exitCode2 = findings.some((f) => f.status === "fail") ? 1 : 0;
348
- return { findings, exitCode: exitCode2 };
349
- }
22
+ // src/cli.ts
23
+ var import_tooling3 = require("@narrativetrace/tooling");
350
24
 
351
- // src/doctor/render.ts
352
- function renderFinding(finding) {
353
- const tag = finding.status === "fail" ? "FAIL" : "PASS";
354
- const lines = [`[${tag}] ${finding.id} \u2014 ${finding.message}`];
355
- if (finding.status === "fail") {
356
- lines.push(` fix: ${finding.fix}`, ` docs: ${finding.docUrl}`);
357
- }
358
- lines.push("");
359
- return lines;
360
- }
361
- function renderHuman(report) {
362
- const failing = report.findings.filter((f) => f.status === "fail");
363
- const passing = report.findings.filter((f) => f.status === "pass");
364
- const header = `narrativetrace doctor \u2014 ${report.findings.length} check(s), ${failing.length} finding(s)`;
365
- const summary = failing.length === 0 ? "All checks passed." : `${failing.length} finding(s). Exit code ${report.exitCode}.`;
366
- const body = [...failing, ...passing].flatMap(renderFinding);
367
- return [header, "", ...body, summary].join("\n");
368
- }
369
- function renderJson(report) {
370
- return JSON.stringify(report, null, 2);
25
+ // src/installer-arguments.ts
26
+ var import_tooling2 = require("@narrativetrace/tooling");
27
+ var VALUE_FLAGS = /* @__PURE__ */ new Set(["--only", "--vendor", "--from"]);
28
+ var SWITCHES = /* @__PURE__ */ new Map([
29
+ [
30
+ "--dry-run",
31
+ (reading) => {
32
+ reading.draft.dryRun = true;
33
+ }
34
+ ],
35
+ [
36
+ "--write-existing",
37
+ (reading) => {
38
+ reading.draft.writeExisting = true;
39
+ }
40
+ ],
41
+ [
42
+ "--force",
43
+ (reading) => {
44
+ reading.draft.force = true;
45
+ }
46
+ ],
47
+ [
48
+ "--json",
49
+ (reading) => {
50
+ reading.json = true;
51
+ }
52
+ ],
53
+ [
54
+ "--help",
55
+ (reading) => {
56
+ reading.help = true;
57
+ }
58
+ ],
59
+ [
60
+ "-h",
61
+ (reading) => {
62
+ reading.help = true;
63
+ }
64
+ ]
65
+ ]);
66
+ function fail(reading, message) {
67
+ reading.error ??= message;
68
+ }
69
+ function readScope(value, reading) {
70
+ if (value === "skills" || value === "agents-md") reading.draft.scope = value;
71
+ else fail(reading, `--only takes skills or agents-md, got "${value}"`);
72
+ }
73
+ function readVendor(value, reading) {
74
+ if (value === "claude") reading.draft.vendorClaude = "on";
75
+ else if (value === "none") reading.draft.vendorClaude = "off";
76
+ else fail(reading, `--vendor takes claude or none, got "${value}"`);
77
+ }
78
+ function nextFlagMessage(name, value) {
79
+ return `${name} needs a value, and "${value}" reads as the next flag \u2014 write ${name}=${value} if it really is the value`;
80
+ }
81
+ function readValued(name, value, spelledOut, reading) {
82
+ if (value === void 0 || value === "") {
83
+ fail(reading, `${name} needs a value`);
84
+ return false;
85
+ }
86
+ if (!spelledOut && value.startsWith("--")) {
87
+ fail(reading, nextFlagMessage(name, value));
88
+ return false;
89
+ }
90
+ if (name === "--only") readScope(value, reading);
91
+ else if (name === "--vendor") readVendor(value, reading);
92
+ else reading.from = value;
93
+ return true;
94
+ }
95
+ function splitArgument(argument) {
96
+ const equals = argument.indexOf("=");
97
+ return equals < 0 ? { name: argument, spelled: void 0 } : { name: argument.slice(0, equals), spelled: argument.slice(equals + 1) };
98
+ }
99
+ function readOne(args, index, reading) {
100
+ const argument = args[index];
101
+ const { name, spelled } = splitArgument(argument);
102
+ const turnOn = SWITCHES.get(name);
103
+ if (turnOn !== void 0) {
104
+ if (spelled === void 0) turnOn(reading);
105
+ else fail(reading, `${name} takes no value, got "${argument}"`);
106
+ return index;
107
+ }
108
+ if (!VALUE_FLAGS.has(name)) {
109
+ fail(reading, `unknown option: "${argument}"`);
110
+ return index;
111
+ }
112
+ const valueIndex = spelled === void 0 ? index + 1 : index;
113
+ const value = spelled ?? args[valueIndex];
114
+ return readValued(name, value, spelled !== void 0, reading) ? valueIndex : index;
115
+ }
116
+ function parseInstallerArguments(args) {
117
+ if (args == null) throw new TypeError("a command line is a list of arguments, never null");
118
+ const reading = {
119
+ draft: {},
120
+ from: void 0,
121
+ json: false,
122
+ help: false,
123
+ error: void 0
124
+ };
125
+ for (let index = 0; index < args.length; index += 1) index = readOne(args, index, reading);
126
+ return Object.freeze({
127
+ options: (0, import_tooling2.initOptions)(reading.draft),
128
+ from: reading.from,
129
+ json: reading.json,
130
+ help: reading.help,
131
+ error: reading.error
132
+ });
371
133
  }
372
134
 
373
135
  // src/cli.ts
@@ -375,9 +137,14 @@ var USAGE = `narrativetrace \u2014 one CLI over NarrativeTrace's open artifact f
375
137
 
376
138
  Usage:
377
139
  narrativetrace doctor [--json]
140
+ narrativetrace init [--dry-run] [--write-existing] [--force] [--only <half>] [--vendor <vendor>]
141
+ [--from <dir>] [--json]
142
+ narrativetrace uninstall [--dry-run] [--only <half>] [--json]
378
143
 
379
144
  Commands:
380
- doctor Read-only project diagnosis: toolchain, configuration, and known traps. Zero network.
145
+ doctor Read-only project diagnosis: toolchain, configuration, and known traps. Zero network.
146
+ init Installs the NarrativeTrace agent skills and the AGENTS.md section into this project.
147
+ uninstall Removes exactly what init wrote, and nothing beside it.
381
148
 
382
149
  Options:
383
150
  --json Machine-readable output instead of human text.
@@ -386,6 +153,43 @@ var DOCTOR_USAGE = `narrativetrace doctor [--json]
386
153
 
387
154
  Read-only. Checks toolchain/install state, configuration, and known traps against the current
388
155
  project. Exit 0 = clean, 1 = findings, 2 = could not run.`;
156
+ var INSTALLER_OPTIONS = ` --dry-run Show the plan and the unified diff. Writes nothing, always exits 0.
157
+ --write-existing Permission to touch an AGENTS.md or CLAUDE.md that is already there.
158
+ --force Permission to overwrite a skill directory somebody else owns.
159
+ --only <half> skills | agents-md. Both halves by default.
160
+ --vendor <vendor> claude | none. Detected from the project by default.
161
+ --from <dir> A directory holding the carrier: a checked-out @narrativetrace/skills, or an
162
+ unpacked tarball of one. The copy bundled with this CLI by default.
163
+ --json Machine-readable output instead of human text.
164
+
165
+ Exit 0 = applied (or a dry run), 1 = something was refused, 2 = could not run.`;
166
+ var INIT_USAGE = `narrativetrace init [options]
167
+
168
+ Copies the NarrativeTrace agent skills into .agents/skills/ (and .claude/skills/ where the project
169
+ is one of that vendor's) and writes one marked section into AGENTS.md. Zero network.
170
+
171
+ Run it again to refresh: this CLI installs no build hook and schedules nothing, so a re-run IS the
172
+ refresh. On a project that already carries the skills it rewrites only our own pages and our own
173
+ marked section, and leaves everything else where it is.
174
+
175
+ ${INSTALLER_OPTIONS}`;
176
+ var UNINSTALL_OPTIONS = ` --dry-run Show the plan and the unified diff. Removes nothing, always exits 0.
177
+ --only <half> skills | agents-md. Both halves by default.
178
+ --json Machine-readable output instead of human text.
179
+
180
+ The install-only flags (--write-existing, --force, --vendor, --from) are accepted and ignored: there
181
+ is no carrier to open and no permission to ask for when removing what this tool itself wrote.
182
+
183
+ Exit 0 = removed (or a dry run), 1 = something was refused, 2 = could not run.`;
184
+ var UNINSTALL_USAGE = `narrativetrace uninstall [options]
185
+
186
+ Removes exactly what init wrote: skill directories carrying its provenance line, the marked
187
+ section, and the one @AGENTS.md import line. Never touches anything else.
188
+
189
+ ${UNINSTALL_OPTIONS}`;
190
+ var CARRIER_HINT = `Nothing was fetched: this command makes no network call. Point --from at a carrier you have:
191
+ --from node_modules/@narrativetrace/skills
192
+ npm pack @narrativetrace/skills && tar xzf narrativetrace-skills-*.tgz && narrativetrace init --from package`;
389
193
  function runDoctorCommand(rest, deps) {
390
194
  if (rest.includes("--help") || rest.includes("-h")) {
391
195
  deps.log(DOCTOR_USAGE);
@@ -404,179 +208,96 @@ ${DOCTOR_USAGE}`);
404
208
  deps.error(`Could not run: no readable package.json found at ${deps.cwd}`);
405
209
  return 2;
406
210
  }
407
- const report = runDoctor(snapshot);
408
- deps.log(json ? renderJson(report) : renderHuman(report));
409
- return report.exitCode;
211
+ const report2 = (0, import_tooling3.runDoctor)(snapshot);
212
+ deps.log(json ? (0, import_tooling3.renderJson)(report2) : (0, import_tooling3.renderHuman)(report2));
213
+ return report2.exitCode;
410
214
  }
411
- function runCli(argv, deps) {
412
- const [verb, ...rest] = argv;
413
- if (verb === void 0) {
414
- deps.error(USAGE);
415
- return 2;
416
- }
417
- if (verb === "--help" || verb === "-h") {
418
- deps.log(USAGE);
419
- return 0;
215
+ function report(plan, parsed, deps) {
216
+ const options = { json: parsed.json };
217
+ if (parsed.options.dryRun) {
218
+ deps.print((0, import_tooling3.renderPlan)(plan, options));
219
+ return (0, import_tooling3.planExitCode)(plan);
420
220
  }
421
- if (verb !== "doctor") {
422
- deps.error(`Unknown command: ${verb}
423
-
424
- ${USAGE}`);
425
- return 2;
426
- }
427
- return runDoctorCommand(rest, deps);
221
+ const executed = (0, import_tooling3.applyPlan)(plan, deps.cwd);
222
+ deps.print((0, import_tooling3.renderReport)(executed, options));
223
+ return (0, import_tooling3.reportExitCode)(executed);
428
224
  }
429
-
430
- // src/doctor/environment.ts
431
- var import_node_fs = require("fs");
432
- var import_node_module = require("module");
433
- var import_node_path = require("path");
434
- var EXCLUDED_DIRS = /* @__PURE__ */ new Set([
435
- // Stryker disable next-line StringLiteral: equivalent — every dot-prefixed entry name is
436
- // already excluded earlier, in visitEntry's leading-dot check (the only exception there is
437
- // ".env", which isn't a directory this set would ever mention), so ".git" never actually
438
- // reaches this set's `.has()` check.
439
- ".git",
440
- // Stryker disable next-line StringLiteral: same equivalence as ".git" above.
441
- ".turbo",
442
- // Stryker disable next-line StringLiteral: same equivalence as ".git" above.
443
- ".stryker-tmp",
444
- // Not dot-prefixed, so NOT equivalent — reaching this set's `.has()` check is the only thing
445
- // that excludes each of the four names below; each is covered by a real test.
446
- "node_modules",
447
- "dist",
448
- "build",
449
- "coverage"
450
- ]);
451
- var SOURCE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"];
452
- var BASE_PACKAGES = [
453
- "vitest",
454
- "@narrativetrace/core",
455
- "@narrativetrace/core-node",
456
- "@narrativetrace/vitest"
457
- ];
458
- var MAX_FILES = 2e4;
459
- function readJson(path) {
225
+ function runPlan(planner, parsed, deps) {
460
226
  try {
461
- return JSON.parse((0, import_node_fs.readFileSync)(path, "utf8"));
462
- } catch {
463
- return void 0;
227
+ return report(planner((0, import_tooling3.readProjectState)(deps.cwd, deps.env)), parsed, deps);
228
+ } catch (cause) {
229
+ deps.error(cause.message);
230
+ return 1;
464
231
  }
465
232
  }
466
- function resolvePackageJson(name, cwd) {
467
- try {
468
- const require2 = (0, import_node_module.createRequire)((0, import_node_path.join)(cwd, "package.json"));
469
- return readJson(require2.resolve(`${name}/package.json`));
470
- } catch {
471
- return void 0;
472
- }
233
+ function noteVersions(carrier, state, deps) {
234
+ const warning = (0, import_tooling3.carrierVersionWarning)(carrier, state);
235
+ if (warning !== void 0) deps.error(warning);
473
236
  }
474
- function isDirectorySafe(path) {
237
+ function runInit(parsed, deps) {
238
+ let carrier;
475
239
  try {
476
- return (0, import_node_fs.statSync)(path).isDirectory();
477
- } catch {
478
- return void 0;
479
- }
480
- }
481
- function safeRead(path) {
482
- try {
483
- return (0, import_node_fs.readFileSync)(path, "utf8");
484
- } catch {
485
- return "";
486
- }
240
+ carrier = deps.openCarrier(parsed.from);
241
+ } catch (cause) {
242
+ deps.error(`${cause.message}
243
+
244
+ ${CARRIER_HINT}`);
245
+ return 1;
246
+ }
247
+ return runPlan(
248
+ (state) => {
249
+ noteVersions(carrier, state, deps);
250
+ return (0, import_tooling3.planInstall)(state, carrier, parsed.options);
251
+ },
252
+ parsed,
253
+ deps
254
+ );
487
255
  }
488
- function listDirectory(dir) {
489
- try {
490
- return (0, import_node_fs.readdirSync)(dir);
491
- } catch {
492
- return [];
256
+ function runInstaller(install, rest, deps) {
257
+ const parsed = parseInstallerArguments(rest);
258
+ const usage = install ? INIT_USAGE : UNINSTALL_USAGE;
259
+ if (parsed.help) {
260
+ deps.log(usage);
261
+ return 0;
493
262
  }
494
- }
495
- function classify(rel, outputDirName, approvedDirName) {
496
- const topSegment = rel.split("/")[0];
497
- if (topSegment === outputDirName) return "output";
498
- if (topSegment === approvedDirName) return "approved";
499
- return SOURCE_EXTENSIONS.some((ext) => rel.endsWith(ext)) ? "source" : "skip";
500
- }
501
- function bucketFor(state, kind) {
502
- return kind === "output" ? state.outputFiles : kind === "approved" ? state.approvedDirFiles : state.sourceFiles;
503
- }
504
- function visitEntry(ctx, dir, entry) {
505
- if (entry.startsWith(".") && entry !== ".env") return;
506
- const full = (0, import_node_path.join)(dir, entry);
507
- const isDir = isDirectorySafe(full);
508
- if (isDir === void 0) return;
509
- if (isDir) {
510
- if (!EXCLUDED_DIRS.has(entry)) ctx.queue.push(full);
511
- return;
263
+ if (parsed.error !== void 0) {
264
+ deps.error(`${parsed.error}
265
+
266
+ ${usage}`);
267
+ return 2;
512
268
  }
513
- ctx.state.visited++;
514
- const rel = (0, import_node_path.relative)(ctx.root, full);
515
- const kind = classify(rel, ctx.output, ctx.approved);
516
- if (kind === "skip") return;
517
- bucketFor(ctx.state, kind).set(rel, safeRead(full));
269
+ return install ? runInit(parsed, deps) : runPlan((state) => (0, import_tooling3.planUninstall)(state, parsed.options), parsed, deps);
518
270
  }
519
- function walk(root, output, approved) {
520
- const state = {
521
- sourceFiles: /* @__PURE__ */ new Map(),
522
- outputFiles: /* @__PURE__ */ new Map(),
523
- approvedDirFiles: /* @__PURE__ */ new Map(),
524
- visited: 0
525
- };
526
- const ctx = { state, root, output, approved, queue: [root] };
527
- while (ctx.queue.length > 0 && state.visited < MAX_FILES) {
528
- const dir = ctx.queue.shift();
529
- for (const entry of listDirectory(dir)) {
530
- if (state.visited >= MAX_FILES) break;
531
- visitEntry(ctx, dir, entry);
532
- }
271
+ function runCli(argv, deps) {
272
+ const [verb, ...rest] = argv;
273
+ if (verb === void 0) {
274
+ deps.error(USAGE);
275
+ return 2;
533
276
  }
534
- return state;
535
- }
536
- function resolveBasePackages(cwd) {
537
- const installed = /* @__PURE__ */ new Map();
538
- for (const name of BASE_PACKAGES) {
539
- const pkg = resolvePackageJson(name, cwd);
540
- if (pkg) installed.set(name, pkg);
277
+ if (verb === "--help" || verb === "-h") {
278
+ deps.log(USAGE);
279
+ return 0;
541
280
  }
542
- return installed;
543
- }
544
- function resolveVitestSiblings(cwd, installed) {
545
- const ntVitest = installed.get("@narrativetrace/vitest");
546
- for (const name of Object.keys(ntVitest?.dependencies ?? {})) {
547
- if (!name.startsWith("@narrativetrace/") || installed.has(name)) continue;
548
- const pkg = resolvePackageJson(name, cwd);
549
- if (pkg) installed.set(name, pkg);
281
+ if (verb === "doctor") return runDoctorCommand(rest, deps);
282
+ if (verb === "init" || verb === "uninstall") {
283
+ return runInstaller(verb === "init", rest, deps);
550
284
  }
551
- }
552
- function resolveInstalledPackages(cwd) {
553
- const installed = resolveBasePackages(cwd);
554
- resolveVitestSiblings(cwd, installed);
555
- return installed;
556
- }
557
- function buildSnapshot(cwd, env) {
558
- const output = env.NARRATIVETRACE_OUTPUT_DIR ?? "narrativetrace-output";
559
- const approved = env.NARRATIVETRACE_APPROVED_DIR ?? "narratives";
560
- const { sourceFiles, outputFiles, approvedDirFiles } = walk(cwd, output, approved);
561
- return {
562
- cwd,
563
- nodeVersion: process.version.replace(/^v/, ""),
564
- env,
565
- rootPackageJson: readJson((0, import_node_path.join)(cwd, "package.json")),
566
- sourceFiles,
567
- outputFiles,
568
- approvedDirFiles,
569
- installedPackages: resolveInstalledPackages(cwd)
570
- };
285
+ deps.error(`Unknown command: ${verb}
286
+
287
+ ${USAGE}`);
288
+ return 2;
571
289
  }
572
290
 
573
291
  // src/cli-bin.ts
292
+ var cwd = process.cwd();
574
293
  var exitCode = runCli(process.argv.slice(2), {
575
- cwd: process.cwd(),
294
+ cwd,
576
295
  env: process.env,
577
- buildSnapshot,
296
+ buildSnapshot: (c, e) => (0, import_tooling4.buildSnapshot)(c, e, cliPackageDirectory()),
297
+ openCarrier: (from) => openCarrierFor(cwd, from),
578
298
  log: (message) => process.stdout.write(`${message}
579
299
  `),
300
+ print: (text) => process.stdout.write(text),
580
301
  error: (message) => process.stderr.write(`${message}
581
302
  `)
582
303
  });