@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,302 @@
1
+ /**
2
+ * `shipctl callback` — report the terminal status of a pipeline run back
3
+ * to Ship.
4
+ *
5
+ * The customer's GitHub Actions workflow runs this in an `if: always()`
6
+ * step at the end of the job. It replaces the 12-line `curl + HEREDOC`
7
+ * boilerplate the previous starter workflows shipped with, so adopters
8
+ * get a one-liner and a versioned CLI instead of hand-rolled JSON that
9
+ * has silently broken every time Ship evolves the callback contract.
10
+ *
11
+ * URL resolution (first hit wins):
12
+ * 1. `--callback-url <url>` flag
13
+ * 2. `SHIP_CALLBACK_URL` env (what the existing workflow.yml injects)
14
+ * 3. `--base-url <https://api.ship.example.com>` + `--run-id <uuid>`
15
+ * (or `SHIP_API_BASE` + `SHIP_RUN_ID` envs) → constructed as
16
+ * `{base}/v1/pipelines/runs/{run_id}/result`.
17
+ *
18
+ * Auth: exclusively the bearer token minted by Ship at dispatch time.
19
+ * - Required env: `SHIP_RUN_TOKEN`. We refuse to fall back to
20
+ * `SHIP_API_TOKEN` (the long-lived operator token used elsewhere in
21
+ * this CLI) because a workflow-context callback must *only* use the
22
+ * short-lived, run-scoped JWT. Cross-auth would silently hide bugs.
23
+ *
24
+ * This is intentionally **not** mounted under the `base-url` global flag
25
+ * (which defaults to the public methodology host); run callbacks hit the
26
+ * orchestration API (`api.ship.elmundi.com`), a different origin, so we
27
+ * take the URL directly from the run context Ship injected.
28
+ */
29
+
30
+ /** @typedef {"succeeded"|"failed"|"cancelled"} TerminalStatus */
31
+
32
+ const STATUS_ALIASES = {
33
+ ok: "succeeded",
34
+ succeeded: "succeeded",
35
+ success: "succeeded",
36
+ pass: "succeeded",
37
+ green: "succeeded",
38
+ fail: "failed",
39
+ failed: "failed",
40
+ failure: "failed",
41
+ red: "failed",
42
+ cancelled: "cancelled",
43
+ canceled: "cancelled",
44
+ cancel: "cancelled",
45
+ };
46
+
47
+ const EXIT_USAGE = 2;
48
+ const EXIT_AUTH = 10;
49
+ const EXIT_CONFIG = 11;
50
+ const EXIT_HTTP = 3;
51
+
52
+ function die(code, msg) {
53
+ console.error(msg);
54
+ process.exit(code);
55
+ }
56
+
57
+ function printCallbackHelp() {
58
+ console.log(`shipctl callback — report a pipeline run's terminal status to Ship.
59
+
60
+ USAGE
61
+ shipctl callback --status <ok|fail|cancelled> [--summary "..."] [--metric k=v]...
62
+
63
+ FLAGS
64
+ --status Terminal status. Aliases: ok|success|succeeded, fail|failed,
65
+ cancelled|canceled. Required.
66
+ --summary One-line human summary (≤1024 chars). Optional.
67
+ --metric k=v Structured metric to attach. Repeatable. Values coerced:
68
+ numbers, booleans (true|false), JSON (prefix { or [), else string.
69
+ Example: --metric tickets_processed=3 --metric dry_run=true
70
+ --run-id Pipeline run UUID (usually set by SHIP_RUN_ID env).
71
+ --callback-url Full callback URL (usually set by SHIP_CALLBACK_URL env).
72
+ --base-url Orchestration API base (default: SHIP_API_BASE env). Combined
73
+ with --run-id to construct the URL when --callback-url absent.
74
+ --json Print the Ship response JSON on success.
75
+ --help Show this help.
76
+
77
+ ENV
78
+ SHIP_RUN_TOKEN (required) Short-lived bearer Ship issued for this run.
79
+ SHIP_CALLBACK_URL (preferred) Full URL of the result endpoint.
80
+ SHIP_RUN_ID Fallback input for --run-id.
81
+ SHIP_API_BASE Fallback input for --base-url.
82
+
83
+ EXAMPLE (inside a workflow.yml ‹if: always()› step)
84
+ shipctl callback --status ok \\
85
+ --summary "Intake processed TICKET-42" \\
86
+ --metric tickets_processed=1 \\
87
+ --metric ticket_ids=LIN-42
88
+ `);
89
+ }
90
+
91
+ /* Parse --metric k=v pairs with sensible coercion. We deliberately keep
92
+ * this small — Ship's callback ``metrics`` blob is a free-form JSON bag,
93
+ * so the CLI should offer the common shorthand (numbers, booleans, JSON
94
+ * literals) without growing a tiny DSL. Strings are the fallback. */
95
+ function coerceMetricValue(raw) {
96
+ if (raw === "") return "";
97
+ if (raw === "true") return true;
98
+ if (raw === "false") return false;
99
+ if (raw === "null") return null;
100
+ if (/^-?\d+$/.test(raw)) {
101
+ const n = Number(raw);
102
+ if (Number.isSafeInteger(n)) return n;
103
+ }
104
+ if (/^-?\d+\.\d+$/.test(raw)) {
105
+ const n = Number(raw);
106
+ if (Number.isFinite(n)) return n;
107
+ }
108
+ const first = raw[0];
109
+ if (first === "{" || first === "[") {
110
+ try {
111
+ return JSON.parse(raw);
112
+ } catch {
113
+ /* fall through to string */
114
+ }
115
+ }
116
+ return raw;
117
+ }
118
+
119
+ function parseMetricArg(tok) {
120
+ const eq = tok.indexOf("=");
121
+ if (eq <= 0) {
122
+ die(EXIT_USAGE, `--metric expects key=value; got: ${tok}`);
123
+ }
124
+ const key = tok.slice(0, eq).trim();
125
+ const value = tok.slice(eq + 1);
126
+ if (!key) die(EXIT_USAGE, `--metric key cannot be empty: ${tok}`);
127
+ return { key, value: coerceMetricValue(value) };
128
+ }
129
+
130
+ export function parseCallbackArgs(rest) {
131
+ const out = {
132
+ status: null,
133
+ summary: null,
134
+ metrics: {},
135
+ runId: null,
136
+ callbackUrl: null,
137
+ baseUrl: null,
138
+ json: false,
139
+ help: false,
140
+ };
141
+ const copy = [...rest];
142
+ /* Tiny arg-munger kept inline rather than pulling a dependency —
143
+ * matches the style of feedback.mjs / patterns.mjs and keeps this CLI
144
+ * zero-prod-deps apart from `yaml`. */
145
+ const strFlag = (name, key) => {
146
+ if (copy[0] === name && copy[1] !== undefined) {
147
+ copy.shift();
148
+ out[key] = String(copy.shift());
149
+ return true;
150
+ }
151
+ const p = `${name}=`;
152
+ if (copy[0] && copy[0].startsWith(p)) {
153
+ out[key] = copy[0].slice(p.length);
154
+ copy.shift();
155
+ return true;
156
+ }
157
+ return false;
158
+ };
159
+ while (copy.length) {
160
+ const a = copy[0];
161
+ if (a === "--help" || a === "-h") {
162
+ out.help = true;
163
+ copy.shift();
164
+ continue;
165
+ }
166
+ if (a === "--json") {
167
+ out.json = true;
168
+ copy.shift();
169
+ continue;
170
+ }
171
+ if (strFlag("--status", "status")) continue;
172
+ if (strFlag("--summary", "summary")) continue;
173
+ if (strFlag("--run-id", "runId")) continue;
174
+ if (strFlag("--callback-url", "callbackUrl")) continue;
175
+ if (strFlag("--base-url", "baseUrl")) continue;
176
+ if (a === "--metric" && copy[1] !== undefined) {
177
+ copy.shift();
178
+ const { key, value } = parseMetricArg(String(copy.shift()));
179
+ out.metrics[key] = value;
180
+ continue;
181
+ }
182
+ if (a && a.startsWith("--metric=")) {
183
+ const raw = a.slice("--metric=".length);
184
+ copy.shift();
185
+ const { key, value } = parseMetricArg(raw);
186
+ out.metrics[key] = value;
187
+ continue;
188
+ }
189
+ die(EXIT_USAGE, `unknown argument: ${a}\nRun: shipctl callback --help`);
190
+ }
191
+ return out;
192
+ }
193
+
194
+ export function normaliseStatus(raw) {
195
+ if (!raw) return null;
196
+ const lower = String(raw).toLowerCase().trim();
197
+ return STATUS_ALIASES[lower] ?? null;
198
+ }
199
+
200
+ export function resolveCallbackUrl(args, env = process.env) {
201
+ if (args.callbackUrl) return args.callbackUrl;
202
+ if (env.SHIP_CALLBACK_URL) return env.SHIP_CALLBACK_URL;
203
+ const runId = args.runId || env.SHIP_RUN_ID || null;
204
+ const base = args.baseUrl || env.SHIP_API_BASE || null;
205
+ if (runId && base) {
206
+ return `${base.replace(/\/$/, "")}/v1/pipelines/runs/${runId}/result`;
207
+ }
208
+ return null;
209
+ }
210
+
211
+ export function buildCallbackBody(args) {
212
+ /** @type {Record<string, unknown>} */
213
+ const body = { status: args.status };
214
+ if (args.summary) body.summary = String(args.summary).slice(0, 1024);
215
+ if (Object.keys(args.metrics).length > 0) body.metrics = args.metrics;
216
+ return body;
217
+ }
218
+
219
+ export async function callbackCommand(_ctx, rest) {
220
+ const args = parseCallbackArgs(rest);
221
+ if (args.help) {
222
+ printCallbackHelp();
223
+ return;
224
+ }
225
+
226
+ const status = normaliseStatus(args.status);
227
+ if (!status) {
228
+ die(
229
+ EXIT_USAGE,
230
+ `--status is required (ok|fail|cancelled). Got: ${args.status ?? "<missing>"}\nRun: shipctl callback --help`,
231
+ );
232
+ }
233
+ args.status = status;
234
+
235
+ const token = process.env.SHIP_RUN_TOKEN;
236
+ if (!token) {
237
+ die(
238
+ EXIT_AUTH,
239
+ "SHIP_RUN_TOKEN env var is required. Ship injects it into workflow_dispatch inputs; set it in the callback step's env block.",
240
+ );
241
+ }
242
+
243
+ const url = resolveCallbackUrl(args);
244
+ if (!url) {
245
+ die(
246
+ EXIT_CONFIG,
247
+ "Cannot resolve callback URL. Set SHIP_CALLBACK_URL (preferred — Ship injects it), or pass --callback-url, or combine SHIP_API_BASE + SHIP_RUN_ID.",
248
+ );
249
+ }
250
+
251
+ const body = buildCallbackBody(args);
252
+
253
+ let res;
254
+ try {
255
+ res = await fetch(url, {
256
+ method: "POST",
257
+ headers: {
258
+ "Content-Type": "application/json",
259
+ Accept: "application/json",
260
+ Authorization: `Bearer ${token}`,
261
+ "User-Agent": await getUA(),
262
+ },
263
+ body: JSON.stringify(body),
264
+ });
265
+ } catch (err) {
266
+ die(EXIT_HTTP, `callback POST failed: ${err instanceof Error ? err.message : err}`);
267
+ return;
268
+ }
269
+
270
+ const text = await res.text();
271
+ if (!res.ok) {
272
+ const hint =
273
+ res.status === 401
274
+ ? " (check SHIP_RUN_TOKEN matches the run Ship dispatched)"
275
+ : res.status === 404
276
+ ? " (check SHIP_RUN_ID — the run may not exist)"
277
+ : res.status === 422
278
+ ? " (check --status is one of succeeded|failed|cancelled)"
279
+ : "";
280
+ die(
281
+ EXIT_HTTP,
282
+ `Ship rejected callback: HTTP ${res.status} ${res.statusText}${hint}\n${text}`,
283
+ );
284
+ return;
285
+ }
286
+
287
+ if (args.json) {
288
+ console.log(text);
289
+ } else {
290
+ console.log(`callback accepted: ${status}${args.summary ? ` — ${args.summary}` : ""}`);
291
+ }
292
+ }
293
+
294
+ /* Lazy import to keep the helper self-contained & testable. */
295
+ async function getUA() {
296
+ try {
297
+ const { getUserAgent } = await import("../version.mjs");
298
+ return getUserAgent();
299
+ } catch {
300
+ return "shipctl-callback";
301
+ }
302
+ }
@@ -0,0 +1,257 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import YAML from "yaml";
4
+ import {
5
+ DEFAULT_CONFIG,
6
+ ensureAnonymousId,
7
+ findShipRoot,
8
+ readConfig,
9
+ writeConfig,
10
+ writeState,
11
+ defaultState,
12
+ SHIP_DIR,
13
+ CONFIG_REL,
14
+ STATE_REL,
15
+ } from "../config/io.mjs";
16
+ import { validateConfig } from "../config/schema.mjs";
17
+
18
+ function parseConfigArgs(rest) {
19
+ const out = { cwd: null, positional: [] };
20
+ const copy = [...rest];
21
+ while (copy.length) {
22
+ const a = copy[0];
23
+ if (a === "--cwd" && copy[1]) {
24
+ copy.shift();
25
+ out.cwd = String(copy.shift());
26
+ continue;
27
+ }
28
+ if (a.startsWith("--cwd=")) {
29
+ out.cwd = a.slice("--cwd=".length);
30
+ copy.shift();
31
+ continue;
32
+ }
33
+ out.positional.push(copy.shift());
34
+ }
35
+ out.cwd = out.cwd || process.cwd();
36
+ return out;
37
+ }
38
+
39
+ function ensureGitignoreEntry(shipRoot) {
40
+ const giPath = path.join(shipRoot, ".gitignore");
41
+ const entries = [
42
+ "# Ship",
43
+ ".ship/cache/",
44
+ ".ship/telemetry-outbox.jsonl",
45
+ ".ship/feedback-drafts/",
46
+ ".ship/state.json",
47
+ ];
48
+ let current = "";
49
+ if (fs.existsSync(giPath)) current = fs.readFileSync(giPath, "utf8");
50
+ const existingLines = new Set(current.split(/\r?\n/).map((l) => l.trim()));
51
+ const toAppend = entries.filter((e) => !existingLines.has(e.trim()));
52
+ if (toAppend.length === 0) return { giPath, changed: false };
53
+ const prefix = current.length === 0 || current.endsWith("\n") ? "" : "\n";
54
+ const tail = current.length === 0 ? `${toAppend.join("\n")}\n` : `${prefix}${toAppend.join("\n")}\n`;
55
+ fs.writeFileSync(giPath, current + tail, "utf8");
56
+ return { giPath, changed: true };
57
+ }
58
+
59
+ function initCmd(rest) {
60
+ const { cwd } = parseConfigArgs(rest);
61
+ const root = path.resolve(cwd);
62
+ const shipDir = path.join(root, SHIP_DIR);
63
+ const filePath = path.join(root, CONFIG_REL);
64
+
65
+ if (fs.existsSync(filePath)) {
66
+ console.error(`exists: ${filePath}`);
67
+ process.exit(1);
68
+ }
69
+
70
+ fs.mkdirSync(shipDir, { recursive: true });
71
+ const config = DEFAULT_CONFIG();
72
+ ensureAnonymousId(config);
73
+
74
+ writeConfig(filePath, config);
75
+ writeState(root, defaultState());
76
+
77
+ const cacheDir = path.join(shipDir, "cache");
78
+ fs.mkdirSync(cacheDir, { recursive: true });
79
+ const keep = path.join(cacheDir, ".gitkeep");
80
+ if (!fs.existsSync(keep)) fs.writeFileSync(keep, "", "utf8");
81
+
82
+ const { giPath, changed } = ensureGitignoreEntry(root);
83
+
84
+ console.log(`created: ${filePath}`);
85
+ console.log(`created: ${path.join(root, STATE_REL)}`);
86
+ console.log(`created: ${cacheDir}/`);
87
+ console.log(`${changed ? "updated" : "ok "}: ${giPath}`);
88
+ }
89
+
90
+ function getAtPath(obj, dottedKey) {
91
+ const parts = parsePath(dottedKey);
92
+ let cur = obj;
93
+ for (const p of parts) {
94
+ if (cur == null) return undefined;
95
+ if (typeof cur !== "object") return undefined;
96
+ cur = cur[p];
97
+ }
98
+ return cur;
99
+ }
100
+
101
+ /**
102
+ * Split a dotted key, preserving `<kind>/<id>` segments under artifacts.pins.
103
+ * Example: artifacts.pins.pattern/cloud-developer → ["artifacts","pins","pattern/cloud-developer"]
104
+ */
105
+ function parsePath(dottedKey) {
106
+ const raw = dottedKey.split(".");
107
+ const out = [];
108
+ for (let i = 0; i < raw.length; i++) {
109
+ if (
110
+ out.length === 2 &&
111
+ out[0] === "artifacts" &&
112
+ out[1] === "pins"
113
+ ) {
114
+ out.push(raw.slice(i).join("."));
115
+ break;
116
+ }
117
+ out.push(raw[i]);
118
+ }
119
+ return out;
120
+ }
121
+
122
+ function setAtPath(obj, dottedKey, value) {
123
+ const parts = parsePath(dottedKey);
124
+ let cur = obj;
125
+ for (let i = 0; i < parts.length - 1; i++) {
126
+ const p = parts[i];
127
+ if (cur[p] == null || typeof cur[p] !== "object") cur[p] = {};
128
+ cur = cur[p];
129
+ }
130
+ cur[parts[parts.length - 1]] = value;
131
+ }
132
+
133
+ function parseValue(raw) {
134
+ if (raw === "true") return true;
135
+ if (raw === "false") return false;
136
+ if (raw === "null") return null;
137
+ if (/^-?\d+$/.test(raw)) return Number(raw);
138
+ if (/^-?\d+\.\d+$/.test(raw)) return Number(raw);
139
+ const trimmed = raw.trim();
140
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
141
+ const inner = trimmed.slice(1, -1).trim();
142
+ if (inner.length === 0) return [];
143
+ return inner.split(",").map((x) => parseValue(x.trim().replace(/^['"]|['"]$/g, "")));
144
+ }
145
+ if (
146
+ (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
147
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))
148
+ ) {
149
+ return trimmed.slice(1, -1);
150
+ }
151
+ return raw;
152
+ }
153
+
154
+ function getCmd(rest) {
155
+ const { cwd, positional } = parseConfigArgs(rest);
156
+ const key = positional[0];
157
+ if (!key) {
158
+ console.error("usage: shipctl config get <dotted.key>");
159
+ process.exit(1);
160
+ }
161
+ const { config } = readConfig(cwd);
162
+ const val = getAtPath(config, key);
163
+ if (val === undefined) {
164
+ console.error(`unknown key: ${key}`);
165
+ process.exit(1);
166
+ }
167
+ if (Array.isArray(val) || (val !== null && typeof val === "object")) {
168
+ console.log(JSON.stringify(val));
169
+ } else {
170
+ console.log(String(val));
171
+ }
172
+ }
173
+
174
+ function setCmd(rest) {
175
+ const { cwd, positional } = parseConfigArgs(rest);
176
+ const [key, ...valueParts] = positional;
177
+ if (!key || valueParts.length === 0) {
178
+ console.error("usage: shipctl config set <dotted.key> <value>");
179
+ process.exit(1);
180
+ }
181
+ const raw = valueParts.join(" ");
182
+ const value = parseValue(raw);
183
+ const { config, filePath } = readConfig(cwd);
184
+ setAtPath(config, key, value);
185
+
186
+ const res = validateConfig(config);
187
+ if (!res.ok) {
188
+ for (const e of res.errors) console.error(e);
189
+ process.exit(10);
190
+ }
191
+ for (const w of res.warnings) console.error(`warn: ${w}`);
192
+
193
+ writeConfig(filePath, config);
194
+ console.log(`${key} = ${JSON.stringify(value)}`);
195
+ }
196
+
197
+ function validateCmd(rest) {
198
+ const { cwd } = parseConfigArgs(rest);
199
+ const { config, filePath } = readConfig(cwd);
200
+ const res = validateConfig(config);
201
+ for (const w of res.warnings) console.error(`warn: ${w}`);
202
+ if (!res.ok) {
203
+ for (const e of res.errors) console.error(e);
204
+ process.exit(10);
205
+ }
206
+ console.log(`ok: ${filePath}`);
207
+ }
208
+
209
+ function showCmd(rest) {
210
+ const { cwd } = parseConfigArgs(rest);
211
+ const { config } = readConfig(cwd);
212
+ process.stdout.write(YAML.stringify(config, { lineWidth: 0, indent: 2 }));
213
+ }
214
+
215
+ function pathCmd(rest) {
216
+ const { cwd } = parseConfigArgs(rest);
217
+ const root = findShipRoot(cwd);
218
+ if (!root) {
219
+ console.log("not found");
220
+ process.exit(1);
221
+ }
222
+ console.log(path.join(root, CONFIG_REL));
223
+ }
224
+
225
+ export async function configCommand(_ctx, rest) {
226
+ const [sub, ...tail] = rest;
227
+ if (!sub || sub === "-h" || sub === "--help" || sub === "help") {
228
+ console.log(`shipctl config <subcommand>
229
+
230
+ Subcommands:
231
+ init [--cwd DIR] Create .ship/config.yml + state.json + cache/.
232
+ get <dotted.key> Print value.
233
+ set <dotted.key> <value> Update value (validates; atomic write).
234
+ validate Validate .ship/config.yml; exit 10 on errors.
235
+ show Pretty-print effective YAML.
236
+ path Print absolute path to config file.
237
+ `);
238
+ return;
239
+ }
240
+ switch (sub) {
241
+ case "init":
242
+ return initCmd(tail);
243
+ case "get":
244
+ return getCmd(tail);
245
+ case "set":
246
+ return setCmd(tail);
247
+ case "validate":
248
+ return validateCmd(tail);
249
+ case "show":
250
+ return showCmd(tail);
251
+ case "path":
252
+ return pathCmd(tail);
253
+ default:
254
+ console.error(`unknown subcommand: config ${sub}`);
255
+ process.exit(1);
256
+ }
257
+ }
@@ -13,7 +13,7 @@ export async function docsCommand(ctx, args) {
13
13
  ship docs feedback --title "..." --summary "..." [--recommendation "line"]... [--source-context "..."]
14
14
 
15
15
  Vector search: ship search <query>
16
- Catalog bodies: ship pattern|tool|workflow|collection fetch <id>
16
+ Catalog bodies: ship pattern|tool|collection fetch <id>
17
17
 
18
18
  Global flags: --base-url URL --json`);
19
19
  return;