@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
@@ -2,222 +2,894 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import readline from "node:readline/promises";
4
4
  import { stdin as input, stdout as output } from "node:process";
5
- import { detectAgentTargets } from "../detect.mjs";
6
- import { MARKER, cursorRuleMdc, markdownSection, standaloneDoc } from "../templates.mjs";
5
+ import YAML from "yaml";
7
6
 
8
- const END_MARKER = "<!-- ship-cli:end methodology-api -->";
7
+ import {
8
+ DEFAULT_CONFIG,
9
+ validateConfig,
10
+ TRACKERS,
11
+ CIS,
12
+ PRESETS,
13
+ LANGUAGES,
14
+ CHANNELS,
15
+ AGENT_IDS,
16
+ } from "../config/schema.mjs";
17
+ import {
18
+ writeConfig,
19
+ writeState,
20
+ defaultState,
21
+ ensureAnonymousId,
22
+ findShipRoot,
23
+ readConfig,
24
+ SHIP_DIR,
25
+ CONFIG_REL,
26
+ STATE_REL,
27
+ } from "../config/io.mjs";
28
+ import { syncArtifacts } from "./sync.mjs";
29
+ import { detectAll } from "../adapters/index.mjs";
30
+ import { listCached, readCached } from "../cache/store.mjs";
31
+ import { renderPlan, applyPlan } from "../bootstrap/render.mjs";
32
+ import { KNOWN_AGENTS } from "../detect.mjs";
33
+
34
+ const MARKER = "<!-- ship-cli: artifacts-protocol v1 -->";
35
+ const END_MARKER = "<!-- ship-cli:end artifacts-protocol -->";
36
+ const INSTALLED_FROM_RE = /<!--\s*ship-cli:\s*installed-from\s+([^\s>]+)\s*-->/g;
37
+ const FOOTER_VERSION_RE = /@(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)\s*$/;
38
+
39
+ /**
40
+ * @typedef {{
41
+ * cwd:string,
42
+ * agents:string[],
43
+ * tracker:string|null,
44
+ * ci:string|null,
45
+ * preset:string|null,
46
+ * language:string|null,
47
+ * channel:string|null,
48
+ * telemetry:"on"|"off"|"ask"|null,
49
+ * copyRules:boolean,
50
+ * copyPlaybook:boolean,
51
+ * bootstrap:boolean,
52
+ * json:boolean,
53
+ * yes:boolean,
54
+ * force:boolean,
55
+ * dryRun:boolean
56
+ * }} InitOptions
57
+ */
9
58
 
10
59
  /**
11
- * @param {{ baseUrl: string; yes: boolean; force: boolean; dryRun: boolean; json: boolean }} ctx
60
+ * @param {{baseUrl:string, yes:boolean, force:boolean, dryRun:boolean, json:boolean}} ctx
12
61
  * @param {string[]} args
13
62
  */
14
63
  export async function initCommand(ctx, args) {
15
- if (!args.length || args[0] === "help" || args[0] === "-h" || args[0] === "--help") {
16
- console.log(`Usage:
17
- ship init [--yes] [--force] [--dry-run] [--only <id>] [--cwd <dir>]
18
-
19
- Writes Cursor rules and/or markdown sections that point agents at the Ship methodology API
20
- (base URL from SHIP_API_BASE or --base-url; same default as other commands, e.g. ship.elmundi.com).
21
-
22
- Flags:
23
- --dry-run Show the plan only (recommended before first use).
24
- --yes Non-interactive: apply immediately. In CI or scripts there is no prompt;
25
- combine with --dry-run first if you are unsure. --force replaces existing
26
- ship-cli blocks; without --force, existing injections are skipped.
27
- --force Replace existing injected blocks.
28
- --only cursor | agents-md | claude-md | codex | copilot
29
- --cwd Target repository root (default: current directory).
30
-
31
- If stdin is not a TTY and you omit --yes, init exits with an error unless you use --dry-run.`);
64
+ if (args[0] === "help" || args[0] === "-h" || args[0] === "--help") {
65
+ printInitHelp();
32
66
  return;
33
67
  }
34
68
 
35
- let cwd = process.cwd();
36
- /** @type {string[]} */
37
- let only = [];
38
- for (let i = 0; i < args.length; i++) {
39
- const a = args[i];
40
- if (a === "--cwd" && args[i + 1]) {
41
- cwd = path.resolve(args[++i]);
42
- continue;
43
- }
44
- if (a === "--only" && args[i + 1]) {
45
- only.push(args[++i]);
46
- continue;
69
+ const opts = parseInitArgs(args, ctx);
70
+ validateFlagEnums(opts);
71
+
72
+ // ── Load existing config if present; else build a fresh one ──────────────
73
+ const shipRootBefore = findShipRoot(opts.cwd);
74
+ let config;
75
+ let configFilePath;
76
+ let configExisted = false;
77
+ if (shipRootBefore) {
78
+ try {
79
+ const read = readConfig(opts.cwd);
80
+ config = read.config;
81
+ configFilePath = read.filePath;
82
+ configExisted = true;
83
+ } catch {
84
+ config = DEFAULT_CONFIG();
85
+ configFilePath = path.join(opts.cwd, CONFIG_REL);
47
86
  }
87
+ } else {
88
+ config = DEFAULT_CONFIG();
89
+ configFilePath = path.join(opts.cwd, CONFIG_REL);
48
90
  }
49
91
 
50
- let targets = detectAgentTargets(cwd);
51
- if (only.includes("cursor") && !targets.some((t) => t.id === "cursor")) {
52
- targets.push({
53
- id: "cursor",
54
- label: "Cursor (`.cursor/rules/` will be created if missing)",
55
- paths: [path.join(cwd, ".cursor", "rules", "ship-methodology-api.mdc")],
56
- });
57
- }
58
- if (only.length) {
59
- const allowed = new Set(["cursor", "agents-md", "claude-md", "codex", "copilot"]);
60
- for (const o of only) {
61
- if (!allowed.has(o)) {
62
- console.error(`init: unknown --only ${o}. Allowed: ${[...allowed].join(", ")}`);
63
- process.exit(1);
64
- }
65
- }
66
- targets = targets.filter((t) => only.includes(t.id));
67
- if (!targets.length) {
68
- console.error("init: no agent targets matched --only (markers missing in this repo).");
69
- process.exit(1);
70
- }
92
+ const flagSet = {
93
+ tracker: opts.tracker !== null,
94
+ ci: opts.ci !== null,
95
+ preset: opts.preset !== null,
96
+ agents: opts.agents.length > 0,
97
+ language: opts.language !== null,
98
+ channel: opts.channel !== null,
99
+ };
100
+
101
+ applyFlagOverrides(config, opts, flagSet);
102
+
103
+ // ── Telemetry decision ───────────────────────────────────────────────────
104
+ let telemetryMode;
105
+ if (opts.telemetry === "on") {
106
+ config.telemetry.share = true;
107
+ telemetryMode = "on";
108
+ } else if (opts.telemetry === "off") {
109
+ config.telemetry.share = false;
110
+ telemetryMode = "off";
111
+ } else if (opts.telemetry === "ask" && input.isTTY && output.isTTY && !opts.dryRun) {
112
+ telemetryMode = (await promptTelemetry()) ? "on" : "off";
113
+ config.telemetry.share = telemetryMode === "on";
114
+ } else if (opts.yes) {
115
+ config.telemetry.share = false;
116
+ telemetryMode = "off";
117
+ } else if (!opts.dryRun && input.isTTY && output.isTTY && !configExisted) {
118
+ telemetryMode = (await promptTelemetry()) ? "on" : "off";
119
+ config.telemetry.share = telemetryMode === "on";
120
+ } else {
121
+ // Non-TTY (e.g. CI), or config already existed — keep whatever was there;
122
+ // never auto-enable. Default `share` is false via DEFAULT_CONFIG().
123
+ telemetryMode = config.telemetry.share === true ? "on" : "off";
71
124
  }
72
125
 
73
- /** @type {{ id: string; label: string; action: string }[]} */
74
- const plan = [];
126
+ ensureAnonymousId(config);
75
127
 
76
- if (targets.some((t) => t.id === "cursor")) {
77
- const rulesDir = path.join(cwd, ".cursor", "rules");
78
- const file = path.join(rulesDir, "ship-methodology-api.mdc");
79
- plan.push({ id: "cursor", label: "Cursor rule", action: `write ${path.relative(cwd, file)}` });
128
+ // ── Doctor-based inference (no network) for anything left at defaults ────
129
+ let findings = null;
130
+ try {
131
+ findings = await detectAll(opts.cwd);
132
+ } catch {
133
+ findings = null;
80
134
  }
81
- for (const t of targets) {
82
- if (t.id === "agents-md") {
83
- plan.push({ id: "agents-md", label: t.label, action: `append section → ${t.paths[0]}` });
135
+ if (findings) {
136
+ const proposed = proposeStack(findings);
137
+ if (!flagSet.tracker && (config.stack.tracker == null || config.stack.tracker === "none")) {
138
+ config.stack.tracker = proposed.tracker;
84
139
  }
85
- if (t.id === "claude-md") {
86
- plan.push({ id: "claude-md", label: t.label, action: `append section → ${t.paths[0]}` });
140
+ if (!flagSet.ci && (config.stack.ci == null || config.stack.ci === "manual")) {
141
+ config.stack.ci = proposed.ci;
87
142
  }
88
- if (t.id === "codex") {
89
- plan.push({ id: "codex", label: t.label, action: `write ${path.relative(cwd, t.paths[0])}` });
143
+ if (!flagSet.language && (config.stack.language == null || config.stack.language === "multi")) {
144
+ config.stack.language = proposed.language;
90
145
  }
91
- if (t.id === "copilot") {
92
- plan.push({ id: "copilot", label: t.label, action: `append section → ${t.paths[0]}` });
146
+ if (
147
+ !flagSet.agents &&
148
+ (!Array.isArray(config.stack.agents) || config.stack.agents.length === 0)
149
+ ) {
150
+ config.stack.agents = proposed.agents;
93
151
  }
94
152
  }
95
153
 
96
- const standalonePath = path.join(cwd, "SHIP_AGENT_API.md");
97
- if (!targets.length) {
98
- plan.push({
99
- id: "standalone",
100
- label: "Standalone reference (no agent markers in repo)",
101
- action: `write ${path.relative(cwd, standalonePath)}`,
102
- });
154
+ // Final validation
155
+ const valid = validateConfig(config);
156
+ if (!valid.ok) {
157
+ for (const w of valid.warnings) process.stderr.write(`warn: ${w}\n`);
158
+ for (const e of valid.errors) process.stderr.write(`${e}\n`);
159
+ process.exit(10);
103
160
  }
161
+ for (const w of valid.warnings) process.stderr.write(`warn: ${w}\n`);
104
162
 
105
- if (ctx.json) {
106
- console.log(JSON.stringify({ cwd, baseUrl: ctx.baseUrl, plan }, null, 2));
163
+ // ── Derived artifact list to fetch via syncArtifacts ─────────────────────
164
+ const derived = buildDerivedList(config, opts);
165
+
166
+ // ── Dry-run short-circuit: emit plan only, write nothing ─────────────────
167
+ if (opts.dryRun) {
168
+ const plan = buildPlanSummary(opts.cwd, config, opts, telemetryMode, derived);
169
+ if (opts.json) {
170
+ process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`);
171
+ } else {
172
+ printHumanPlan(plan, opts);
173
+ }
174
+ process.stdout.write("(dry-run: no files written)\n");
107
175
  return;
108
176
  }
109
177
 
110
- console.log(`Repository: ${cwd}`);
111
- console.log(`API base URL in injected docs: ${ctx.baseUrl}\n`);
112
- console.log("Planned changes:");
113
- for (const p of plan) console.log(` - [${p.id}] ${p.action}`);
114
- console.log("");
115
-
116
- if (ctx.dryRun) {
117
- console.log("(dry-run: no files written)");
118
- return;
178
+ // ── Write config / state / cache dir / .gitignore ────────────────────────
179
+ const ensured = ensureShipLayout(opts.cwd, config, configFilePath, configExisted);
180
+ const shipRoot = findShipRoot(opts.cwd);
181
+ if (!shipRoot) {
182
+ throw new Error("init: failed to locate .ship/ after creation");
119
183
  }
120
184
 
121
- if (!ctx.yes) {
122
- if (!input.isTTY || !output.isTTY) {
123
- console.error("init: not a TTY; re-run with --yes or use --dry-run to preview.");
124
- process.exit(1);
185
+ // ── Sync relevant artifacts into .ship/cache/ ────────────────────────────
186
+ let syncSummary = null;
187
+ if (derived.length) {
188
+ try {
189
+ syncSummary = await syncArtifacts({
190
+ cwd: shipRoot,
191
+ baseUrl: ctx.baseUrl,
192
+ channel: opts.channel || config.api?.channel,
193
+ onlyKinds: ["collection"],
194
+ include: derived,
195
+ verbose: false,
196
+ });
197
+ } catch (e) {
198
+ const msg = e && e.message ? e.message : String(e);
199
+ process.stderr.write(`warn: artifact fetch partially failed (${msg})\n`);
125
200
  }
126
- const rl = readline.createInterface({ input, output });
127
- const ans = (await rl.question("Apply these changes? [y/N] ")).trim().toLowerCase();
128
- rl.close();
129
- if (ans !== "y" && ans !== "yes") {
130
- console.log("Aborted.");
131
- return;
201
+ }
202
+
203
+ // ── --copy-rules: materialize agent rules from cache onto disk ───────────
204
+ const ruleInstallations = [];
205
+ if (opts.copyRules) {
206
+ for (const agent of config.stack.agents || []) {
207
+ const res = installAgentRule(shipRoot, agent, { force: opts.force });
208
+ if (res) ruleInstallations.push(res);
132
209
  }
133
210
  }
134
211
 
135
- for (const t of targets) {
136
- if (t.id === "cursor") {
137
- await writeCursorRule(cwd, ctx);
212
+ // ── --bootstrap: render CI/tracker scaffolding ───────────────────────────
213
+ let bootstrapSummary = null;
214
+ if (opts.bootstrap) {
215
+ const presetArtifact = readPresetArtifact(shipRoot, config);
216
+ const plan = renderPlan(config, presetArtifact);
217
+ const results = applyPlan(shipRoot, plan, { dryRun: false, force: opts.force });
218
+ bootstrapSummary = { files: plan.summary.files, notes: plan.summary.notes, results };
219
+ }
220
+
221
+ // ── --copy-playbook: copy cached playbook to .ship/playbooks/ ────────────
222
+ let playbookCopied = null;
223
+ if (opts.copyPlaybook) {
224
+ playbookCopied = copyPlaybookFromCache(shipRoot);
225
+ }
226
+
227
+ // ── Output ───────────────────────────────────────────────────────────────
228
+ const summary = {
229
+ ok: true,
230
+ cwd: opts.cwd,
231
+ ship_root: shipRoot,
232
+ config_path: ensured.configFilePath,
233
+ telemetry: telemetryMode,
234
+ stack: {
235
+ tracker: config.stack.tracker,
236
+ ci: config.stack.ci,
237
+ preset: config.stack.preset,
238
+ language: config.stack.language,
239
+ agents: [...(config.stack.agents || [])],
240
+ },
241
+ channel: config.api?.channel || "stable",
242
+ rules: ruleInstallations,
243
+ bootstrap: bootstrapSummary,
244
+ playbook: playbookCopied,
245
+ sync: syncSummary
246
+ ? {
247
+ up_to_date: syncSummary.up_to_date,
248
+ updated: syncSummary.updated,
249
+ failed: syncSummary.failed,
250
+ entries: syncSummary.entries,
251
+ }
252
+ : null,
253
+ };
254
+
255
+ if (opts.json) {
256
+ process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
257
+ return;
258
+ }
259
+
260
+ printHumanSummary(summary, ensured, opts);
261
+ }
262
+
263
+ // ── Arg parsing ────────────────────────────────────────────────────────────
264
+ /**
265
+ * @param {string[]} args
266
+ * @param {{yes:boolean,force:boolean,dryRun:boolean,json:boolean}} ctx
267
+ * @returns {InitOptions}
268
+ */
269
+ function parseInitArgs(args, ctx) {
270
+ /** @type {InitOptions} */
271
+ const opts = {
272
+ cwd: process.cwd(),
273
+ agents: [],
274
+ tracker: null,
275
+ ci: null,
276
+ preset: null,
277
+ language: null,
278
+ channel: null,
279
+ telemetry: null,
280
+ copyRules: false,
281
+ copyPlaybook: false,
282
+ bootstrap: false,
283
+ json: !!ctx.json,
284
+ yes: !!ctx.yes,
285
+ force: !!ctx.force,
286
+ dryRun: !!ctx.dryRun,
287
+ };
288
+
289
+ const agentsCsv = [];
290
+
291
+ for (let i = 0; i < args.length; i++) {
292
+ const a = args[i];
293
+ if (a === "--cwd" && args[i + 1]) {
294
+ opts.cwd = path.resolve(String(args[++i]));
295
+ continue;
138
296
  }
139
- if (t.id === "agents-md") {
140
- await appendOrWrite(t.paths[0], markdownSection(ctx.baseUrl), ctx);
297
+ if (a.startsWith("--cwd=")) {
298
+ opts.cwd = path.resolve(a.slice("--cwd=".length));
299
+ continue;
141
300
  }
142
- if (t.id === "claude-md") {
143
- await appendOrWrite(t.paths[0], markdownSection(ctx.baseUrl), ctx);
301
+ if (a === "--yes" || a === "-y") {
302
+ opts.yes = true;
303
+ continue;
304
+ }
305
+ if (a === "--force") {
306
+ opts.force = true;
307
+ continue;
308
+ }
309
+ if (a === "--dry-run") {
310
+ opts.dryRun = true;
311
+ continue;
144
312
  }
145
- if (t.id === "codex") {
146
- await writeNew(t.paths[0], standaloneDoc(ctx.baseUrl), ctx);
313
+ if (a === "--json") {
314
+ opts.json = true;
315
+ continue;
316
+ }
317
+ if (a === "--agents" && args[i + 1]) {
318
+ for (const s of String(args[++i]).split(",")) {
319
+ const id = s.trim();
320
+ if (id) agentsCsv.push(id);
321
+ }
322
+ continue;
323
+ }
324
+ if (a === "--tracker" && args[i + 1]) {
325
+ opts.tracker = String(args[++i]);
326
+ continue;
327
+ }
328
+ if (a === "--ci" && args[i + 1]) {
329
+ opts.ci = String(args[++i]);
330
+ continue;
331
+ }
332
+ if (a === "--preset" && args[i + 1]) {
333
+ opts.preset = String(args[++i]);
334
+ continue;
147
335
  }
148
- if (t.id === "copilot") {
149
- await appendOrWrite(t.paths[0], markdownSection(ctx.baseUrl), ctx);
336
+ if (a === "--language" && args[i + 1]) {
337
+ opts.language = String(args[++i]);
338
+ continue;
339
+ }
340
+ if (a === "--channel" && args[i + 1]) {
341
+ opts.channel = String(args[++i]);
342
+ continue;
343
+ }
344
+ if (a === "--telemetry" && args[i + 1]) {
345
+ const v = String(args[++i]).trim().toLowerCase();
346
+ if (v !== "on" && v !== "off" && v !== "ask") {
347
+ process.stderr.write(`init: --telemetry must be on|off|ask (got "${v}")\n`);
348
+ process.exit(1);
349
+ }
350
+ opts.telemetry = /** @type {"on"|"off"|"ask"} */ (v);
351
+ continue;
352
+ }
353
+ if (a === "--copy-rules") {
354
+ opts.copyRules = true;
355
+ continue;
356
+ }
357
+ if (a === "--copy-playbook") {
358
+ opts.copyPlaybook = true;
359
+ continue;
360
+ }
361
+ if (a === "--bootstrap") {
362
+ opts.bootstrap = true;
363
+ continue;
364
+ }
365
+ }
366
+
367
+ opts.agents = agentsCsv;
368
+ return opts;
369
+ }
370
+
371
+ /** @param {InitOptions} opts */
372
+ function validateFlagEnums(opts) {
373
+ if (opts.tracker && !TRACKERS.includes(opts.tracker)) {
374
+ process.stderr.write(`init: unknown --tracker "${opts.tracker}". Allowed: ${TRACKERS.join(", ")}\n`);
375
+ process.exit(1);
376
+ }
377
+ if (opts.ci && !CIS.includes(opts.ci)) {
378
+ process.stderr.write(`init: unknown --ci "${opts.ci}". Allowed: ${CIS.join(", ")}\n`);
379
+ process.exit(1);
380
+ }
381
+ if (opts.preset && !PRESETS.includes(opts.preset)) {
382
+ process.stderr.write(`init: unknown --preset "${opts.preset}". Allowed: ${PRESETS.join(", ")}\n`);
383
+ process.exit(1);
384
+ }
385
+ if (opts.language && !LANGUAGES.includes(opts.language)) {
386
+ process.stderr.write(`init: unknown --language "${opts.language}". Allowed: ${LANGUAGES.join(", ")}\n`);
387
+ process.exit(1);
388
+ }
389
+ if (opts.channel && !CHANNELS.includes(opts.channel)) {
390
+ process.stderr.write(`init: unknown --channel "${opts.channel}". Allowed: ${CHANNELS.join(", ")}\n`);
391
+ process.exit(1);
392
+ }
393
+ for (const a of opts.agents) {
394
+ if (!AGENT_IDS.includes(a)) {
395
+ process.stderr.write(
396
+ `init: unknown agent "${a}". Allowed: ${AGENT_IDS.slice().sort().join(", ")}\n`,
397
+ );
398
+ process.exit(1);
150
399
  }
151
400
  }
401
+ }
402
+
403
+ /** @param {object} config @param {InitOptions} opts @param {Record<string,boolean>} flagSet */
404
+ function applyFlagOverrides(config, opts, flagSet) {
405
+ if (!config.stack || typeof config.stack !== "object") config.stack = {};
406
+ if (!config.api || typeof config.api !== "object") config.api = {};
407
+ if (!config.telemetry || typeof config.telemetry !== "object") config.telemetry = {};
152
408
 
153
- if (!targets.length) {
154
- await writeNew(standalonePath, standaloneDoc(ctx.baseUrl), ctx);
409
+ if (flagSet.tracker) config.stack.tracker = opts.tracker;
410
+ if (flagSet.ci) config.stack.ci = opts.ci;
411
+ if (flagSet.preset) config.stack.preset = opts.preset;
412
+ if (flagSet.language) config.stack.language = opts.language;
413
+ if (flagSet.agents) config.stack.agents = [...opts.agents];
414
+ if (flagSet.channel) config.api.channel = opts.channel;
415
+ }
416
+
417
+ // ── Doctor / stack inference ───────────────────────────────────────────────
418
+ function proposeStack(findings) {
419
+ const pickTop = (arr, { min = 0.1, exclude = [], fallback = null } = {}) => {
420
+ const pool = arr.filter(
421
+ (e) => e.present && e.confidence > min && !exclude.includes(e.id),
422
+ );
423
+ return pool.length ? pool[0].id : fallback;
424
+ };
425
+ const tracker = pickTop(findings.trackers, { exclude: ["none"], fallback: "none" });
426
+ const ci = pickTop(findings.ci, { exclude: ["manual"], fallback: "manual" });
427
+ const language = pickTop(findings.language, { fallback: "multi" }) || "multi";
428
+ const agents = (findings.agents || [])
429
+ .filter((a) => a.present && a.confidence >= 0.5)
430
+ .map((a) => a.id)
431
+ .filter((id) => AGENT_IDS.includes(id));
432
+ return { tracker, ci, language, agents };
433
+ }
434
+
435
+ // ── Derived artifact list ──────────────────────────────────────────────────
436
+ /**
437
+ * @param {object} config
438
+ * @param {InitOptions} opts
439
+ * @returns {Array<{kind:string,id:string}>}
440
+ */
441
+ function buildDerivedList(config, opts) {
442
+ const list = [];
443
+ const seen = new Set();
444
+ const add = (kind, id) => {
445
+ const key = `${kind}:${id}`;
446
+ if (seen.has(key)) return;
447
+ seen.add(key);
448
+ list.push({ kind, id });
449
+ };
450
+ for (const a of config.stack.agents || []) {
451
+ add("collection", `agent-rules-${a}`);
452
+ }
453
+ if (config.stack.preset) {
454
+ add("collection", `preset-${config.stack.preset}`);
455
+ }
456
+ if (opts.copyPlaybook) {
457
+ add("collection", "adoption-playbook");
458
+ }
459
+ return list;
460
+ }
461
+
462
+ // ── Ship layout creation ───────────────────────────────────────────────────
463
+ function ensureShipLayout(cwd, config, configFilePath, configExisted) {
464
+ const shipDir = path.join(cwd, SHIP_DIR);
465
+ fs.mkdirSync(shipDir, { recursive: true });
466
+
467
+ let configWritten = false;
468
+ if (!configExisted) {
469
+ writeConfig(configFilePath, config);
470
+ configWritten = true;
471
+ } else {
472
+ // Persist flag-driven updates back to disk when config already existed.
473
+ writeConfig(configFilePath, config);
474
+ configWritten = true;
155
475
  }
156
476
 
157
- console.log("Done.");
477
+ const statePath = path.join(cwd, STATE_REL);
478
+ if (!fs.existsSync(statePath)) {
479
+ writeState(cwd, defaultState());
480
+ }
481
+
482
+ const cacheDir = path.join(shipDir, "cache");
483
+ fs.mkdirSync(cacheDir, { recursive: true });
484
+ const keep = path.join(cacheDir, ".gitkeep");
485
+ if (!fs.existsSync(keep)) fs.writeFileSync(keep, "", "utf8");
486
+
487
+ const giResult = ensureGitignore(cwd);
488
+
489
+ return {
490
+ configFilePath,
491
+ configWritten,
492
+ configExisted,
493
+ gitignorePath: giResult.path,
494
+ gitignoreChanged: giResult.changed,
495
+ };
496
+ }
497
+
498
+ function ensureGitignore(cwd) {
499
+ const giPath = path.join(cwd, ".gitignore");
500
+ const entries = [
501
+ "# Ship",
502
+ ".ship/cache/",
503
+ ".ship/telemetry-outbox.jsonl",
504
+ ".ship/feedback-drafts/",
505
+ ".ship/state.json",
506
+ ];
507
+ let current = "";
508
+ if (fs.existsSync(giPath)) current = fs.readFileSync(giPath, "utf8");
509
+ const existingLines = new Set(current.split(/\r?\n/).map((l) => l.trim()));
510
+ const toAppend = entries.filter((e) => !existingLines.has(e.trim()));
511
+ if (toAppend.length === 0) return { path: giPath, changed: false };
512
+ const prefix = current.length === 0 || current.endsWith("\n") ? "" : "\n";
513
+ const tail =
514
+ current.length === 0 ? `${toAppend.join("\n")}\n` : `${prefix}${toAppend.join("\n")}\n`;
515
+ fs.writeFileSync(giPath, current + tail, "utf8");
516
+ return { path: giPath, changed: true };
158
517
  }
159
518
 
519
+ // ── Agent rule installation ────────────────────────────────────────────────
160
520
  /**
161
- * @param {string} cwd
162
- * @param {{ force: boolean; dryRun: boolean }} ctx
521
+ * @param {string} shipRoot
522
+ * @param {string} agent
523
+ * @param {{force:boolean}} opts
524
+ * @returns {null | {agent:string, path:string, action:string, from:string}}
163
525
  */
164
- async function writeCursorRule(cwd, ctx) {
165
- const rulesDir = path.join(cwd, ".cursor", "rules");
166
- const file = path.join(rulesDir, "ship-methodology-api.mdc");
167
- const body = cursorRuleMdc(ctx.baseUrl);
168
- if (fs.existsSync(file) && fs.readFileSync(file, "utf8").includes(MARKER) && !ctx.force) {
169
- console.log(`skip (exists): ${file}`);
170
- return;
526
+ function installAgentRule(shipRoot, agent, { force }) {
527
+ const id = `agent-rules-${agent}`;
528
+ const version = latestCachedVersion(shipRoot, "collection", id);
529
+ if (!version) {
530
+ process.stderr.write(
531
+ `warn: --copy-rules: no cached artifact for collection/${id} (was the fetch successful?)\n`,
532
+ );
533
+ return null;
534
+ }
535
+ const cached = readCached(shipRoot, "collection", id, version);
536
+ if (!cached) return null;
537
+ const parsed = parseFrontmatter(cached.content);
538
+ const target = parsed.attrs.install_target || fallbackInstallTarget(agent);
539
+ if (!target) {
540
+ process.stderr.write(`warn: --copy-rules: no install_target for ${agent}; skipping\n`);
541
+ return null;
171
542
  }
172
- if (ctx.dryRun) return;
173
- fs.mkdirSync(rulesDir, { recursive: true });
174
- fs.writeFileSync(file, body, "utf8");
175
- console.log(`wrote ${file}`);
543
+ const marker = parsed.attrs.marker || MARKER;
544
+ const body = parsed.body.trimEnd();
545
+ const footer = `<!-- ship-cli: installed-from collection/${id}@${version} -->`;
546
+
547
+ const absTarget = path.isAbsolute(target) ? target : path.join(shipRoot, target);
548
+ const existed = fs.existsSync(absTarget);
549
+ const prev = existed ? fs.readFileSync(absTarget, "utf8") : "";
550
+
551
+ // If installed-from references a different version and --force is not set, skip.
552
+ if (existed) {
553
+ const existingVersion = extractInstalledVersion(prev);
554
+ if (existingVersion && existingVersion !== version && !force) {
555
+ process.stderr.write(
556
+ `warn: ${path.relative(shipRoot, absTarget)} has ship-cli installed-from @${existingVersion}; pass --force to replace with @${version}\n`,
557
+ );
558
+ return {
559
+ agent,
560
+ path: path.relative(shipRoot, absTarget) || absTarget,
561
+ action: "skipped",
562
+ from: `collection/${id}@${version}`,
563
+ };
564
+ }
565
+ }
566
+
567
+ const next = upsertMarkedBlock(prev, { marker, endMarker: END_MARKER, body, footer });
568
+
569
+ fs.mkdirSync(path.dirname(absTarget), { recursive: true });
570
+ fs.writeFileSync(absTarget, next, "utf8");
571
+
572
+ return {
573
+ agent,
574
+ path: path.relative(shipRoot, absTarget) || absTarget,
575
+ action: existed ? "updated" : "wrote",
576
+ from: `collection/${id}@${version}`,
577
+ };
578
+ }
579
+
580
+ function fallbackInstallTarget(agent) {
581
+ const meta = KNOWN_AGENTS[agent];
582
+ if (!meta) return null;
583
+ return path.join(...meta.targetRel);
176
584
  }
177
585
 
178
586
  /**
179
- * @param {string} filePath
180
- * @param {string} section
181
- * @param {{ force: boolean; dryRun: boolean }} ctx
587
+ * Parse a YAML front-matter block if present.
588
+ * @param {string} text
589
+ * @returns {{attrs:object, body:string}}
182
590
  */
183
- async function appendOrWrite(filePath, section, ctx) {
184
- if (ctx.dryRun) return;
185
- let prev = "";
186
- if (fs.existsSync(filePath)) prev = fs.readFileSync(filePath, "utf8");
187
- if (prev.includes(MARKER) && !ctx.force) {
188
- console.log(`skip (already injected): ${filePath}`);
189
- return;
591
+ function parseFrontmatter(text) {
592
+ if (!text.startsWith("---\n") && !text.startsWith("---\r\n")) {
593
+ return { attrs: {}, body: text };
190
594
  }
191
- if (prev.includes(MARKER) && ctx.force) {
192
- prev = stripInjectedBlock(prev);
595
+ const rest = text.slice(4);
596
+ const endIdx = rest.indexOf("\n---\n");
597
+ const altEndIdx = rest.indexOf("\n---\r\n");
598
+ const end = endIdx >= 0 ? endIdx : altEndIdx >= 0 ? altEndIdx : -1;
599
+ if (end < 0) return { attrs: {}, body: text };
600
+ const fmText = rest.slice(0, end);
601
+ const body = rest.slice(end + (endIdx >= 0 ? 5 : 6));
602
+ let attrs = {};
603
+ try {
604
+ const parsed = YAML.parse(fmText);
605
+ if (parsed && typeof parsed === "object") attrs = parsed;
606
+ } catch {
607
+ attrs = {};
193
608
  }
194
- const block = `${section}\n${END_MARKER}\n`;
195
- const next = prev.replace(/\s+$/, "") + (prev ? "\n" : "") + block;
196
- fs.writeFileSync(filePath, next, "utf8");
197
- console.log(`updated ${filePath}`);
609
+ return { attrs, body };
198
610
  }
199
611
 
200
612
  /**
201
- * @param {string} filePath
202
- * @param {string} body
203
- * @param {{ force: boolean; dryRun: boolean }} ctx
613
+ * Idempotent upsert of a marker-delimited block + footer line.
614
+ * prev : current file text (may be empty)
615
+ * marker, endMarker : <!-- --> tokens
616
+ * body : replacement body (should contain or wrap with the markers itself;
617
+ * we just splice it in verbatim)
618
+ * footer : single line `<!-- ship-cli: installed-from … -->`
204
619
  */
205
- async function writeNew(filePath, body, ctx) {
206
- if (ctx.dryRun) return;
207
- if (fs.existsSync(filePath) && fs.readFileSync(filePath, "utf8").includes(MARKER) && !ctx.force) {
208
- console.log(`skip (exists): ${filePath}`);
209
- return;
620
+ function upsertMarkedBlock(prev, { marker, endMarker, body, footer }) {
621
+ // Strip every existing "installed-from" footer so we never duplicate them.
622
+ let stripped = prev.replace(INSTALLED_FROM_RE, "");
623
+ // Collapse any 3+ consecutive newlines left by the strip.
624
+ stripped = stripped.replace(/\n{3,}/g, "\n\n");
625
+
626
+ let out;
627
+ if (stripped.includes(marker) && stripped.includes(endMarker)) {
628
+ const start = stripped.indexOf(marker);
629
+ const endAt = stripped.indexOf(endMarker, start) + endMarker.length;
630
+ const before = stripped.slice(0, start).replace(/\s+$/, "");
631
+ const after = stripped.slice(endAt).replace(/^\s+/, "");
632
+ out = `${before}${before ? "\n\n" : ""}${body.trim()}\n${after ? `\n${after}` : ""}`;
633
+ } else if (stripped.trim().length === 0) {
634
+ out = `${body.trim()}\n`;
635
+ } else {
636
+ out = `${stripped.replace(/\s+$/, "")}\n\n${body.trim()}\n`;
637
+ }
638
+
639
+ // Append a single footer line.
640
+ out = `${out.replace(/\s+$/, "")}\n\n${footer}\n`;
641
+ return out;
642
+ }
643
+
644
+ function extractInstalledVersion(text) {
645
+ const matches = [...text.matchAll(INSTALLED_FROM_RE)];
646
+ if (!matches.length) return null;
647
+ const last = matches[matches.length - 1][1];
648
+ const m = last.match(FOOTER_VERSION_RE);
649
+ return m ? m[1] : null;
650
+ }
651
+
652
+ // ── Cache helpers ──────────────────────────────────────────────────────────
653
+ function latestCachedVersion(shipRoot, kind, id) {
654
+ const all = listCached(shipRoot).filter((c) => c.kind === kind && c.id === id);
655
+ if (!all.length) return null;
656
+ all.sort((a, b) => cmpSemver(b.version, a.version));
657
+ return all[0].version;
658
+ }
659
+
660
+ function cmpSemver(a, b) {
661
+ const pa = String(a).split(/[.-]/).map((x) => (Number.isNaN(Number(x)) ? x : Number(x)));
662
+ const pb = String(b).split(/[.-]/).map((x) => (Number.isNaN(Number(x)) ? x : Number(x)));
663
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
664
+ const xa = pa[i];
665
+ const xb = pb[i];
666
+ if (xa === undefined) return -1;
667
+ if (xb === undefined) return 1;
668
+ if (xa === xb) continue;
669
+ if (typeof xa === typeof xb) return xa < xb ? -1 : 1;
670
+ return typeof xa === "number" ? -1 : 1;
210
671
  }
211
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
212
- fs.writeFileSync(filePath, body, "utf8");
213
- console.log(`wrote ${filePath}`);
672
+ return 0;
214
673
  }
215
674
 
216
- /** @param {string} prev */
217
- function stripInjectedBlock(prev) {
218
- const start = prev.indexOf(MARKER);
219
- if (start === -1) return prev;
220
- const end = prev.indexOf(END_MARKER, start);
221
- if (end === -1) return prev.slice(0, start).replace(/\n{3,}$/, "\n\n");
222
- return (prev.slice(0, start) + prev.slice(end + END_MARKER.length)).replace(/\n{3,}/g, "\n\n");
675
+ function readPresetArtifact(shipRoot, config) {
676
+ const preset = config.stack?.preset;
677
+ if (!preset) return null;
678
+ const id = `preset-${preset}`;
679
+ const version = latestCachedVersion(shipRoot, "collection", id);
680
+ if (!version) return null;
681
+ const cached = readCached(shipRoot, "collection", id, version);
682
+ if (!cached) return null;
683
+ const { attrs, body } = parseFrontmatter(cached.content);
684
+ return { id, version, attrs, body };
685
+ }
686
+
687
+ function copyPlaybookFromCache(shipRoot) {
688
+ const version = latestCachedVersion(shipRoot, "collection", "adoption-playbook");
689
+ if (!version) return null;
690
+ const cached = readCached(shipRoot, "collection", "adoption-playbook", version);
691
+ if (!cached) return null;
692
+ const dest = path.join(shipRoot, ".ship", "playbooks", `adoption-playbook@${version}.md`);
693
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
694
+ fs.writeFileSync(dest, cached.content, "utf8");
695
+ return { path: path.relative(shipRoot, dest), version };
696
+ }
697
+
698
+ // ── Telemetry prompt ───────────────────────────────────────────────────────
699
+ async function promptTelemetry() {
700
+ const rl = readline.createInterface({ input, output });
701
+ try {
702
+ const ans = (
703
+ await rl.question(
704
+ "Share anonymous artifact usage with Ship to improve the methodology? [y/N] ",
705
+ )
706
+ )
707
+ .trim()
708
+ .toLowerCase();
709
+ return ans === "y" || ans === "yes";
710
+ } finally {
711
+ rl.close();
712
+ }
713
+ }
714
+
715
+ // ── Plan summary (dry-run) ─────────────────────────────────────────────────
716
+ function buildPlanSummary(cwd, config, opts, telemetryMode, derived) {
717
+ const stack = {
718
+ tracker: config.stack.tracker,
719
+ ci: config.stack.ci,
720
+ preset: config.stack.preset,
721
+ language: config.stack.language,
722
+ agents: [...(config.stack.agents || [])],
723
+ };
724
+ const rules = opts.copyRules
725
+ ? (config.stack.agents || []).map((a) => ({
726
+ agent: a,
727
+ path:
728
+ (KNOWN_AGENTS[a] && KNOWN_AGENTS[a].targetRel.join("/")) ||
729
+ `.ship/rules/${a}.md`,
730
+ from: `collection/agent-rules-${a}@<latest>`,
731
+ }))
732
+ : [];
733
+ const bootstrapPreview = opts.bootstrap
734
+ ? renderPlan(config, null).summary
735
+ : null;
736
+ return {
737
+ ok: true,
738
+ dry_run: true,
739
+ cwd,
740
+ config_path: path.join(cwd, CONFIG_REL),
741
+ telemetry: telemetryMode,
742
+ channel: config.api?.channel || "stable",
743
+ stack,
744
+ artifacts_to_fetch: derived,
745
+ rules,
746
+ bootstrap: bootstrapPreview,
747
+ playbook: opts.copyPlaybook ? { requested: true, fetched: false } : null,
748
+ };
749
+ }
750
+
751
+ function printHumanPlan(plan, opts) {
752
+ const lines = [];
753
+ lines.push("Ship init — planned changes");
754
+ lines.push("---------------------------");
755
+ lines.push(`cwd: ${plan.cwd}`);
756
+ lines.push(`config: ${plan.config_path}`);
757
+ lines.push(`telemetry: ${plan.telemetry}`);
758
+ lines.push(`channel: ${plan.channel}`);
759
+ lines.push(
760
+ `stack: tracker=${plan.stack.tracker} ci=${plan.stack.ci} preset=${plan.stack.preset} language=${plan.stack.language}`,
761
+ );
762
+ lines.push(`agents: ${plan.stack.agents.join(", ") || "(none)"}`);
763
+ if (plan.artifacts_to_fetch.length) {
764
+ lines.push("");
765
+ lines.push("Artifacts to fetch:");
766
+ for (const a of plan.artifacts_to_fetch) lines.push(` - ${a.kind}/${a.id}`);
767
+ }
768
+ if (plan.rules.length) {
769
+ lines.push("");
770
+ lines.push("Rules to install (--copy-rules):");
771
+ for (const r of plan.rules) lines.push(` - ${r.path} (${r.from})`);
772
+ }
773
+ if (plan.bootstrap) {
774
+ lines.push("");
775
+ lines.push("Bootstrap plan:");
776
+ for (const f of plan.bootstrap.files) lines.push(` - ${f.mode}: ${f.path}`);
777
+ }
778
+ if (plan.playbook) {
779
+ lines.push("");
780
+ lines.push("--copy-playbook: requested (fetched during real run only)");
781
+ }
782
+ if (!opts.copyRules) {
783
+ lines.push("");
784
+ lines.push("(--copy-rules not set: rules files will NOT be installed)");
785
+ }
786
+ process.stdout.write(`${lines.join("\n")}\n\n`);
787
+ }
788
+
789
+ // ── Human summary (real run) ───────────────────────────────────────────────
790
+ function printHumanSummary(summary, ensured, opts) {
791
+ const lines = [];
792
+ lines.push("Ship init complete");
793
+ lines.push("-----------------");
794
+ lines.push(`Config: ${path.relative(summary.cwd, summary.config_path) || summary.config_path}`);
795
+ lines.push(
796
+ `Agents: ${summary.stack.agents.length ? summary.stack.agents.join(", ") : "(none)"}`,
797
+ );
798
+ lines.push(`Tracker: ${summary.stack.tracker}`);
799
+ lines.push(`CI: ${summary.stack.ci}`);
800
+ lines.push(`Preset: ${summary.stack.preset}`);
801
+ lines.push(`Channel: ${summary.channel}`);
802
+ lines.push(`Telemetry: ${summary.telemetry}`);
803
+
804
+ if (summary.rules.length) {
805
+ lines.push("");
806
+ lines.push("Installed rules:");
807
+ for (const r of summary.rules) {
808
+ lines.push(` - ${r.action} ${r.path} (from ${r.from})`);
809
+ }
810
+ }
811
+
812
+ if (summary.bootstrap) {
813
+ lines.push("");
814
+ lines.push(`Bootstrap (preset=${summary.stack.preset}):`);
815
+ for (const r of summary.bootstrap.results) {
816
+ lines.push(` - ${r.action}: ${r.path}`);
817
+ }
818
+ }
819
+
820
+ if (summary.playbook) {
821
+ lines.push("");
822
+ lines.push(`Playbook: wrote ${summary.playbook.path} (@${summary.playbook.version})`);
823
+ } else if (opts.copyPlaybook) {
824
+ lines.push("");
825
+ lines.push("Playbook: not found on manifest (skipped)");
826
+ }
827
+
828
+ if (summary.sync) {
829
+ lines.push("");
830
+ lines.push(
831
+ `Sync: up_to_date=${summary.sync.up_to_date} updated=${summary.sync.updated} failed=${summary.sync.failed}`,
832
+ );
833
+ }
834
+
835
+ lines.push("");
836
+ lines.push("Next:");
837
+ lines.push(" shipctl sync # keep artifacts fresh");
838
+ lines.push(" shipctl verify # check tracker labels, CI secrets, rules markers");
839
+ lines.push(" shipctl feedback draft # submit improvement idea");
840
+
841
+ process.stdout.write(`${lines.join("\n")}\n`);
842
+ }
843
+
844
+ // ── Help ──────────────────────────────────────────────────────────────────-
845
+ function printInitHelp() {
846
+ const agentsList = AGENT_IDS.slice().sort().join(", ");
847
+ process.stdout.write(`shipctl init — bootstrap .ship/, fetch artifacts, install agent rules.
848
+
849
+ USAGE
850
+ shipctl init [--yes] [--force] [--dry-run] [--cwd DIR] [--json]
851
+ [--agents cursor,codex,claude-md]
852
+ [--tracker <name>] [--ci <name>] [--preset <name>]
853
+ [--language <id>] [--channel stable|edge]
854
+ [--copy-rules] [--copy-playbook] [--bootstrap]
855
+ [--telemetry on|off|ask]
856
+
857
+ FLAGS
858
+ --yes Non-interactive: skip confirmation prompts.
859
+ --force Replace existing ship-managed blocks with current content.
860
+ --dry-run Preview only; no files written, no network writes.
861
+ --json Emit the final summary as a JSON object (stdout).
862
+ --cwd DIR Operate against DIR instead of the current working dir.
863
+ --agents <csv> Comma-separated agent ids. Example: cursor,codex,claude-md.
864
+ --tracker <name> Stack tracker: ${TRACKERS.join("|")}
865
+ --ci <name> Stack CI: ${CIS.join("|")}
866
+ --preset <name> Stack preset: ${PRESETS.join("|")}
867
+ --language <id> Repo language: ${LANGUAGES.join("|")}
868
+ --channel <c> Override config.api.channel: ${CHANNELS.join("|")}
869
+ --copy-rules Install collection/agent-rules-<agent>@<v> from cache to its install_target.
870
+ --copy-playbook Try to fetch collection/adoption-playbook and copy it under .ship/playbooks/.
871
+ --bootstrap Also render CI/tracker scaffolding (SHIP_BOOTSTRAP_PLAN.md etc.).
872
+ --telemetry Explicit telemetry choice (default: prompt on first init, off in --yes / non-TTY).
873
+
874
+ BEHAVIOR
875
+ 1. Ensures .ship/ exists and writes config.yml + state.json + cache/.gitkeep + .gitignore.
876
+ 2. Runs built-in adapter detection (doctor --no-network) to propose any stack fields
877
+ the flags / existing config left at defaults.
878
+ 3. Calls shipctl sync for collection/agent-rules-<agent> + collection/preset-<preset>.
879
+ 4. With --copy-rules, installs each cached rules artifact to its install_target,
880
+ preserving unrelated content and marker-guarded sections. Re-runs are idempotent;
881
+ --force replaces a previously-installed different version.
882
+ 5. With --bootstrap, renders CI/tracker skeletons from the preset artifact
883
+ (full support: mobile-app + gh-actions + linear; plan-only otherwise).
884
+
885
+ KNOWN AGENT IDS
886
+ ${agentsList}
887
+
888
+ EXAMPLES
889
+ shipctl init --yes --agents cursor,claude-md --copy-rules --telemetry off
890
+ shipctl init --yes --bootstrap --agents cursor,codex --tracker linear \\
891
+ --ci gh-actions --preset mobile-app --copy-rules
892
+ shipctl init --dry-run --agents cursor --copy-rules --bootstrap \\
893
+ --preset mobile-app --ci gh-actions --tracker linear
894
+ `);
223
895
  }