@elmundi/ship-cli 0.11.2 → 0.12.1

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.
@@ -2,11 +2,10 @@
2
2
  * `shipctl run` — single entry-point for executing a Ship lane (RFC-0007).
3
3
  *
4
4
  * Today's scope (Phase 1):
5
- * - `kind=once` lanes run end-to-end: resolve config, fetch pattern,
6
- * check idempotency, emit the prompt to stdout, write the marker.
7
- * - `kind=event` and `kind=schedule` lanes are recognised but emit a
8
- * "not yet wired" exit-0 no-op. Phase 3 wires the reusable workflow
9
- * that makes those kinds execute.
5
+ * - `kind=once` lanes run with local idempotency markers.
6
+ * - `kind=event` and `kind=schedule` lanes execute when Ship's trigger
7
+ * router says they are due; router-side audit state prevents duplicate
8
+ * runs for the same schedule window.
10
9
  *
11
10
  * The command intentionally does not fork an agent subprocess. The
12
11
  * reusable workflow pipes shipctl's stdout into the customer's agent
@@ -25,7 +24,13 @@ import fs from "node:fs";
25
24
  import path from "node:path";
26
25
 
27
26
  import { readConfig, findShipRoot } from "../config/io.mjs";
28
- import { validateConfig, CONFIG_SCHEMA_VERSION } from "../config/schema.mjs";
27
+ import {
28
+ validateConfig,
29
+ CONFIG_SCHEMA_VERSION,
30
+ lanePatterns,
31
+ laneFanout,
32
+ LANE_FANOUT_MODES,
33
+ } from "../config/schema.mjs";
29
34
  import { fetchArtifact } from "../http.mjs";
30
35
  import { resolveShipRepoRootForCatalog } from "../find-ship-root.mjs";
31
36
  import { readArtifactFile } from "../artifacts/fs-index.mjs";
@@ -41,16 +46,39 @@ const EXIT_IDEMPOTENCY = 4;
41
46
  const VALID_TRIGGERS = new Set(["event", "schedule", "manual", "once"]);
42
47
 
43
48
  function printHelp() {
44
- console.log(`shipctl run — execute a Ship lane end-to-end.
49
+ console.log(`shipctl run — execute a Ship lane (Automation in the operator console).
50
+
51
+ WHAT THIS COMMAND IS FOR
52
+ shipctl run is the **one-shot dispatch** entry point. It resolves a
53
+ lane from .ship/config.yml, fetches its pattern body, checks
54
+ idempotency, and emits the prompt for an agent to consume. Behaviour
55
+ by lane kind:
56
+ - kind: once — executed fully here, locally.
57
+ - kind: lane / event / — recognised but NOT executed locally;
58
+ schedule those run via the workspace's GitHub
59
+ Actions runner using the reusable
60
+ .github/workflows/run-agent.yml. shipctl
61
+ run exits 0 with a no-op summary so CI
62
+ wrappers can wire them safely.
45
63
 
46
64
  USAGE
47
- shipctl run --lane <id> [--trigger <event|schedule|manual|once>]
65
+ shipctl run --lane <id> [--pattern <id>] [--fanout <matrix|sequential|concurrent>]
66
+ [--trigger <event|schedule|manual|once>]
48
67
  [--dry-run] [--offline]
49
68
  [--ship-run-id <uuid>] [--ship-callback-url <url>] [--ship-run-token <jwt>]
50
69
  [--cwd <dir>] [--json]
51
70
 
52
71
  FLAGS
53
72
  --lane <id> Lane id declared in .ship/config.yml. Required.
73
+ --pattern <id> For multi-pattern lanes: run only this pattern. This
74
+ is the per-entry call issued by the matrix workflow
75
+ (one matrix job per pattern). Must be one of the
76
+ lane's declared patterns.
77
+ --fanout <mode> Override the lane's configured fan-out for this run
78
+ (matrix|sequential|concurrent). Meaningful only
79
+ when the lane has ≥2 patterns and --pattern is not
80
+ set. Matrix mode without --pattern is rejected;
81
+ it requires a driving workflow.
54
82
  --trigger <kind> Force the trigger context (event|schedule|manual|once).
55
83
  If omitted, inferred from GITHUB_EVENT_NAME / SHIP_RUN_TRIGGER.
56
84
  --dry-run Print the plan without touching idempotency markers or callback.
@@ -150,49 +178,107 @@ export async function runCommand(ctx, rest) {
150
178
  process.exit(EXIT_OK);
151
179
  }
152
180
 
153
- /* Phase 1: only `once` executes fully today. The other kinds validate
154
- * and exit 0 with a clear reason so CI wrappers can safely wire them
155
- * now and we flip on behaviour in Phase 3 without re-release. */
156
- if (lane.kind !== "once") {
157
- const summary = {
158
- lane: args.lane,
159
- kind: lane.kind,
160
- trigger: effectiveTrigger.trigger,
161
- status: "noop",
162
- reason: `lane.kind=${lane.kind} is recognised but not yet wired in shipctl run (Phase 3).`,
163
- };
164
- emitSummary(ctx, args, summary);
165
- process.exit(EXIT_OK);
166
- }
167
-
168
- /* --- kind=once path ------------------------------------------------ */
169
-
170
- const patternId = String(lane.pattern);
171
- const patternFetch = await fetchPatternBody({
172
- patternId,
173
- patternVersion: lane.pattern_version || null,
174
- offline: args.offline,
175
- root,
176
- ctx,
177
- config,
178
- });
179
- if (!patternFetch.ok) {
180
- die(EXIT_USAGE, patternFetch.error);
181
+ // RFC-0008 C3.1/C3.2: resolve the list of patterns that this invocation
182
+ // should execute.
183
+ //
184
+ // --pattern <id> → run only that pattern (the per-entry call
185
+ // issued by the matrix workflow). The pattern
186
+ // must be one of the lane's declared patterns,
187
+ // otherwise we refuse so typos don't silently
188
+ // execute an unrelated pattern.
189
+ // (none) → run every pattern the lane declares, using
190
+ // the lane's fan-out mode. Matrix mode without
191
+ // --pattern is rejected because it requires a
192
+ // driving workflow (see run-agent.yml).
193
+ const allPatterns = lanePatterns(lane);
194
+ if (allPatterns.length === 0) {
195
+ die(EXIT_USAGE, `lane ${JSON.stringify(args.lane)} declares no patterns.`);
196
+ }
197
+
198
+ const effectiveFanout = args.fanout || laneFanout(lane);
199
+ let patternsToRun;
200
+ let runMode; // ``single`` | ``sequential`` | ``concurrent``
201
+ if (args.pattern) {
202
+ if (!allPatterns.includes(args.pattern)) {
203
+ die(
204
+ EXIT_USAGE,
205
+ `--pattern=${JSON.stringify(args.pattern)} is not declared on lane ${JSON.stringify(args.lane)}. ` +
206
+ `Known patterns: ${allPatterns.join(", ")}.`,
207
+ );
208
+ }
209
+ patternsToRun = [args.pattern];
210
+ runMode = "single";
211
+ } else if (allPatterns.length === 1) {
212
+ patternsToRun = allPatterns;
213
+ runMode = "single";
214
+ } else if (effectiveFanout === "matrix") {
215
+ die(
216
+ EXIT_USAGE,
217
+ `lane ${JSON.stringify(args.lane)} has fanout=matrix and ${allPatterns.length} patterns ` +
218
+ `but no --pattern was provided. Matrix mode dispatches one 'shipctl run --pattern <id>' per ` +
219
+ `pattern via the workflow (see run-agent.yml). To run them in-process instead, pass ` +
220
+ `--fanout sequential or --fanout concurrent.`,
221
+ );
222
+ } else {
223
+ patternsToRun = allPatterns;
224
+ runMode = effectiveFanout;
181
225
  }
182
226
 
183
- const patternBody = patternFetch.body;
184
- const patternSha = sha256(patternBody);
185
-
186
- const idem = lane.idempotency;
227
+ // Idempotency markers are lane-scoped (not per-pattern) so we read
228
+ // once up front; per-pattern decisions are derived from the
229
+ // concatenated pattern SHA set below so a change to any member of
230
+ // the list re-triggers the run (expected behaviour for audit lanes).
231
+ const idem = lane.kind === "once" ? lane.idempotency : null;
187
232
  let marker = null;
188
- try {
189
- marker = readMarker(cwd, idem.key);
190
- } catch (err) {
191
- await tryCallback(args, "fail", `idempotency read failed: ${err.message}`);
192
- die(EXIT_IDEMPOTENCY, err instanceof Error ? err.message : String(err));
233
+ if (idem) {
234
+ try {
235
+ marker = readMarker(cwd, idem.key);
236
+ } catch (err) {
237
+ await tryCallback(args, "fail", `idempotency read failed: ${err.message}`);
238
+ die(EXIT_IDEMPOTENCY, err instanceof Error ? err.message : String(err));
239
+ }
193
240
  }
194
241
 
195
- const decision = decideRun(marker, patternBody, idem.reset_on || "version-change");
242
+ // Fetch every pattern body first so we can reject the whole run
243
+ // atomically if any one is unavailable — partial success is worse
244
+ // than a hard failure here (the caller can retry once the fetch
245
+ // error is cleared).
246
+ const fetchJobs = patternsToRun.map((patternId) =>
247
+ fetchPatternBody({
248
+ patternId,
249
+ patternVersion: lane.pattern_version || null,
250
+ offline: args.offline,
251
+ root,
252
+ ctx,
253
+ config,
254
+ }).then((result) => ({ patternId, result })),
255
+ );
256
+ // `sequential` vs `concurrent` only differ for future in-process agent
257
+ // invocation; today's CLI just emits the pattern bodies to stdout, so
258
+ // both modes fetch in parallel and print in declared order. We still
259
+ // record the requested mode on the summary so downstream consumers
260
+ // (and future work) can see the intent.
261
+ const fetched = await Promise.all(fetchJobs);
262
+ for (const { patternId, result } of fetched) {
263
+ if (!result.ok) {
264
+ die(EXIT_USAGE, `pattern ${patternId}: ${result.error}`);
265
+ }
266
+ }
267
+ const runs = fetched.map(({ patternId, result }) => ({
268
+ patternId,
269
+ body: result.body,
270
+ source: result.source,
271
+ sha256: sha256(result.body),
272
+ }));
273
+
274
+ // Composite SHA over all pattern bodies. ``reset_on=version-change``
275
+ // fires when any member's body drifts — which is the correct
276
+ // semantics for a multi-pattern audit lane: if one role's playbook
277
+ // updates, we want the whole lane to re-run.
278
+ const compositeBody = runs.map((r) => `#${r.patternId}\n${r.body}`).join("\n---\n");
279
+ const decision = idem
280
+ ? decideRun(marker, compositeBody, idem.reset_on || "version-change")
281
+ : { run: true, reason: "trigger-router-due", marker: null };
196
282
  if (!decision.run) {
197
283
  const summary = {
198
284
  lane: args.lane,
@@ -201,20 +287,25 @@ export async function runCommand(ctx, rest) {
201
287
  status: "noop",
202
288
  reason: "already-done",
203
289
  marker: decision.marker,
290
+ patterns: runs.map((r) => ({ id: r.patternId, sha256: r.sha256 })),
204
291
  };
205
292
  await tryCallback(
206
293
  args,
207
294
  "ok",
208
295
  `lane ${args.lane}: already completed, no-op.`,
209
- { pattern_id: patternId, pattern_sha256: patternSha, noop: true },
296
+ runMode === "single"
297
+ ? { pattern_id: runs[0].patternId, pattern_sha256: runs[0].sha256, noop: true }
298
+ : { patterns: runs.map((r) => r.patternId), noop: true },
210
299
  );
211
300
  emitSummary(ctx, args, summary);
212
301
  process.exit(EXIT_OK);
213
302
  }
214
303
 
215
304
  /* Dry-run stops here — no marker write, no callback, just print the
216
- * plan. We still emit the pattern body to stdout so operators can
217
- * eyeball what the agent would receive. */
305
+ * plan. We still emit the pattern bodies to stdout so operators can
306
+ * eyeball what the agent would receive. Multi-pattern runs print
307
+ * each body preceded by a ``# ship: pattern=<id>`` banner so the
308
+ * agent-side (or a human) can split them back apart. */
218
309
  if (args.dryRun || ctx.dryRun) {
219
310
  const summary = {
220
311
  lane: args.lane,
@@ -222,65 +313,192 @@ export async function runCommand(ctx, rest) {
222
313
  trigger: effectiveTrigger.trigger,
223
314
  status: "dry-run",
224
315
  reason: decision.reason,
225
- pattern: { id: patternId, sha256: patternSha, source: patternFetch.source },
316
+ mode: runMode,
317
+ patterns: runs.map((r) => ({ id: r.patternId, sha256: r.sha256, source: r.source })),
226
318
  };
319
+ if (runs.length === 1) {
320
+ summary.pattern = { id: runs[0].patternId, sha256: runs[0].sha256, source: runs[0].source };
321
+ }
227
322
  if (ctx.json || args.json) {
228
- console.log(JSON.stringify({ ...summary, pattern_body: patternBody }, null, 2));
323
+ console.log(
324
+ JSON.stringify(
325
+ { ...summary, pattern_bodies: Object.fromEntries(runs.map((r) => [r.patternId, r.body])) },
326
+ null,
327
+ 2,
328
+ ),
329
+ );
229
330
  } else {
230
- console.error(`# ship: lane=${args.lane} kind=${lane.kind} trigger=${effectiveTrigger.trigger} (dry-run)`);
231
- process.stdout.write(patternBody.endsWith("\n") ? patternBody : `${patternBody}\n`);
331
+ console.error(
332
+ `# ship: lane=${args.lane} kind=${lane.kind} trigger=${effectiveTrigger.trigger} mode=${runMode} (dry-run)`,
333
+ );
334
+ emitPatternBodies(runs, { json: false });
232
335
  }
233
336
  process.exit(EXIT_OK);
234
337
  }
235
338
 
236
- /* Emit the prompt for the agent to consume (same contract as
237
- * `shipctl kickoff`). The reusable workflow pipes this into the
238
- * configured agent runtime. */
339
+ /* Emit the prompt(s) for the agent to consume (same contract as
340
+ * `shipctl kickoff`). The reusable workflow pipes stdout into the
341
+ * configured agent runtime; multi-pattern output is delimited by a
342
+ * banner line per pattern so consumers can split on it.
343
+ *
344
+ * Workspace policy injection (RFC-Workspace-policy): before any
345
+ * pattern body, fetch the workspace's prose-rule policies from the
346
+ * backend and prepend them as a markdown block. This makes the
347
+ * agent treat the policies as hard preamble — the same shape the
348
+ * Navigator chat injects into ``TopicService.assemble_messages``.
349
+ * Best-effort: a missing token, missing callback URL, or a network
350
+ * failure quietly skips the prepend so local / offline runs still
351
+ * work. */
239
352
  if (!(ctx.json || args.json)) {
240
353
  const provider = resolveAgentProvider(config, args.lane);
241
- if (provider) console.error(`# ship: lane=${args.lane} agent.provider=${provider}`);
242
- process.stdout.write(patternBody.endsWith("\n") ? patternBody : `${patternBody}\n`);
354
+ if (provider) console.error(`# ship: lane=${args.lane} agent.provider=${provider} mode=${runMode}`);
355
+ const preamble = await fetchPoliciesPreamble(args);
356
+ if (preamble) emitPoliciesPreamble(preamble);
357
+ emitPatternBodies(runs, { json: false });
358
+ }
359
+
360
+ if (idem) {
361
+ try {
362
+ writeMarker(cwd, idem.key, {
363
+ lane: args.lane,
364
+ pattern_id: runs[0].patternId,
365
+ pattern_sha256: sha256(compositeBody),
366
+ pattern_version: lane.pattern_version || null,
367
+ patterns: runs.map((r) => ({ id: r.patternId, sha256: r.sha256 })),
368
+ });
369
+ } catch (err) {
370
+ await tryCallback(args, "fail", `idempotency write failed: ${err.message}`);
371
+ die(EXIT_IDEMPOTENCY, err instanceof Error ? err.message : String(err));
372
+ }
243
373
  }
244
374
 
245
- try {
246
- writeMarker(cwd, idem.key, {
375
+ const callbackMetrics = runMode === "single"
376
+ ? { pattern_id: runs[0].patternId, pattern_sha256: runs[0].sha256 }
377
+ : {
378
+ pattern_id: runs[0].patternId,
379
+ pattern_sha256: runs[0].sha256,
380
+ patterns: runs.map((r) => r.patternId).join(","),
381
+ };
382
+ const callbackSummary = runMode === "single"
383
+ ? `lane ${args.lane} completed (pattern ${runs[0].patternId}@${runs[0].sha256.slice(0, 8)}).`
384
+ : `lane ${args.lane} completed (${runs.length} patterns, mode=${runMode}).`;
385
+ const callbackResult = await tryCallback(args, "ok", callbackSummary, callbackMetrics);
386
+
387
+ if (ctx.json || args.json) {
388
+ // For single-pattern runs we keep the legacy ``pattern: {…}`` key
389
+ // alongside the new ``patterns: […]`` list so existing consumers
390
+ // (and tests) don't break when they upgrade shipctl before
391
+ // starting to declare multi-pattern lanes.
392
+ const summaryPayload = {
247
393
  lane: args.lane,
248
- pattern_id: patternId,
249
- pattern_sha256: patternSha,
250
- pattern_version: lane.pattern_version || null,
251
- });
252
- } catch (err) {
253
- await tryCallback(args, "fail", `idempotency write failed: ${err.message}`);
254
- die(EXIT_IDEMPOTENCY, err instanceof Error ? err.message : String(err));
394
+ kind: lane.kind,
395
+ trigger: effectiveTrigger.trigger,
396
+ status: "completed",
397
+ mode: runMode,
398
+ patterns: runs.map((r) => ({ id: r.patternId, sha256: r.sha256, source: r.source })),
399
+ callback: callbackResult,
400
+ };
401
+ if (runs.length === 1) {
402
+ summaryPayload.pattern = { id: runs[0].patternId, sha256: runs[0].sha256, source: runs[0].source };
403
+ }
404
+ console.log(JSON.stringify(summaryPayload, null, 2));
255
405
  }
256
406
 
257
- const callbackResult = await tryCallback(
258
- args,
259
- "ok",
260
- `lane ${args.lane} completed (pattern ${patternId}@${patternSha.slice(0, 8)}).`,
261
- { pattern_id: patternId, pattern_sha256: patternSha },
262
- );
407
+ process.exit(callbackResult.ok === false ? EXIT_CALLBACK : EXIT_OK);
408
+ }
263
409
 
264
- if (ctx.json || args.json) {
265
- console.log(
266
- JSON.stringify(
267
- {
268
- lane: args.lane,
269
- kind: lane.kind,
270
- trigger: effectiveTrigger.trigger,
271
- status: "completed",
272
- pattern: { id: patternId, sha256: patternSha, source: patternFetch.source },
273
- callback: callbackResult,
274
- },
275
- null,
276
- 2,
277
- ),
278
- );
410
+ /**
411
+ * Stream pattern bodies to stdout. For single-pattern runs we write
412
+ * the body as-is (identical byte output to the pre-multi-pattern
413
+ * behaviour, keeping the test harness stable). For multi-pattern
414
+ * runs we precede each body with a ``# ship: pattern=<id>`` banner so
415
+ * downstream consumers can re-split the stream.
416
+ */
417
+ function emitPatternBodies(runs, _opts) {
418
+ if (runs.length === 1) {
419
+ const body = runs[0].body;
420
+ process.stdout.write(body.endsWith("\n") ? body : `${body}\n`);
421
+ return;
422
+ }
423
+ for (const r of runs) {
424
+ process.stdout.write(`# ship: pattern=${r.patternId} sha256=${r.sha256}\n`);
425
+ const body = r.body;
426
+ process.stdout.write(body.endsWith("\n") ? body : `${body}\n`);
279
427
  }
428
+ }
280
429
 
281
- process.exit(callbackResult.ok === false ? EXIT_CALLBACK : EXIT_OK);
430
+ /**
431
+ * Print the workspace-policies preamble once at the top of stdout,
432
+ * before the first pattern body. Trailing ``---`` separator visually
433
+ * distinguishes the preamble from the pattern markdown the agent is
434
+ * about to consume; an extra blank line above the separator keeps
435
+ * the markdown well-formed if the preamble already ends with one.
436
+ */
437
+ function emitPoliciesPreamble(preamble) {
438
+ const trimmed = preamble.endsWith("\n") ? preamble : `${preamble}\n`;
439
+ process.stdout.write(trimmed);
440
+ process.stdout.write("\n---\n");
441
+ }
442
+
443
+ /**
444
+ * Fetch the workspace prose-rule policies for the current run from
445
+ * the Ship backend. The endpoint URL is derived from the callback
446
+ * URL by swapping the trailing ``/result`` segment for
447
+ * ``/policies-preamble`` — both share the same auth dependency
448
+ * (per-run JWT or long-lived ``SHIP_RUN_TOKEN``), so we can reuse
449
+ * the same bearer.
450
+ *
451
+ * Returns the preamble markdown or ``null`` when:
452
+ * - there's no callback URL / token (local invocation),
453
+ * - the URL doesn't end in ``/result`` (someone overrode the
454
+ * callback endpoint to a non-canonical path — too risky to
455
+ * guess),
456
+ * - the backend has no enabled policies (``preamble: null``),
457
+ * - or the request fails for any reason.
458
+ *
459
+ * Failures are surfaced as ``warn:`` lines on stderr so an operator
460
+ * can debug them without breaking the lane execution.
461
+ */
462
+ async function fetchPoliciesPreamble(args) {
463
+ const callbackUrl = args.callbackUrl || process.env.SHIP_CALLBACK_URL;
464
+ if (!callbackUrl) return null;
465
+ const token = args.runToken || process.env.SHIP_RUN_TOKEN;
466
+ if (!token) return null;
467
+ if (!callbackUrl.endsWith("/result")) {
468
+ console.error(
469
+ `warn: SHIP_CALLBACK_URL does not end in /result; skipping policies-preamble fetch (got ${callbackUrl}).`,
470
+ );
471
+ return null;
472
+ }
473
+ const url = `${callbackUrl.slice(0, -"/result".length)}/policies-preamble`;
474
+ try {
475
+ const res = await fetch(url, {
476
+ method: "GET",
477
+ headers: {
478
+ Accept: "application/json",
479
+ Authorization: `Bearer ${token}`,
480
+ },
481
+ });
482
+ if (!res.ok) {
483
+ console.error(
484
+ `warn: policies-preamble fetch returned HTTP ${res.status} ${res.statusText}; continuing without policies.`,
485
+ );
486
+ return null;
487
+ }
488
+ const body = await res.json().catch(() => null);
489
+ if (!body || typeof body !== "object") return null;
490
+ const preamble = body.preamble;
491
+ if (typeof preamble !== "string" || !preamble.trim()) return null;
492
+ return preamble;
493
+ } catch (err) {
494
+ console.error(
495
+ `warn: policies-preamble fetch failed: ${err instanceof Error ? err.message : err}`,
496
+ );
497
+ return null;
498
+ }
282
499
  }
283
500
 
501
+
284
502
  /* ------------------------------------------------------------------ */
285
503
  /* Helpers */
286
504
  /* ------------------------------------------------------------------ */
@@ -288,6 +506,8 @@ export async function runCommand(ctx, rest) {
288
506
  function parseArgs(rest) {
289
507
  const out = {
290
508
  lane: null,
509
+ pattern: null,
510
+ fanout: null,
291
511
  trigger: null,
292
512
  dryRun: false,
293
513
  offline: false,
@@ -336,6 +556,8 @@ function parseArgs(rest) {
336
556
  continue;
337
557
  }
338
558
  if (str("--lane", "lane")) continue;
559
+ if (str("--pattern", "pattern")) continue;
560
+ if (str("--fanout", "fanout")) continue;
339
561
  if (str("--trigger", "trigger")) continue;
340
562
  if (str("--ship-run-id", "runId")) continue;
341
563
  if (str("--ship-callback-url", "callbackUrl")) continue;
@@ -352,6 +574,15 @@ function parseArgs(rest) {
352
574
  `--trigger must be one of ${[...VALID_TRIGGERS].join("|")}; got ${out.trigger}`,
353
575
  );
354
576
  }
577
+ if (out.fanout && !LANE_FANOUT_MODES.includes(out.fanout)) {
578
+ die(
579
+ EXIT_USAGE,
580
+ `--fanout must be one of ${LANE_FANOUT_MODES.join("|")}; got ${out.fanout}`,
581
+ );
582
+ }
583
+ if (out.pattern !== null && (typeof out.pattern !== "string" || !out.pattern.trim())) {
584
+ die(EXIT_USAGE, "--pattern: must be a non-empty pattern id");
585
+ }
355
586
  return out;
356
587
  }
357
588
 
@@ -522,6 +753,15 @@ function fetchFromLockfile({ patternId, root, strict }) {
522
753
 
523
754
  function resolveMethodologyBase(ctx, config) {
524
755
  const fromFlag = ctx.baseUrl;
756
+ const fromEnv =
757
+ typeof process.env.SHIP_API_BASE === "string" && process.env.SHIP_API_BASE.trim()
758
+ ? process.env.SHIP_API_BASE.trim().replace(/\/$/, "")
759
+ : null;
760
+ /* Wizard-seeded Actions secret: exact Ship API origin (``POST /fetch`` lives
761
+ * at the root next to ``/v1``). Do not append ``/api/methodology`` here. */
762
+ if (fromEnv) {
763
+ return fromEnv;
764
+ }
525
765
  const raw = config?.api?.base_url;
526
766
  if (typeof raw === "string" && raw.trim()) {
527
767
  const u = raw.replace(/\/$/, "");
@@ -8,9 +8,9 @@ import { apiPost } from "../http.mjs";
8
8
  export async function searchCommand(ctx, args) {
9
9
  if (!args.length || args[0] === "help" || args[0] === "-h" || args[0] === "--help") {
10
10
  console.log(`Usage:
11
- ship search <query> [--top-k 8]
11
+ shipctl search <query> [--top-k 8]
12
12
 
13
- POST /search on the methodology API (same SHIP_API_BASE as ship pattern/tool/…).
13
+ POST /search on the methodology API (same SHIP_API_BASE as shipctl pattern/tool/…).
14
14
 
15
15
  Global flags: --base-url URL --json`);
16
16
  return;