@ctxr/skill-llm-wiki 1.0.1 → 1.0.2

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/scripts/cli.mjs CHANGED
@@ -122,15 +122,23 @@ function _depPreflightFailMessage(missing) {
122
122
  );
123
123
  }
124
124
 
125
- // Skip the dep check entirely for --version and --help so an operator
126
- // debugging a broken install can still get version/usage output. Every
127
- // other invocation (including `--help` placed AFTER another arg, which
128
- // is a malformed invocation we don't need to coddle) runs the check.
125
+ // Skip the dep check entirely for --version, --help, `contract`, and
126
+ // `where` so an operator debugging a broken install can still get
127
+ // version/usage output, and so consumers can probe the contract
128
+ // before the runtime dependencies are necessarily resolved. Every
129
+ // other invocation (including `--help` placed AFTER another arg,
130
+ // which is a malformed invocation we don't need to coddle) runs the
131
+ // check.
129
132
  const _argvDP = process.argv.slice(2);
130
- const _isVersionOrHelpDP =
131
- _argvDP[0] === "--version" || _argvDP[0] === "--help" || _argvDP[0] === "-h";
133
+ const _isPreflightExemptDP =
134
+ _argvDP[0] === "--version" ||
135
+ _argvDP[0] === "--help" ||
136
+ _argvDP[0] === "-h" ||
137
+ _argvDP[0] === "contract" ||
138
+ _argvDP[0] === "where" ||
139
+ _argvDP[0] === "testkit-stub";
132
140
 
133
- if (!_isVersionOrHelpDP) {
141
+ if (!_isPreflightExemptDP) {
134
142
  let _missingDP = _depPreflightCheck();
135
143
  if (_missingDP.length > 0) {
136
144
  process.stderr.write(_depPreflightFailMessage(_missingDP));
@@ -288,6 +296,18 @@ Remote mirroring (explicit user-invoked only, never auto-pushes):
288
296
  remote <wiki> list List configured remotes
289
297
  sync <wiki> [--remote <name>] Fetch + push tag refs explicitly
290
298
 
299
+ Consumer-facing scaffolding:
300
+ init <topic> --kind dated|subject [--template <name>] [--force] [--json]
301
+ Seed a topic directory with a shipped
302
+ layout contract. Prints the exact build
303
+ command to run next. Replaces the
304
+ cp + edit + build-flag dance.
305
+ heal <wiki> [--json] Classify validate findings into
306
+ ok / fixable / needs-rebuild / broken
307
+ and name the next command to run.
308
+ Routes consumers through the right
309
+ mutating op without guessing.
310
+
291
311
  Low-level script helpers (deterministic, called by Claude):
292
312
  ingest <source> Walk source, emit candidate JSON
293
313
  draft-leaf <candidate-file> Script-first frontmatter draft for one candidate
@@ -312,12 +332,30 @@ Tiered-AI flags:
312
332
 
313
333
  UX flags:
314
334
  --no-prompt Never prompt; fail loud on ambiguity
315
- --json-errors Emit ambiguity errors as JSON
335
+ --json Emit JSON on stdout. Operational
336
+ commands (validate, init, heal, rollback)
337
+ use the envelope schema
338
+ skill-llm-wiki/v1 for both success and
339
+ usage-error paths. Probe commands
340
+ (contract, where) emit their own
341
+ command-specific schemas
342
+ (skill-llm-wiki/contract/v1 and
343
+ skill-llm-wiki/where/v1).
344
+ --json-errors Legacy alias for --json, kept for
345
+ backwards compatibility.
316
346
  --accept-dirty Operate on a dirty user git repo
317
347
 
318
348
  Rollback flags:
319
349
  --to <ref> genesis | <op-id> | pre-<op-id> | HEAD~N
320
350
 
351
+ Consumer probes (exempt from runtime-dep preflight):
352
+ contract [--json] Print machine-readable format + CLI surface
353
+ contract. Consumers gate on format_version
354
+ instead of drift-testing SKILL.md.
355
+ where [--json] Print absolute paths to the skill root,
356
+ SKILL.md, guide/, templates/, and testkit/.
357
+ Resolves the install path without kit lookup.
358
+
321
359
  Global:
322
360
  --version Print CLI version
323
361
  --help, -h Show this help
@@ -343,6 +381,7 @@ const FLAG_WITH_VALUE = new Set([
343
381
  ]);
344
382
  const FLAG_BOOLEAN = new Set([
345
383
  "--no-prompt",
384
+ "--json",
346
385
  "--json-errors",
347
386
  "--accept-dirty",
348
387
  "--accept-foreign-target",
@@ -437,6 +476,74 @@ async function main() {
437
476
  return;
438
477
  }
439
478
 
479
+ // `contract` and `where` are exempt from the dep preflight (see
480
+ // the preflight-skip list above) so consumers can probe the
481
+ // skill's surface before the runtime deps are resolvable. Both
482
+ // pull only pure-data from scripts/lib/* and do not touch any
483
+ // wiki state.
484
+ if (argv[0] === "contract") {
485
+ const { getContract, renderContractText } = await import("./lib/contract.mjs");
486
+ const { hasJsonFlag } = await import("./lib/json-envelope.mjs");
487
+ const wantJson = hasJsonFlag(argv.slice(1));
488
+ const contract = getContract();
489
+ if (wantJson) {
490
+ process.stdout.write(JSON.stringify(contract, null, 2) + "\n");
491
+ } else {
492
+ process.stdout.write(renderContractText(contract));
493
+ }
494
+ return;
495
+ }
496
+ if (argv[0] === "where") {
497
+ const { getWhere, renderWhereText } = await import("./lib/where.mjs");
498
+ const { hasJsonFlag } = await import("./lib/json-envelope.mjs");
499
+ const wantJson = hasJsonFlag(argv.slice(1));
500
+ const info = getWhere();
501
+ if (wantJson) {
502
+ process.stdout.write(JSON.stringify(info, null, 2) + "\n");
503
+ } else {
504
+ process.stdout.write(renderWhereText(info));
505
+ }
506
+ return;
507
+ }
508
+ if (argv[0] === "testkit-stub") {
509
+ // Shell shorthand for scripts/testkit/stub-skill.mjs. Consumers
510
+ // whose test suites shell out (e.g. zero-deps agent bundles that
511
+ // can't import from node_modules) use this to seed a stub skill
512
+ // install under an arbitrary base directory. Exempt from the
513
+ // runtime-dep preflight because testkit helpers must work in
514
+ // test environments that deliberately lack the runtime deps.
515
+ const rest = argv.slice(1);
516
+ let atDir = null;
517
+ let layout = "claude-skills";
518
+ for (let i = 0; i < rest.length; i++) {
519
+ const tok = rest[i];
520
+ if (tok === "--at") {
521
+ atDir = rest[++i];
522
+ } else if (tok.startsWith("--at=")) {
523
+ atDir = tok.slice("--at=".length);
524
+ } else if (tok === "--layout") {
525
+ layout = rest[++i];
526
+ } else if (tok.startsWith("--layout=")) {
527
+ layout = tok.slice("--layout=".length);
528
+ } else {
529
+ process.stderr.write(
530
+ `testkit-stub: unknown argument "${tok}"\n`,
531
+ );
532
+ process.exit(1);
533
+ }
534
+ }
535
+ if (!atDir) {
536
+ process.stderr.write(
537
+ "testkit-stub: --at <dir> is required (base directory to seed)\n",
538
+ );
539
+ process.exit(1);
540
+ }
541
+ const { stubSkill } = await import("./testkit/stub-skill.mjs");
542
+ const r = await stubSkill({ home: atDir, layout });
543
+ process.stdout.write(`${r.skillMd}\n`);
544
+ return;
545
+ }
546
+
440
547
  // The dependency preflight has already run in the pre-import block
441
548
  // at the top of this file. By the time we reach this point, every
442
549
  // required runtime dep has been verified or the process has exited
@@ -587,7 +694,8 @@ async function main() {
587
694
  if (INTENT_SUBCOMMANDS.has(cmd)) {
588
695
  const parsed = parseSubArgv(args);
589
696
  if (parsed.error) {
590
- const jsonMode = args.includes("--json-errors");
697
+ const { hasJsonFlag } = await import("./lib/json-envelope.mjs");
698
+ const jsonMode = hasJsonFlag(args);
591
699
  emitIntentError(
592
700
  {
593
701
  code: "INT-11",
@@ -599,7 +707,9 @@ async function main() {
599
707
  );
600
708
  }
601
709
  const { positionals, flags } = parsed;
602
- const jsonMode = Boolean(flags.json_errors);
710
+ // `--json` is the canonical flag; `--json-errors` is the legacy
711
+ // alias kept for existing consumers. Either enables JSON output.
712
+ const jsonMode = Boolean(flags.json_errors) || Boolean(flags.json);
603
713
 
604
714
  // `migrate` has its own resolution path — the intent resolver would
605
715
  // reject the legacy folder shape as ambiguous.
@@ -667,6 +777,29 @@ async function main() {
667
777
 
668
778
  if (cmd === "rollback") {
669
779
  const result = rollbackOperation(plan.target, flags.to);
780
+ if (jsonMode) {
781
+ const { makeEnvelope, writeEnvelope } = await import(
782
+ "./lib/json-envelope.mjs"
783
+ );
784
+ writeEnvelope(
785
+ makeEnvelope({
786
+ command: "rollback",
787
+ target: plan.target,
788
+ verdict: "ok",
789
+ exit: 0,
790
+ diagnostics: [
791
+ {
792
+ code: "ROLLBACK-01",
793
+ severity: "info",
794
+ path: plan.target,
795
+ message: `rolled back to ${result.ref} (sha=${result.sha ?? "n/a"})`,
796
+ },
797
+ ],
798
+ artifacts: { modified: [plan.target] },
799
+ }),
800
+ );
801
+ return;
802
+ }
670
803
  process.stdout.write(
671
804
  `rolled back ${plan.target} to ${result.ref} (${result.sha ?? "n/a"})\n`,
672
805
  );
@@ -815,10 +948,59 @@ async function main() {
815
948
  break;
816
949
  }
817
950
  case "validate": {
818
- if (args.length < 1) usageError("validate requires <wiki>");
819
- const wiki = resolve(args[0]);
951
+ // Parse flags so that `validate --json <wiki>` works as well
952
+ // as `validate <wiki> --json`, and unknown flags are rejected
953
+ // loudly. Pre-scan --json first so usage errors emitted below
954
+ // respect the JSON contract.
955
+ const { hasJsonFlag } = await import("./lib/json-envelope.mjs");
956
+ const wantJson = hasJsonFlag(args);
957
+ const positionals = [];
958
+ for (const tok of args) {
959
+ if (tok === "--json" || tok === "--json-errors") continue;
960
+ if (typeof tok === "string" && tok.startsWith("--")) {
961
+ await emitConsumerUsageError(
962
+ "validate",
963
+ "VALIDATE-USAGE",
964
+ `validate does not support option ${tok}`,
965
+ wantJson,
966
+ );
967
+ }
968
+ positionals.push(tok);
969
+ }
970
+ if (positionals.length < 1)
971
+ await emitConsumerUsageError(
972
+ "validate",
973
+ "VALIDATE-USAGE",
974
+ "validate requires <wiki>",
975
+ wantJson,
976
+ );
977
+ if (positionals.length > 1)
978
+ await emitConsumerUsageError(
979
+ "validate",
980
+ "VALIDATE-USAGE",
981
+ "validate accepts exactly one <wiki>",
982
+ wantJson,
983
+ );
984
+ const wiki = resolve(positionals[0]);
985
+ const startMs = Date.now();
820
986
  const findings = validateWiki(wiki);
821
987
  const summary = summariseFindings(findings);
988
+ const exit = summary.errors > 0 ? 2 : 0;
989
+ if (wantJson) {
990
+ const { findingToDiagnostic, makeEnvelope, writeEnvelope } =
991
+ await import("./lib/json-envelope.mjs");
992
+ writeEnvelope(
993
+ makeEnvelope({
994
+ command: "validate",
995
+ target: wiki,
996
+ verdict: exit === 0 ? "ok" : "broken",
997
+ exit,
998
+ diagnostics: findings.map(findingToDiagnostic),
999
+ timing_ms: Date.now() - startMs,
1000
+ }),
1001
+ );
1002
+ process.exit(exit);
1003
+ }
822
1004
  for (const f of findings) {
823
1005
  const tag =
824
1006
  f.severity === "error"
@@ -832,7 +1014,7 @@ async function main() {
832
1014
  console.log(
833
1015
  `\n${summary.errors} error(s), ${summary.warnings} warning(s)`,
834
1016
  );
835
- process.exit(summary.errors > 0 ? 2 : 0);
1017
+ process.exit(exit);
836
1018
  break;
837
1019
  }
838
1020
  case "shape-check": {
@@ -875,12 +1057,290 @@ async function main() {
875
1057
  process.stdout.write(`current → ${args[1]}\n`);
876
1058
  break;
877
1059
  }
1060
+ case "init": {
1061
+ // `init` seeds a topic directory with a shipped layout contract
1062
+ // so consumers can start building hosted wikis without the
1063
+ // cp-then-build dance. No orchestrator invocation here — that
1064
+ // remains the consumer's explicit `build` call so error paths
1065
+ // stay where they already are.
1066
+ await cmdInit(args);
1067
+ break;
1068
+ }
1069
+ case "heal": {
1070
+ // `heal` classifies validate findings into one of ok, fixable,
1071
+ // needs-rebuild, broken and names the next command to run.
1072
+ // Mutating action stays the consumer's explicit call so the
1073
+ // orchestrator error surface does not leak into this
1074
+ // classify-only path.
1075
+ await cmdHeal(args);
1076
+ break;
1077
+ }
878
1078
  default:
879
1079
  printUsage();
880
1080
  process.exit(1);
881
1081
  }
882
1082
  }
883
1083
 
1084
+ async function cmdInit(args) {
1085
+ const { runInit, InitError, renderInitText } = await import("./lib/init.mjs");
1086
+ const {
1087
+ hasJsonFlag,
1088
+ makeEnvelope,
1089
+ makeErrorEnvelope,
1090
+ writeEnvelope,
1091
+ } = await import("./lib/json-envelope.mjs");
1092
+ const wantJson = hasJsonFlag(args);
1093
+ const fail = (code, message, topic = null) =>
1094
+ initError(code, message, wantJson, topic, {
1095
+ makeErrorEnvelope,
1096
+ writeEnvelope,
1097
+ });
1098
+
1099
+ // Minimal flag parse for init. Positional is <topic>; flags are
1100
+ // --kind <dated|subject>, --template <name>, --force, --json.
1101
+ let topic = null;
1102
+ let kind = null;
1103
+ let template = null;
1104
+ let force = false;
1105
+ for (let i = 0; i < args.length; i++) {
1106
+ const tok = args[i];
1107
+ if (tok === "--force") {
1108
+ force = true;
1109
+ continue;
1110
+ }
1111
+ if (tok === "--json" || tok === "--json-errors") {
1112
+ continue;
1113
+ }
1114
+ if (tok === "--kind") {
1115
+ kind = args[++i];
1116
+ if (kind === undefined || kind === "" || kind.startsWith("--")) {
1117
+ fail("INIT-00", `init: flag "--kind" requires a value`);
1118
+ }
1119
+ continue;
1120
+ }
1121
+ if (tok.startsWith("--kind=")) {
1122
+ kind = tok.slice("--kind=".length);
1123
+ if (kind === "") {
1124
+ fail("INIT-00", `init: flag "--kind" requires a value`);
1125
+ }
1126
+ continue;
1127
+ }
1128
+ if (tok === "--template") {
1129
+ template = args[++i];
1130
+ if (
1131
+ template === undefined ||
1132
+ template === "" ||
1133
+ template.startsWith("--")
1134
+ ) {
1135
+ fail("INIT-00", `init: flag "--template" requires a value`);
1136
+ }
1137
+ continue;
1138
+ }
1139
+ if (tok.startsWith("--template=")) {
1140
+ template = tok.slice("--template=".length);
1141
+ if (template === "") {
1142
+ fail("INIT-00", `init: flag "--template" requires a value`);
1143
+ }
1144
+ continue;
1145
+ }
1146
+ if (tok.startsWith("--")) {
1147
+ fail("INIT-00", `init: unknown flag "${tok}"`);
1148
+ }
1149
+ if (topic === null) {
1150
+ topic = tok;
1151
+ continue;
1152
+ }
1153
+ fail("INIT-00", `init: unexpected positional "${tok}"`);
1154
+ }
1155
+ if (!topic) {
1156
+ fail("INIT-01", "init requires a <topic> path");
1157
+ }
1158
+
1159
+ const startMs = Date.now();
1160
+ let result;
1161
+ try {
1162
+ result = runInit({ topic, kind, template, force, cwd: process.cwd() });
1163
+ } catch (err) {
1164
+ if (err instanceof InitError) {
1165
+ fail(err.code, err.message);
1166
+ }
1167
+ throw err;
1168
+ }
1169
+
1170
+ if (wantJson) {
1171
+ writeEnvelope(
1172
+ makeEnvelope({
1173
+ command: "init",
1174
+ target: result.topic,
1175
+ verdict: "initialised",
1176
+ exit: 0,
1177
+ diagnostics: [
1178
+ {
1179
+ code: "NEXT-01",
1180
+ severity: "info",
1181
+ path: result.topic,
1182
+ message:
1183
+ `contract seeded; next step: ` +
1184
+ result.build_command.join(" "),
1185
+ },
1186
+ ],
1187
+ artifacts: { created: [result.contract_path] },
1188
+ next: result.next,
1189
+ timing_ms: Date.now() - startMs,
1190
+ }),
1191
+ );
1192
+ return;
1193
+ }
1194
+ process.stdout.write(renderInitText(result));
1195
+ }
1196
+
1197
+ async function cmdHeal(args) {
1198
+ const { runHeal, renderHealText } = await import("./lib/heal.mjs");
1199
+ const { findingToDiagnostic, hasJsonFlag, makeEnvelope, writeEnvelope } =
1200
+ await import("./lib/json-envelope.mjs");
1201
+
1202
+ const wantJson = hasJsonFlag(args);
1203
+ let wikiPath = null;
1204
+ // `--dry-run` is accepted for contract compatibility. heal is
1205
+ // already classify-only (never mutates) so --dry-run is a no-op
1206
+ // label today; the flag will become meaningful once a follow-up
1207
+ // adds --apply for inline fix/rebuild routing.
1208
+ for (const tok of args) {
1209
+ if (tok === "--json" || tok === "--json-errors") continue;
1210
+ if (tok === "--dry-run") continue;
1211
+ if (tok.startsWith("--")) {
1212
+ await emitConsumerUsageError(
1213
+ "heal",
1214
+ "HEAL-USAGE",
1215
+ `heal: unknown flag "${tok}"`,
1216
+ wantJson,
1217
+ );
1218
+ }
1219
+ if (wikiPath === null) {
1220
+ wikiPath = tok;
1221
+ continue;
1222
+ }
1223
+ await emitConsumerUsageError(
1224
+ "heal",
1225
+ "HEAL-USAGE",
1226
+ `heal: unexpected positional "${tok}"`,
1227
+ wantJson,
1228
+ );
1229
+ }
1230
+ if (!wikiPath) {
1231
+ await emitConsumerUsageError(
1232
+ "heal",
1233
+ "HEAL-USAGE",
1234
+ "heal requires <wiki> as its first argument",
1235
+ wantJson,
1236
+ );
1237
+ }
1238
+
1239
+ const absWiki = resolve(wikiPath);
1240
+ const startMs = Date.now();
1241
+ const result = runHeal(absWiki);
1242
+
1243
+ const diagnostics = result.findings.map(findingToDiagnostic);
1244
+ if (result.next_command) {
1245
+ diagnostics.push({
1246
+ code: "NEXT-01",
1247
+ severity: "info",
1248
+ path: result.target,
1249
+ message: `next: ${result.next_command.join(" ")}`,
1250
+ });
1251
+ }
1252
+ if (result.error) {
1253
+ diagnostics.unshift({
1254
+ code: "HEAL-00",
1255
+ severity: "error",
1256
+ path: result.target,
1257
+ message: result.error,
1258
+ });
1259
+ }
1260
+
1261
+ // heal is advisory, not a validator. Successful classification is
1262
+ // exit 0 regardless of verdict (ok / fixable / needs-rebuild) — the
1263
+ // envelope's `verdict` carries the state. Only a genuinely broken
1264
+ // or unclassifiable wiki is exit 6 (matches the documented "wiki
1265
+ // corrupt" meaning). Consumers gate on the envelope, not exit 2.
1266
+ const exit =
1267
+ result.verdict === "broken" || result.verdict === "ambiguous" ? 6 : 0;
1268
+ const nextField = result.next_command
1269
+ ? {
1270
+ command: result.next_command[0],
1271
+ args: result.next_command.slice(1),
1272
+ }
1273
+ : null;
1274
+ if (wantJson) {
1275
+ writeEnvelope(
1276
+ makeEnvelope({
1277
+ command: "heal",
1278
+ target: result.target,
1279
+ verdict: result.verdict,
1280
+ exit,
1281
+ diagnostics,
1282
+ next: nextField,
1283
+ timing_ms: Date.now() - startMs,
1284
+ }),
1285
+ );
1286
+ process.exit(exit);
1287
+ }
1288
+
1289
+ process.stdout.write(renderHealText(result));
1290
+ process.exit(exit);
1291
+ }
1292
+
1293
+ // Shared usage-error emitter for operational subcommands that
1294
+ // need to honour --json on the error path as well as success.
1295
+ // Emits the envelope (verdict="ambiguous", exit=1 for usage)
1296
+ // when wantJson, otherwise a plain-text stderr line + exit.
1297
+ // Used by validate, heal, and any future consumer-facing op
1298
+ // whose usage errors must stay parseable.
1299
+ async function emitConsumerUsageError(command, code, message, wantJson) {
1300
+ if (wantJson) {
1301
+ const { makeErrorEnvelope, writeEnvelope } = await import(
1302
+ "./lib/json-envelope.mjs"
1303
+ );
1304
+ writeEnvelope(
1305
+ makeErrorEnvelope({ command, code, message, exit: 1 }),
1306
+ );
1307
+ process.exit(1);
1308
+ }
1309
+ process.stderr.write(`error: ${message}\n`);
1310
+ process.exit(1);
1311
+ }
1312
+
1313
+ // Map init error codes to the skill's documented exit scheme.
1314
+ // INIT-00 / INIT-01 are CLI usage errors (missing flag, unknown
1315
+ // flag) → exit 1. INIT-02..08 are validation / ambiguity conditions
1316
+ // (bad --kind, template mismatch, contract collision, topic-as-
1317
+ // file, symlink refusal) → exit 2, matching how build/extend etc.
1318
+ // surface validation failures. INIT-08 specifically covers the
1319
+ // symlink-guard path (scripts/lib/init.mjs): the input path exists
1320
+ // but is a symbolic link we refuse to write through; consistent
1321
+ // with INIT-06 ("exists but not a directory") in both severity and
1322
+ // consumer recovery (fix the path, retry).
1323
+ const INIT_USAGE_CODES = new Set(["INIT-00", "INIT-01"]);
1324
+
1325
+ function initError(code, message, wantJson, topic, envelopeHelpers) {
1326
+ const exit = INIT_USAGE_CODES.has(code) ? 1 : 2;
1327
+ if (wantJson && envelopeHelpers) {
1328
+ const { makeErrorEnvelope, writeEnvelope } = envelopeHelpers;
1329
+ writeEnvelope(
1330
+ makeErrorEnvelope({
1331
+ command: "init",
1332
+ code,
1333
+ message,
1334
+ target: topic,
1335
+ exit,
1336
+ }),
1337
+ );
1338
+ process.exit(exit);
1339
+ }
1340
+ process.stderr.write(`error: ${code} ${message}\n`);
1341
+ process.exit(exit);
1342
+ }
1343
+
884
1344
  function usageError(msg) {
885
1345
  process.stderr.write(`error: ${msg}\n`);
886
1346
  printUsage(process.stderr);