@elmundi/ship-cli 0.8.1 → 0.11.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.
Files changed (76) hide show
  1. package/README.md +415 -22
  2. package/bin/shipctl.mjs +165 -0
  3. package/lib/adapters/_fs.mjs +165 -0
  4. package/lib/adapters/agents/index.mjs +26 -0
  5. package/lib/adapters/ci/azure-pipelines.mjs +23 -0
  6. package/lib/adapters/ci/buildkite.mjs +24 -0
  7. package/lib/adapters/ci/circleci.mjs +23 -0
  8. package/lib/adapters/ci/gh-actions.mjs +29 -0
  9. package/lib/adapters/ci/gitlab-ci.mjs +23 -0
  10. package/lib/adapters/ci/jenkins.mjs +23 -0
  11. package/lib/adapters/ci/manual.mjs +18 -0
  12. package/lib/adapters/index.mjs +122 -0
  13. package/lib/adapters/language/dart.mjs +23 -0
  14. package/lib/adapters/language/go.mjs +23 -0
  15. package/lib/adapters/language/java.mjs +27 -0
  16. package/lib/adapters/language/js.mjs +32 -0
  17. package/lib/adapters/language/kotlin.mjs +48 -0
  18. package/lib/adapters/language/py.mjs +34 -0
  19. package/lib/adapters/language/rust.mjs +23 -0
  20. package/lib/adapters/language/swift.mjs +37 -0
  21. package/lib/adapters/language/ts.mjs +35 -0
  22. package/lib/adapters/trackers/azure-boards.mjs +49 -0
  23. package/lib/adapters/trackers/clickup.mjs +43 -0
  24. package/lib/adapters/trackers/github-issues.mjs +52 -0
  25. package/lib/adapters/trackers/jira.mjs +72 -0
  26. package/lib/adapters/trackers/linear.mjs +62 -0
  27. package/lib/adapters/trackers/none.mjs +18 -0
  28. package/lib/adapters/trackers/spreadsheet.mjs +28 -0
  29. package/lib/artifacts/fs-index.mjs +230 -0
  30. package/lib/bootstrap/render.mjs +373 -0
  31. package/lib/cache/store.mjs +422 -0
  32. package/lib/commands/bootstrap.mjs +4 -0
  33. package/lib/commands/callback.mjs +302 -0
  34. package/lib/commands/config.mjs +257 -0
  35. package/lib/commands/docs.mjs +1 -1
  36. package/lib/commands/doctor.mjs +583 -0
  37. package/lib/commands/feedback.mjs +355 -0
  38. package/lib/commands/help.mjs +96 -21
  39. package/lib/commands/init.mjs +830 -158
  40. package/lib/commands/kickoff.mjs +192 -0
  41. package/lib/commands/knowledge.mjs +368 -0
  42. package/lib/commands/lanes.mjs +502 -0
  43. package/lib/commands/manifest-catalog.mjs +102 -38
  44. package/lib/commands/migrate.mjs +204 -0
  45. package/lib/commands/new.mjs +452 -0
  46. package/lib/commands/patterns.mjs +9 -43
  47. package/lib/commands/run.mjs +617 -0
  48. package/lib/commands/sync.mjs +749 -0
  49. package/lib/commands/telemetry.mjs +390 -0
  50. package/lib/commands/verify.mjs +187 -0
  51. package/lib/config/io.mjs +232 -0
  52. package/lib/config/migrate.mjs +215 -0
  53. package/lib/config/schema.mjs +650 -0
  54. package/lib/detect.mjs +162 -19
  55. package/lib/feedback/drafts.mjs +129 -0
  56. package/lib/find-ship-root.mjs +16 -10
  57. package/lib/http.mjs +237 -11
  58. package/lib/state/idempotency.mjs +183 -0
  59. package/lib/state/lockfile.mjs +180 -0
  60. package/lib/telemetry/outbox.mjs +224 -0
  61. package/lib/templates.mjs +53 -65
  62. package/lib/verify/checks/agents-on-disk.mjs +58 -0
  63. package/lib/verify/checks/api-reachable.mjs +39 -0
  64. package/lib/verify/checks/artifacts-up-to-date.mjs +78 -0
  65. package/lib/verify/checks/bootstrap-files.mjs +67 -0
  66. package/lib/verify/checks/cache-integrity.mjs +51 -0
  67. package/lib/verify/checks/ci-secrets.mjs +86 -0
  68. package/lib/verify/checks/config-present.mjs +39 -0
  69. package/lib/verify/checks/gitignore-cache.mjs +51 -0
  70. package/lib/verify/checks/rules-markers.mjs +135 -0
  71. package/lib/verify/checks/stack-enums.mjs +33 -0
  72. package/lib/verify/checks/tracker-labels.mjs +91 -0
  73. package/lib/verify/registry.mjs +120 -0
  74. package/lib/version.mjs +34 -0
  75. package/package.json +10 -3
  76. package/bin/ship.mjs +0 -68
@@ -0,0 +1,452 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ /**
7
+ * Resolve the shipctl entry script (bin/shipctl.mjs) relative to this module.
8
+ * Used when we fall back to spawning `shipctl <subcommand>` subprocesses.
9
+ */
10
+ function shipctlBinPath() {
11
+ const here = path.dirname(fileURLToPath(import.meta.url));
12
+ return path.resolve(here, "..", "..", "bin", "shipctl.mjs");
13
+ }
14
+
15
+ /**
16
+ * @param {string[]} args
17
+ */
18
+ function parseNewArgs(args) {
19
+ /** @type {Record<string, any>} */
20
+ const out = {
21
+ name: null,
22
+ here: false,
23
+ preset: null,
24
+ tracker: null,
25
+ ci: null,
26
+ agents: /** @type {string[]} */ ([]),
27
+ language: null,
28
+ channel: "stable",
29
+ baseUrl: null,
30
+ yes: false,
31
+ force: false,
32
+ dryRun: false,
33
+ json: false,
34
+ help: false,
35
+ // tri-state: null = default (copy-rules enabled iff agents non-empty),
36
+ // true = forced on, false = opted out via --no-copy-rules.
37
+ copyRules: null,
38
+ // tri-state: null = default on, false = opted out via --no-bootstrap.
39
+ bootstrap: null,
40
+ telemetry: "off",
41
+ extra: /** @type {string[]} */ ([]),
42
+ };
43
+
44
+ const copy = [...args];
45
+ while (copy.length) {
46
+ const a = copy.shift();
47
+ if (a === "--here") { out.here = true; continue; }
48
+ if (a === "--help" || a === "-h") { out.help = true; continue; }
49
+ if (a === "--yes" || a === "-y") { out.yes = true; continue; }
50
+ if (a === "--force") { out.force = true; continue; }
51
+ if (a === "--dry-run") { out.dryRun = true; continue; }
52
+ if (a === "--json") { out.json = true; continue; }
53
+ if (a === "--copy-rules") { out.copyRules = true; continue; }
54
+ if (a === "--no-copy-rules") { out.copyRules = false; continue; }
55
+ if (a === "--bootstrap") { out.bootstrap = true; continue; }
56
+ if (a === "--no-bootstrap") { out.bootstrap = false; continue; }
57
+ if (a === "--preset" && copy.length) { out.preset = copy.shift(); continue; }
58
+ if (a.startsWith("--preset=")) { out.preset = a.slice("--preset=".length); continue; }
59
+ if (a === "--tracker" && copy.length) { out.tracker = copy.shift(); continue; }
60
+ if (a.startsWith("--tracker=")) { out.tracker = a.slice("--tracker=".length); continue; }
61
+ if (a === "--ci" && copy.length) { out.ci = copy.shift(); continue; }
62
+ if (a.startsWith("--ci=")) { out.ci = a.slice("--ci=".length); continue; }
63
+ if (a === "--base-url" && copy.length) { out.baseUrl = copy.shift(); continue; }
64
+ if (a.startsWith("--base-url=")) { out.baseUrl = a.slice("--base-url=".length); continue; }
65
+ if (a === "--agents" && copy.length) {
66
+ for (const s of String(copy.shift()).split(",")) {
67
+ const id = s.trim();
68
+ if (id) out.agents.push(id);
69
+ }
70
+ continue;
71
+ }
72
+ if (a.startsWith("--agents=")) {
73
+ for (const s of a.slice("--agents=".length).split(",")) {
74
+ const id = s.trim();
75
+ if (id) out.agents.push(id);
76
+ }
77
+ continue;
78
+ }
79
+ if (a === "--language" && copy.length) { out.language = copy.shift(); continue; }
80
+ if (a.startsWith("--language=")) { out.language = a.slice("--language=".length); continue; }
81
+ if (a === "--channel" && copy.length) { out.channel = copy.shift(); continue; }
82
+ if (a.startsWith("--channel=")) { out.channel = a.slice("--channel=".length); continue; }
83
+ if (a === "--telemetry" && copy.length) { out.telemetry = copy.shift(); continue; }
84
+ if (a.startsWith("--telemetry=")) { out.telemetry = a.slice("--telemetry=".length); continue; }
85
+ if (a && a.startsWith("--")) { out.extra.push(a); continue; }
86
+ if (out.name == null) { out.name = a; continue; }
87
+ out.extra.push(a);
88
+ }
89
+ return out;
90
+ }
91
+
92
+ function printNewHelp() {
93
+ console.log(`shipctl new <name> — bootstrap a fresh repository with Ship wiring.
94
+
95
+ USAGE
96
+ shipctl new <name> [options]
97
+ shipctl new [--here] [options]
98
+
99
+ OPTIONS
100
+ --here Initialise in the current directory instead of <name>/.
101
+ --preset <id> adoption-minimum|web-app|api-backend|mobile-app|cli|monorepo
102
+ --tracker <id> linear|jira|github-issues|azure-boards|clickup|spreadsheet|none
103
+ --ci <id> gh-actions|gitlab-ci|buildkite|circleci|azure-pipelines|jenkins|manual
104
+ --agents <csv> Comma-separated agent ids (e.g. cursor,codex,claude).
105
+ --language <id> ts|js|py|go|rust|java|kotlin|swift|dart|multi
106
+ --channel <id> stable|edge (written to api.channel; default stable).
107
+ --base-url <url> Override Ship API base URL (forwarded to 'init').
108
+ --copy-rules Forward to init. Default ON when agents are selected;
109
+ use --no-copy-rules to opt out.
110
+ --no-copy-rules Skip installing cached agent rule files on disk.
111
+ --bootstrap Forward --bootstrap to init. Default ON; use
112
+ --no-bootstrap to opt out.
113
+ --no-bootstrap Skip rendering CI/tracker scaffolding.
114
+ --telemetry <on|off> Default: off. Writes telemetry.share.
115
+ --yes Non-interactive (assumed for --dry-run).
116
+ --force Reuse a non-empty target directory.
117
+ --dry-run Describe the plan without touching disk.
118
+ --json Machine-readable summary.
119
+
120
+ Creates <name>/ (or reuses cwd with --here), runs 'git init -q', writes a
121
+ minimal README.md, seeds .ship/config.yml via 'shipctl config init', applies
122
+ the provided stack flags via 'shipctl config set', and then runs
123
+ 'shipctl init --yes' for any selected agents.
124
+ `);
125
+ }
126
+
127
+ function dirIsEmpty(dir) {
128
+ try {
129
+ const entries = fs.readdirSync(dir);
130
+ return entries.filter((e) => e !== ".DS_Store").length === 0;
131
+ } catch {
132
+ return true;
133
+ }
134
+ }
135
+
136
+ function isGitRepo(dir) {
137
+ return fs.existsSync(path.join(dir, ".git"));
138
+ }
139
+
140
+ function resolveTargetDir(args, cwd) {
141
+ if (args.here) return path.resolve(cwd);
142
+ if (!args.name) {
143
+ throw new Error("new: missing <name>. Run 'shipctl new --help' for usage.");
144
+ }
145
+ return path.resolve(cwd, args.name);
146
+ }
147
+
148
+ /**
149
+ * Run `shipctl <sub...>` via the same Node binary, capturing output for JSON mode.
150
+ * @param {string[]} argv
151
+ * @param {{capture?:boolean}} [opts]
152
+ */
153
+ function runShipctl(argv, opts = {}) {
154
+ const bin = shipctlBinPath();
155
+ const res = spawnSync(process.execPath, [bin, ...argv], {
156
+ stdio: opts.capture ? ["ignore", "pipe", "pipe"] : "inherit",
157
+ encoding: "utf8",
158
+ });
159
+ if (res.error) throw res.error;
160
+ return {
161
+ status: typeof res.status === "number" ? res.status : 1,
162
+ stdout: res.stdout || "",
163
+ stderr: res.stderr || "",
164
+ };
165
+ }
166
+
167
+ /**
168
+ * Apply stack-level settings via `shipctl config set`.
169
+ * Missing values are skipped.
170
+ * @param {string} newDir
171
+ * @param {ReturnType<typeof parseNewArgs>} a
172
+ * @param {boolean} capture
173
+ * @returns {{ok:boolean, applied:string[], errors:string[]}}
174
+ */
175
+ function applyStackConfig(newDir, a, capture) {
176
+ const applied = [];
177
+ const errors = [];
178
+ const set = (key, value) => {
179
+ const res = runShipctl(["config", "set", key, String(value), "--cwd", newDir], {
180
+ capture,
181
+ });
182
+ if (res.status !== 0) {
183
+ errors.push(`${key}=${value}: ${(res.stderr || res.stdout).trim() || `exit ${res.status}`}`);
184
+ return false;
185
+ }
186
+ applied.push(`${key}=${value}`);
187
+ return true;
188
+ };
189
+
190
+ if (a.tracker) set("stack.tracker", a.tracker);
191
+ if (a.ci) set("stack.ci", a.ci);
192
+ if (a.preset) set("stack.preset", a.preset);
193
+ if (a.language) set("stack.language", a.language);
194
+ if (a.channel) set("api.channel", a.channel);
195
+ if (a.agents.length) set("stack.agents", `[${a.agents.join(",")}]`);
196
+ if (a.telemetry === "on") set("telemetry.share", "true");
197
+ else if (a.telemetry === "off") set("telemetry.share", "false");
198
+
199
+ return { ok: errors.length === 0, applied, errors };
200
+ }
201
+
202
+ /**
203
+ * @param {{ baseUrl:string, yes:boolean, force:boolean, dryRun:boolean, json:boolean }} ctx
204
+ * @param {string[]} args
205
+ */
206
+ export async function newCommand(ctx, args) {
207
+ const a = parseNewArgs(args);
208
+ if (a.help) { printNewHelp(); return; }
209
+
210
+ if (ctx) {
211
+ if (ctx.json) a.json = true;
212
+ if (ctx.yes) a.yes = true;
213
+ if (ctx.force) a.force = true;
214
+ if (ctx.dryRun) a.dryRun = true;
215
+ // extractGlobalArgv in bin/shipctl.mjs strips `--base-url` out of argv
216
+ // and stashes it on ctx. Fold it in here so the init subprocess gets
217
+ // the same URL the caller handed to `shipctl new`.
218
+ if (!a.baseUrl && ctx.baseUrl) a.baseUrl = ctx.baseUrl;
219
+ }
220
+
221
+ const cwd = process.cwd();
222
+ let newDir;
223
+ try {
224
+ newDir = resolveTargetDir(a, cwd);
225
+ } catch (e) {
226
+ console.error(e.message);
227
+ process.exit(1);
228
+ }
229
+
230
+ const willCreate = !fs.existsSync(newDir);
231
+ const alreadyGit = !willCreate && isGitRepo(newDir);
232
+ const alreadyNonEmpty = !willCreate && !dirIsEmpty(newDir);
233
+ const configAlready = fs.existsSync(path.join(newDir, ".ship", "config.yml"));
234
+
235
+ if (!a.here && !willCreate && alreadyNonEmpty && !a.force) {
236
+ console.error(
237
+ `new: target directory is not empty: ${newDir}\n` +
238
+ `Re-run with --force to reuse it, or choose a different <name>.`,
239
+ );
240
+ process.exit(1);
241
+ }
242
+
243
+ const plannedFiles = [
244
+ { path: path.join(newDir, ".git"), reason: "git init" },
245
+ { path: path.join(newDir, "README.md"), reason: "minimal stub" },
246
+ { path: path.join(newDir, ".ship", "config.yml"), reason: "shipctl config init" },
247
+ ];
248
+
249
+ const initArgv = buildInitArgv(a, newDir);
250
+ const runInit = shouldRunInit(a);
251
+
252
+ const summary = {
253
+ cwd,
254
+ dir: newDir,
255
+ created_dir: willCreate,
256
+ reused_dir: !willCreate,
257
+ here: a.here,
258
+ git_init: !alreadyGit,
259
+ readme: true,
260
+ stack: {
261
+ tracker: a.tracker,
262
+ ci: a.ci,
263
+ preset: a.preset,
264
+ language: a.language,
265
+ channel: a.channel,
266
+ base_url: a.baseUrl,
267
+ agents: a.agents,
268
+ telemetry: a.telemetry,
269
+ copy_rules:
270
+ a.copyRules === true || (a.copyRules !== false && a.agents.length > 0),
271
+ bootstrap: a.bootstrap !== false,
272
+ },
273
+ init_argv: initArgv,
274
+ run_init: runInit,
275
+ planned_files: plannedFiles.map((f) => path.relative(cwd, f.path) || f.path),
276
+ next_steps: [
277
+ `cd ${path.relative(cwd, newDir) || "."}`,
278
+ "shipctl verify",
279
+ ],
280
+ };
281
+
282
+ if (a.dryRun) {
283
+ if (a.json) {
284
+ console.log(JSON.stringify({ ...summary, dry_run: true }, null, 2));
285
+ return;
286
+ }
287
+ console.log(
288
+ `shipctl new (dry-run) — ${a.here ? "using current dir" : willCreate ? "would create" : "would reuse"}: ${newDir}`,
289
+ );
290
+ const show = (full) => {
291
+ const rel = path.relative(cwd, full);
292
+ return rel && !rel.startsWith("..") ? rel : full;
293
+ };
294
+ for (const f of plannedFiles) {
295
+ console.log(` plan: write ${show(f.path)} (${f.reason})`);
296
+ }
297
+ if (runInit) {
298
+ console.log(` plan: shipctl ${initArgv.join(" ")}`);
299
+ }
300
+ const stackLines = [];
301
+ if (a.tracker) stackLines.push(`stack.tracker=${a.tracker}`);
302
+ if (a.ci) stackLines.push(`stack.ci=${a.ci}`);
303
+ if (a.preset) stackLines.push(`stack.preset=${a.preset}`);
304
+ if (a.language) stackLines.push(`stack.language=${a.language}`);
305
+ if (a.channel) stackLines.push(`api.channel=${a.channel}`);
306
+ if (a.agents.length) stackLines.push(`stack.agents=[${a.agents.join(",")}]`);
307
+ if (a.telemetry) stackLines.push(`telemetry.share=${a.telemetry === "on"}`);
308
+ for (const s of stackLines) console.log(` plan: shipctl config set ${s}`);
309
+ console.log("(dry-run: no files written)");
310
+ return;
311
+ }
312
+
313
+ const createdFiles = [];
314
+ if (willCreate) fs.mkdirSync(newDir, { recursive: true });
315
+
316
+ if (!alreadyGit) {
317
+ const gitInit = spawnSync("git", ["init", "-q"], { cwd: newDir, encoding: "utf8" });
318
+ if (gitInit.status !== 0) {
319
+ const reason = (gitInit.stderr || "").trim() || `exit ${gitInit.status}`;
320
+ console.error(`new: 'git init' failed in ${newDir}: ${reason}`);
321
+ process.exit(1);
322
+ }
323
+ createdFiles.push(path.join(newDir, ".git"));
324
+ }
325
+
326
+ const readmePath = path.join(newDir, "README.md");
327
+ if (!fs.existsSync(readmePath)) {
328
+ const displayName = a.name || path.basename(newDir);
329
+ fs.writeFileSync(
330
+ readmePath,
331
+ `# ${displayName}\n\nBootstrapped with shipctl (Ship methodology kit).\n`,
332
+ "utf8",
333
+ );
334
+ createdFiles.push(readmePath);
335
+ }
336
+
337
+ const capture = !!a.json;
338
+ const log = (msg) => { if (!a.json) console.log(msg); };
339
+
340
+ if (!configAlready) {
341
+ const res = runShipctl(["config", "init", "--cwd", newDir], { capture });
342
+ if (res.status !== 0) {
343
+ const out = (res.stderr || res.stdout).trim();
344
+ console.error(
345
+ `new: 'shipctl config init' exited with code ${res.status}${out ? `\n${out}` : ""}`,
346
+ );
347
+ process.exit(res.status);
348
+ }
349
+ createdFiles.push(path.join(newDir, ".ship", "config.yml"));
350
+ if (!a.json) process.stdout.write(res.stdout || "");
351
+ } else {
352
+ log(`config: reusing existing ${path.join(newDir, ".ship", "config.yml")}`);
353
+ }
354
+
355
+ const stackResult = applyStackConfig(newDir, a, capture);
356
+ if (!stackResult.ok) {
357
+ console.error("new: failed to apply stack flags:");
358
+ for (const e of stackResult.errors) console.error(` - ${e}`);
359
+ process.exit(10);
360
+ }
361
+ if (!a.json) {
362
+ for (const s of stackResult.applied) console.log(`config set ${s}`);
363
+ }
364
+
365
+ let initStatus = 0;
366
+ if (runInit) {
367
+ const res = runShipctl(initArgv, { capture });
368
+ initStatus = res.status;
369
+ if (!a.json) process.stdout.write(res.stdout || "");
370
+ if (res.status !== 0) {
371
+ const out = (res.stderr || res.stdout).trim();
372
+ console.error(
373
+ `new: 'shipctl init' exited with code ${res.status}${out ? `\n${out}` : ""}\n` +
374
+ `Re-run with the same flags to retry, or pass --no-bootstrap / --no-copy-rules to skip remote steps.`,
375
+ );
376
+ process.exit(res.status);
377
+ }
378
+ }
379
+
380
+ const finalConfigPath = path.join(newDir, ".ship", "config.yml");
381
+ const configExists = fs.existsSync(finalConfigPath);
382
+
383
+ if (a.json) {
384
+ console.log(
385
+ JSON.stringify(
386
+ {
387
+ ...summary,
388
+ dry_run: false,
389
+ stack_set: stackResult.applied,
390
+ created_files: createdFiles.map((p) => path.relative(cwd, p) || p),
391
+ config_written: configExists,
392
+ init_status: initStatus,
393
+ },
394
+ null,
395
+ 2,
396
+ ),
397
+ );
398
+ return;
399
+ }
400
+
401
+ console.log("");
402
+ console.log(`Done. Ship scaffolding in ${newDir}`);
403
+ console.log("Next:");
404
+ console.log(` cd ${path.relative(cwd, newDir) || "."}`);
405
+ console.log(" shipctl verify");
406
+ }
407
+
408
+ /**
409
+ * Build the argv list for the `shipctl init` subprocess. Kept separate so
410
+ * --dry-run can show the plan without executing. Forwards the full set of
411
+ * stack flags (tracker, CI, preset, agents, language, channel, base-url,
412
+ * telemetry) and defaults --copy-rules ON (when agents were selected) and
413
+ * --bootstrap ON. Use --no-copy-rules / --no-bootstrap on `new` to opt out.
414
+ *
415
+ * @param {ReturnType<typeof parseNewArgs>} a
416
+ * @param {string} newDir
417
+ */
418
+ export function buildInitArgv(a, newDir) {
419
+ const argv = ["init", "--cwd", newDir, "--yes"];
420
+ if (a.agents.length) argv.push("--agents", a.agents.join(","));
421
+ if (a.tracker) argv.push("--tracker", a.tracker);
422
+ if (a.ci) argv.push("--ci", a.ci);
423
+ if (a.preset) argv.push("--preset", a.preset);
424
+ if (a.force) argv.push("--force");
425
+ if (a.language) argv.push("--language", a.language);
426
+ if (a.channel) argv.push("--channel", a.channel);
427
+ if (a.telemetry) argv.push("--telemetry", a.telemetry);
428
+ if (a.baseUrl) argv.push("--base-url", a.baseUrl);
429
+
430
+ const wantCopyRules =
431
+ a.copyRules === true || (a.copyRules !== false && a.agents.length > 0);
432
+ if (wantCopyRules) argv.push("--copy-rules");
433
+
434
+ const wantBootstrap = a.bootstrap !== false;
435
+ if (wantBootstrap) argv.push("--bootstrap");
436
+
437
+ if (a.json) argv.push("--json");
438
+ return argv;
439
+ }
440
+
441
+ /**
442
+ * Decide whether `shipctl new` needs to spawn `shipctl init` at all. init
443
+ * does the real work (rule files on disk, CI scaffolding, telemetry prompt,
444
+ * initial sync). If the caller said --no-bootstrap and passed no agents,
445
+ * we skip the subprocess entirely.
446
+ * @param {ReturnType<typeof parseNewArgs>} a
447
+ */
448
+ function shouldRunInit(a) {
449
+ if (a.agents.length > 0) return true;
450
+ if (a.bootstrap !== false) return true;
451
+ return false;
452
+ }
@@ -1,25 +1,7 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
1
  import { apiGet, apiPost } from "../http.mjs";
4
2
  import { resolveShipRepoRootForCatalog } from "../find-ship-root.mjs";
5
3
  import { searchCommand } from "./search.mjs";
6
-
7
- const MANIFEST_REL = "patterns/manifest.json";
8
-
9
- /**
10
- * @param {Record<string, unknown>} data
11
- */
12
- function parseManifest(data) {
13
- const patterns = /** @type {unknown} */ (data.patterns);
14
- if (!Array.isArray(patterns)) {
15
- throw new Error(`${MANIFEST_REL} must contain a "patterns" array.`);
16
- }
17
- return {
18
- version: data.version ?? 1,
19
- description: typeof data.description === "string" ? data.description : "",
20
- patterns: /** @type {Array<Record<string, unknown>>} */ (patterns),
21
- };
22
- }
4
+ import { scanArtifacts, readArtifactFile } from "../artifacts/fs-index.mjs";
23
5
 
24
6
  /**
25
7
  * @param {Record<string, unknown>} p
@@ -42,19 +24,14 @@ function slimEntry(p) {
42
24
  * @param {string[]} rest
43
25
  */
44
26
  async function patternsFromDisk(root, ctx, sub, rest) {
45
- const manifestPath = path.join(root, MANIFEST_REL);
46
- const raw = fs.readFileSync(manifestPath, "utf8");
47
- /** @type {Record<string, unknown>} */
48
- const manifest = JSON.parse(raw);
49
- const { version, description, patterns } = parseManifest(manifest);
50
- const entries = patterns.filter((p) => p && typeof p === "object" && typeof p.id === "string");
27
+ const entries = scanArtifacts(root, "pattern");
51
28
 
52
29
  if (sub === "list") {
53
30
  const slim = entries.map((p) => slimEntry(p));
54
- const out = { version, description, patterns: slim };
31
+ const out = { version: 1, description: "Patterns", patterns: slim };
55
32
  if (ctx.json) console.log(JSON.stringify(out, null, 2));
56
33
  else {
57
- console.log(`${description || "Patterns"}\n`);
34
+ console.log(`Patterns\n`);
58
35
  for (const p of slim) {
59
36
  console.log(`- ${p.id}`);
60
37
  console.log(` ${p.title}`);
@@ -76,23 +53,12 @@ async function patternsFromDisk(root, ctx, sub, rest) {
76
53
  console.error(`Unknown id: ${id}`);
77
54
  process.exit(1);
78
55
  }
79
- const rel = entry.path;
80
- if (typeof rel !== "string" || !rel.trim()) {
81
- console.error("Pattern entry has no path.");
82
- process.exit(1);
83
- }
84
- const abs = path.resolve(root, rel);
85
- const rootNorm = root.endsWith(path.sep) ? root.slice(0, -1) : root;
86
- const absNorm = abs.endsWith(path.sep) ? abs.slice(0, -1) : abs;
87
- if (absNorm !== rootNorm && !abs.startsWith(root + path.sep)) {
88
- console.error("Manifest path escapes repository root.");
89
- process.exit(1);
90
- }
91
- if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) {
92
- console.error(`Missing file: ${rel}`);
56
+ const file = readArtifactFile(root, "pattern", id);
57
+ if (!file) {
58
+ console.error(`Missing file: ${entry.path}`);
93
59
  process.exit(1);
94
60
  }
95
- const content = fs.readFileSync(abs, "utf8");
61
+ const content = file.content;
96
62
  const full = { ...slimEntry(entry), content };
97
63
  if (ctx.json) console.log(JSON.stringify(full, null, 2));
98
64
  else {
@@ -171,7 +137,7 @@ export async function patternCommand(ctx, args) {
171
137
  ship pattern fetch <id>
172
138
  ship pattern search <query> [--top-k N]
173
139
 
174
- With a local Ship tree (cwd or SHIP_REPO): list/show/fetch read patterns/manifest.json on disk.
140
+ With a local Ship tree (cwd or SHIP_REPO): list/show/fetch scan artifacts/patterns/<id>/ARTIFACT.md on disk.
175
141
  Otherwise: methodology API (GET /patterns, POST /fetch for fetch, POST /search for search).
176
142
 
177
143
  Plural alias: ship patterns …