@elmundi/ship-cli 0.14.2 → 0.15.3

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 (39) hide show
  1. package/README.md +17 -16
  2. package/bin/shipctl.mjs +4 -80
  3. package/lib/commands/feedback.mjs +1 -1
  4. package/lib/commands/help.mjs +47 -131
  5. package/lib/commands/init.mjs +17 -250
  6. package/lib/commands/knowledge.mjs +25 -328
  7. package/lib/commands/preflight.mjs +213 -0
  8. package/lib/commands/run.mjs +266 -116
  9. package/lib/commands/trigger.mjs +95 -10
  10. package/lib/config/schema.mjs +68 -11
  11. package/lib/http.mjs +0 -2
  12. package/lib/runtime/routines.mjs +34 -0
  13. package/lib/templates.mjs +2 -2
  14. package/lib/verify/checks/agents-on-disk.mjs +5 -28
  15. package/lib/verify/registry.mjs +7 -8
  16. package/package.json +1 -1
  17. package/lib/artifacts/fs-index.mjs +0 -230
  18. package/lib/cache/store.mjs +0 -422
  19. package/lib/commands/bootstrap.mjs +0 -4
  20. package/lib/commands/callback.mjs +0 -742
  21. package/lib/commands/docs.mjs +0 -90
  22. package/lib/commands/kickoff.mjs +0 -192
  23. package/lib/commands/lanes.mjs +0 -566
  24. package/lib/commands/manifest-catalog.mjs +0 -251
  25. package/lib/commands/migrate.mjs +0 -204
  26. package/lib/commands/new.mjs +0 -452
  27. package/lib/commands/patterns.mjs +0 -160
  28. package/lib/commands/process.mjs +0 -388
  29. package/lib/commands/search.mjs +0 -43
  30. package/lib/commands/sync.mjs +0 -824
  31. package/lib/config/migrate.mjs +0 -223
  32. package/lib/find-ship-root.mjs +0 -75
  33. package/lib/process/specialist-prompt-contract.mjs +0 -171
  34. package/lib/state/lockfile.mjs +0 -180
  35. package/lib/vendor/run-agent.workflow.yml +0 -254
  36. package/lib/verify/checks/artifacts-up-to-date.mjs +0 -78
  37. package/lib/verify/checks/cache-integrity.mjs +0 -51
  38. package/lib/verify/checks/gitignore-cache.mjs +0 -51
  39. package/lib/verify/checks/rules-markers.mjs +0 -135
@@ -1,18 +1,14 @@
1
1
  /**
2
- * `shipctl knowledge` — manage workspace knowledge buckets.
2
+ * `shipctl knowledge` — read-only access to workspace knowledge buckets.
3
3
  *
4
- * The canonical knowledge surface is now Ship-owned:
5
- * ``knowledge_buckets`` contain ``bucket_articles`` and
6
- * ``knowledge_sources`` records where each article came from. The
7
- * historical ``init`` command remains as a compatibility wrapper for
8
- * starter PRs, while ``bootstrap`` is the GitHub Actions entry point
9
- * that opens the generated knowledge PR after wizard seed merge.
4
+ * The agent calls this during a routine run to pull bucket articles
5
+ * into context (the same surface the Navigator chat reads). Bucket
6
+ * authoring, ingestion, and intel harvest live server-side now;
7
+ * starter docs are written by the wizard at workspace seed time.
10
8
  *
11
9
  * Usage:
12
10
  *
13
- * shipctl knowledge fetch repository-context --workspace <id>
14
- * shipctl knowledge bootstrap --workspace <id> --repo <id|owner/name>
15
- * shipctl knowledge refresh-intel --workspace <id> --repo <id|owner/name>
11
+ * shipctl knowledge fetch <bucket-slug> [--workspace <id>] [--json]
16
12
  *
17
13
  * Auth: bearer token from ``SHIP_API_TOKEN`` (the same env var the
18
14
  * console docs describe for CLI sessions minted under Settings →
@@ -28,28 +24,15 @@
28
24
  * 4. ``https://api.ship.elmundi.com`` as the canonical production
29
25
  * workspace API.
30
26
  *
31
- * Workspace + repo resolution:
27
+ * Workspace resolution:
32
28
  *
33
- * - ``--workspace`` pins a workspace id; otherwise we fetch
29
+ * - ``--workspace`` pins a workspace id; ``SHIP_WORKSPACE_ID`` env
30
+ * var serves as a fallback. Without either we fetch
34
31
  * ``GET /v1/workspaces`` and pick the only row. If there are
35
- * multiple rows we abort with a helpful message so the caller
36
- * either supplies ``--workspace`` or narrows their PAT.
37
- * - ``--repo`` pins a repo id (uuid) or a full_name like
38
- * ``owner/name``; otherwise we fetch
39
- * ``GET /v1/workspaces/{ws}/repos`` and pick the most-recently
40
- * activated row — the same heuristic the wizard uses, so
41
- * ``shipctl knowledge init`` on a freshly-onboarded workspace
42
- * seeds the repo the user just activated.
32
+ * multiple rows we abort with a helpful message.
43
33
  */
44
34
 
45
- const VERSION = "v1";
46
-
47
- /** Static starters with source markdown under ``artifacts/knowledge-starters``.
48
- * Procedural catalog recipes are exposed by the backend under the
49
- * ``ship-recipes/<pattern-id>`` prefix because that list is generated from
50
- * on-disk pattern artifacts at runtime. */
51
- export const STATIC_KNOWLEDGE_SLUGS = ["code-style", "ui-runbook"];
52
- export const RECIPE_KNOWLEDGE_PREFIX = "ship-recipes/";
35
+ const VERSION = "v2";
53
36
 
54
37
  /**
55
38
  * @param {{baseUrl?: string, json?: boolean}} ctx
@@ -61,22 +44,10 @@ export async function knowledgeCommand(ctx, rest) {
61
44
  printKnowledgeHelp();
62
45
  return;
63
46
  }
64
- if (sub === "init") {
65
- await knowledgeInitCommand(ctx, args);
66
- return;
67
- }
68
47
  if (sub === "fetch") {
69
48
  await knowledgeFetchCommand(ctx, args);
70
49
  return;
71
50
  }
72
- if (sub === "bootstrap") {
73
- await knowledgeBootstrapCommand(ctx, args);
74
- return;
75
- }
76
- if (sub === "refresh-intel" || sub === "refresh-context") {
77
- await knowledgeRefreshIntelCommand(ctx, args);
78
- return;
79
- }
80
51
  console.error(
81
52
  `Unknown 'shipctl knowledge' subcommand: ${sub}\nRun: shipctl knowledge --help`,
82
53
  );
@@ -84,111 +55,31 @@ export async function knowledgeCommand(ctx, rest) {
84
55
  }
85
56
 
86
57
  function printKnowledgeHelp() {
87
- console.log(`shipctl knowledge — manage workspace knowledge buckets (${VERSION})
58
+ console.log(`shipctl knowledge — read workspace knowledge buckets (${VERSION})
88
59
 
89
60
  SUBCOMMANDS
90
- shipctl knowledge init [--workspace <id>] [--repo <id|owner/name>]
91
- [--only <csv>] [--json]
92
61
  shipctl knowledge fetch <bucket-slug> [--workspace <id>] [--json]
93
- shipctl knowledge bootstrap [--workspace <id>] [--repo <id|owner/name>]
94
- [--json]
95
- shipctl knowledge refresh-intel [--workspace <id>] [--repo <id|owner/name>]
96
- [--json]
97
-
98
- INIT FLAGS
99
- --workspace <id> Workspace UUID. Defaults to the only workspace
100
- the caller's PAT can see.
101
- --repo <ref> Workspace repo UUID, or GitHub 'owner/name'.
102
- Defaults to the most-recently activated repo in
103
- the resolved workspace.
104
- --only <csv> Comma-separated starter slugs. Defaults to the
105
- full backend catalog, including static starters
106
- (${STATIC_KNOWLEDGE_SLUGS.join(", ")}) and generated
107
- recipe starters under ${RECIPE_KNOWLEDGE_PREFIX}<pattern-id>.
108
- --base-url URL Workspace control-plane API. See env fallbacks.
109
- --json Emit a machine-readable JSON summary.
110
62
 
111
63
  ENV
112
64
  SHIP_API_TOKEN Required. Bearer PAT minted at /settings.
65
+ SHIP_WORKSPACE_ID Optional. Skips the /v1/workspaces lookup.
113
66
  SHIP_WORKSPACE_API_BASE Optional override for the control plane.
114
67
  SHIP_API_BASE Fallback only (co-located proxies).
115
68
 
116
69
  EXIT
117
- 0 PR opened (or idempotently already present)
70
+ 0 bucket fetched
118
71
  1 arg / config error
119
72
  2 auth error (401)
120
73
  3 network / HTTP 5xx
121
74
  `);
122
75
  }
123
76
 
124
- /**
125
- * @param {{baseUrl?: string, json?: boolean}} ctx
126
- * @param {string[]} args
127
- */
128
- async function knowledgeInitCommand(ctx, args) {
129
- const opts = parseInitArgs(args);
130
- const baseUrl = resolveBaseUrl(opts.baseUrl || explicitGlobalBaseUrl(ctx));
131
- const token = process.env.SHIP_API_TOKEN || "";
132
- if (!token) {
133
- console.error(
134
- "SHIP_API_TOKEN is required. Mint one at /settings in the Ship console.",
135
- );
136
- process.exit(1);
137
- }
138
-
139
- const selection = opts.only;
140
- if (selection !== null) {
141
- const unknown = selection.filter((s) => !isKnownKnowledgeStarterSlug(s));
142
- if (unknown.length) {
143
- console.error(
144
- `Unknown knowledge slug(s): ${unknown.join(", ")}\nKnown static slugs: ${STATIC_KNOWLEDGE_SLUGS.join(", ")}; recipe slugs must start with ${RECIPE_KNOWLEDGE_PREFIX}`,
145
- );
146
- process.exit(1);
147
- }
148
- }
149
-
150
- let workspaceId = opts.workspace;
151
- if (!workspaceId) {
152
- workspaceId = await resolveSoleWorkspace(baseUrl, token);
153
- }
154
-
155
- const repoId = await resolveRepoId(baseUrl, token, workspaceId, opts.repo);
156
-
157
- const body = selection === null ? {} : { selection };
158
- const result = await apiPostJson(
159
- baseUrl,
160
- `/v1/workspaces/${encodeURIComponent(workspaceId)}/repos/${encodeURIComponent(repoId)}/knowledge_seed`,
161
- body,
162
- token,
163
- );
164
-
165
- if (ctx.json || opts.json) {
166
- console.log(JSON.stringify(result, null, 2));
167
- return;
168
- }
169
- const files = Array.isArray(result.files) ? result.files : [];
170
- console.log(
171
- `Seeded compatibility knowledge files for workspace ${workspaceId} / repo ${repoId}:\n` +
172
- ` PR #${result.pr_number}: ${result.pr_url}\n` +
173
- ` Branch: ${result.branch}\n` +
174
- ` Files: ${files.join(", ") || "(none)"}\n` +
175
- `\nShip-owned repository context is refreshed separately with:\n` +
176
- ` shipctl knowledge refresh-intel --workspace ${workspaceId} --repo ${repoId}`,
177
- );
178
- }
179
-
180
- export function isKnownKnowledgeStarterSlug(slug) {
181
- return (
182
- STATIC_KNOWLEDGE_SLUGS.includes(slug) ||
183
- slug.startsWith(RECIPE_KNOWLEDGE_PREFIX)
184
- );
185
- }
186
-
187
77
  async function knowledgeFetchCommand(ctx, args) {
188
78
  const opts = parseFetchArgs(args);
189
79
  const baseUrl = resolveBaseUrl(opts.baseUrl || explicitGlobalBaseUrl(ctx));
190
80
  const token = requireToken();
191
- let workspaceId = opts.workspace;
81
+ let workspaceId =
82
+ opts.workspace || (process.env.SHIP_WORKSPACE_ID || "").trim() || "";
192
83
  if (!workspaceId) {
193
84
  workspaceId = await resolveSoleWorkspace(baseUrl, token);
194
85
  }
@@ -227,87 +118,14 @@ async function knowledgeFetchCommand(ctx, args) {
227
118
  }
228
119
  }
229
120
 
230
- async function knowledgeRefreshIntelCommand(ctx, args) {
231
- const opts = parseRefreshArgs(args);
232
- const baseUrl = resolveBaseUrl(opts.baseUrl || explicitGlobalBaseUrl(ctx));
233
- const token = requireToken();
234
- let workspaceId = opts.workspace;
235
- if (!workspaceId) {
236
- workspaceId = await resolveSoleWorkspace(baseUrl, token);
237
- }
238
- const repoId = await resolveRepoId(baseUrl, token, workspaceId, opts.repo);
239
- const result = await apiPostJson(
240
- baseUrl,
241
- `/v1/workspaces/${encodeURIComponent(workspaceId)}/repos/${encodeURIComponent(repoId)}/intel/harvest`,
242
- {},
243
- token,
244
- );
245
- if (ctx.json || opts.json) {
246
- console.log(JSON.stringify(result, null, 2));
247
- return;
248
- }
249
- const where = result.enqueued
250
- ? `queued as job ${result.job_id || "(unknown)"}`
251
- : `completed inline, intel_id=${result.intel_id || "(none)"}`;
252
- console.log(
253
- `Repository context refresh for workspace ${workspaceId} / repo ${repoId}: ${where}\n` +
254
- `Fetch it with: shipctl knowledge fetch repository-context --workspace ${workspaceId}`,
255
- );
256
- }
257
-
258
- async function knowledgeBootstrapCommand(ctx, args) {
259
- const opts = parseBootstrapArgs(args);
260
- const baseUrl = resolveBaseUrl(opts.baseUrl || explicitGlobalBaseUrl(ctx));
261
- const token = requireToken();
262
- let workspaceId = opts.workspace;
263
- if (!workspaceId) {
264
- workspaceId = await resolveSoleWorkspace(baseUrl, token);
265
- }
266
- const repoId = await resolveRepoId(baseUrl, token, workspaceId, opts.repo);
267
- const result = await apiPostJson(
268
- baseUrl,
269
- `/v1/workspaces/${encodeURIComponent(workspaceId)}/repos/${encodeURIComponent(repoId)}/knowledge/bootstrap`,
270
- {},
271
- token,
272
- );
273
- if (ctx.json || opts.json) {
274
- console.log(JSON.stringify(result, null, 2));
275
- return;
276
- }
277
- const files = Array.isArray(result.files) ? result.files : [];
278
- if (result.status === "already_done") {
279
- console.log(
280
- `Knowledge bootstrap already completed for workspace ${workspaceId} / repo ${repoId}:\n` +
281
- ` PR #${result.pr_number || "?"}: ${result.pr_url || "(unknown)"}`,
282
- );
283
- return;
284
- }
285
- console.log(
286
- `Knowledge bootstrap opened PR #${result.pr_number} for workspace ${workspaceId} / repo ${repoId}:\n` +
287
- ` ${result.pr_url}\n` +
288
- ` Files: ${files.join(", ") || "(none)"}`,
289
- );
290
- }
291
-
292
121
  function explicitGlobalBaseUrl(ctx) {
293
122
  return ctx?.baseUrlSource === "flag" ? ctx.baseUrl : null;
294
123
  }
295
124
 
296
- /**
297
- * @param {string[]} args
298
- * @returns {{
299
- * workspace: string|null,
300
- * repo: string|null,
301
- * only: string[]|null,
302
- * baseUrl: string|null,
303
- * json: boolean,
304
- * }}
305
- */
306
- function parseInitArgs(args) {
125
+ function parseFetchArgs(args) {
307
126
  const out = {
127
+ slug: null,
308
128
  workspace: null,
309
- repo: null,
310
- only: null,
311
129
  baseUrl: null,
312
130
  json: false,
313
131
  };
@@ -329,8 +147,6 @@ function parseInitArgs(args) {
329
147
  while (copy.length) {
330
148
  if (
331
149
  consume("--workspace", "workspace") ||
332
- consume("--repo", "repo") ||
333
- consume("--only", "only") ||
334
150
  consume("--base-url", "baseUrl")
335
151
  ) {
336
152
  continue;
@@ -344,81 +160,19 @@ function parseInitArgs(args) {
344
160
  printKnowledgeHelp();
345
161
  process.exit(0);
346
162
  }
347
- console.error(`Unknown flag: ${copy[0]}`);
348
- process.exit(1);
349
- }
350
- if (out.only !== null) {
351
- out.only = String(out.only)
352
- .split(",")
353
- .map((s) => s.trim())
354
- .filter(Boolean);
355
- }
356
- return out;
357
- }
358
-
359
- function parseFetchArgs(args) {
360
- const out = parseCommonArgs(args, { slug: null });
361
- if (!out.slug) {
362
- console.error("Usage: shipctl knowledge fetch <bucket-slug> [--workspace <id>] [--json]");
363
- process.exit(1);
364
- }
365
- return out;
366
- }
367
-
368
- function parseRefreshArgs(args) {
369
- return parseCommonArgs(args, { repo: null });
370
- }
371
-
372
- function parseBootstrapArgs(args) {
373
- return parseCommonArgs(args, { repo: null });
374
- }
375
-
376
- function parseCommonArgs(args, extra) {
377
- const out = {
378
- workspace: null,
379
- baseUrl: null,
380
- json: false,
381
- ...extra,
382
- };
383
- const copy = [...args];
384
- const consume = (flag, key) => {
385
- if (copy[0] === flag && copy[1] !== undefined) {
386
- copy.shift();
387
- out[key] = String(copy.shift());
388
- return true;
389
- }
390
- const p = `${flag}=`;
391
- if (copy[0] && copy[0].startsWith(p)) {
392
- out[key] = copy[0].slice(p.length);
393
- copy.shift();
394
- return true;
395
- }
396
- return false;
397
- };
398
- while (copy.length) {
399
- if (
400
- consume("--workspace", "workspace") ||
401
- consume("--repo", "repo") ||
402
- consume("--base-url", "baseUrl")
403
- ) {
404
- continue;
405
- }
406
- if (copy[0] === "--json") {
407
- out.json = true;
408
- copy.shift();
409
- continue;
410
- }
411
- if (!String(copy[0]).startsWith("-") && "slug" in out && out.slug === null) {
163
+ if (!String(copy[0]).startsWith("-") && out.slug === null) {
412
164
  out.slug = String(copy.shift());
413
165
  continue;
414
166
  }
415
- if (copy[0] === "--help" || copy[0] === "-h") {
416
- printKnowledgeHelp();
417
- process.exit(0);
418
- }
419
167
  console.error(`Unknown flag: ${copy[0]}`);
420
168
  process.exit(1);
421
169
  }
170
+ if (!out.slug) {
171
+ console.error(
172
+ "Usage: shipctl knowledge fetch <bucket-slug> [--workspace <id>] [--json]",
173
+ );
174
+ process.exit(1);
175
+ }
422
176
  return out;
423
177
  }
424
178
 
@@ -467,53 +221,6 @@ async function resolveSoleWorkspace(baseUrl, token) {
467
221
  return String(rows[0].id);
468
222
  }
469
223
 
470
- /**
471
- * @param {string} baseUrl
472
- * @param {string} token
473
- * @param {string} workspaceId
474
- * @param {string|null} hint
475
- * @returns {Promise<string>}
476
- */
477
- async function resolveRepoId(baseUrl, token, workspaceId, hint) {
478
- // Direct UUID? Accept it verbatim — avoids a list call.
479
- if (hint && /^[0-9a-fA-F-]{32,36}$/.test(hint) && hint.includes("-")) {
480
- return hint;
481
- }
482
- const rows = await apiGetJson(
483
- baseUrl,
484
- `/v1/workspaces/${encodeURIComponent(workspaceId)}/repos`,
485
- token,
486
- );
487
- if (!Array.isArray(rows) || rows.length === 0) {
488
- console.error(
489
- `Workspace ${workspaceId} has no activated repos. Activate one in the console first.`,
490
- );
491
- process.exit(1);
492
- }
493
- if (hint) {
494
- const match = rows.find(
495
- (r) =>
496
- r.full_name === hint ||
497
- `${r.owner ?? ""}/${r.name ?? ""}` === hint ||
498
- r.id === hint,
499
- );
500
- if (!match) {
501
- const known = rows.map((r) => r.full_name ?? r.id).join(", ");
502
- console.error(
503
- `--repo ${hint} doesn't match any activated repo in workspace ${workspaceId}.\nKnown: ${known}`,
504
- );
505
- process.exit(1);
506
- }
507
- return String(match.id);
508
- }
509
- const sorted = [...rows].sort((a, b) => {
510
- const ax = a.activated_at ? Date.parse(a.activated_at) : 0;
511
- const bx = b.activated_at ? Date.parse(b.activated_at) : 0;
512
- return bx - ax;
513
- });
514
- return String(sorted[0].id);
515
- }
516
-
517
224
  /**
518
225
  * @param {string} baseUrl
519
226
  * @param {string} path
@@ -523,16 +230,6 @@ async function apiGetJson(baseUrl, path, token) {
523
230
  return apiRequest(baseUrl, path, "GET", token, null);
524
231
  }
525
232
 
526
- /**
527
- * @param {string} baseUrl
528
- * @param {string} path
529
- * @param {Record<string, unknown>} body
530
- * @param {string} token
531
- */
532
- async function apiPostJson(baseUrl, path, body, token) {
533
- return apiRequest(baseUrl, path, "POST", token, body);
534
- }
535
-
536
233
  /**
537
234
  * @param {string} baseUrl
538
235
  * @param {string} path
@@ -0,0 +1,213 @@
1
+ /**
2
+ * `shipctl preflight` — Phase 4 lifecycle gate.
3
+ *
4
+ * The trigger workflow runs this *before* `shipctl run` so a missing
5
+ * secret or an unauthorised role denial surfaces as a structured
6
+ * inbox-shaped result instead of a half-spawned agent that crashes
7
+ * mid-prompt. Two outputs depending on the result:
8
+ *
9
+ * ready=true → exit 0, JSON body shows the resolved deny list
10
+ * + the env contract that passed.
11
+ * ready=false → exit 0 but `ready: false` and a list of
12
+ * ``missing_secrets`` and/or ``denied_role`` reasons.
13
+ * The workflow's case statement uses this to skip
14
+ * the run cleanly without consuming a Cursor seat.
15
+ *
16
+ * Phase 4 MVP scope (ship narrow, expand on real need):
17
+ * - Secrets check: SHIP_API_TOKEN, SHIP_WORKSPACE_ID,
18
+ * SHIP_API_BASE (or SHIP_WORKSPACE_API_BASE), and CURSOR_API_KEY
19
+ * when provider is cursor (the default).
20
+ * - Role denial surfacing: read the resolved role's `denied_tools`
21
+ * so the workflow can decide whether the role is even runnable
22
+ * in this environment (e.g. a future check that flags "reviewer
23
+ * needs gh auth, not git push" rejections at the runner side).
24
+ * - No tool detection (gh, jq, gitleaks, etc) — those are workflow
25
+ * concerns, and the runner itself complains loudly. We do NOT
26
+ * want preflight to grow into a general 'verify' clone.
27
+ */
28
+
29
+ import path from "node:path";
30
+
31
+ import { findShipRoot, readConfig } from "../config/io.mjs";
32
+ import { resolveProvider } from "../agents/index.mjs";
33
+
34
+ const EXIT_OK = 0;
35
+ const EXIT_USAGE = 2;
36
+
37
+
38
+ export async function preflightCommand(ctx, rest) {
39
+ const args = parseArgs(rest);
40
+ if (args.help) {
41
+ printHelp();
42
+ process.exit(EXIT_OK);
43
+ }
44
+
45
+ const cwd = args.cwd || process.cwd();
46
+ const root = findShipRoot(cwd);
47
+ // Without a Ship root we can still preflight the env — a missing
48
+ // ``.ship/config.yml`` is itself a missing-tool reason.
49
+ const config = root ? readConfig(cwd).config : null;
50
+
51
+ const env = readEnv();
52
+ const missingSecrets = [];
53
+ if (!env.apiToken) missingSecrets.push("SHIP_API_TOKEN");
54
+ if (!env.workspaceId) missingSecrets.push("SHIP_WORKSPACE_ID");
55
+ if (!env.apiBase) missingSecrets.push("SHIP_API_BASE");
56
+
57
+ // Provider-specific secrets only when we know which provider the
58
+ // workflow will resolve to. ``args.routine`` / ``args.specialist``
59
+ // is optional — when absent we report the workspace-default
60
+ // provider (typically ``cursor``).
61
+ const provider = config
62
+ ? resolveProvider(config, args.routine || args.specialist)
63
+ : "cursor";
64
+ if (provider === "cursor" && !env.cursorKey) {
65
+ missingSecrets.push("CURSOR_API_KEY");
66
+ }
67
+
68
+ // Role-side denial surfacing. We don't *enforce* the deny list
69
+ // here — that's the runner's job once an agent runtime ships
70
+ // tool-execution metadata Ship can intercept. Preflight just
71
+ // reports the list so the workflow can pre-empt unsupported
72
+ // combinations (and so `verify` can audit drift between the
73
+ // Ship default and the workspace override).
74
+ let deniedTools = [];
75
+ if (env.apiToken && env.apiBase && env.workspaceId && (args.routine || args.specialist)) {
76
+ const slug = args.specialist || resolveSpecialistFromRoutine(config, args.routine);
77
+ if (slug) {
78
+ try {
79
+ const role = await fetchResolvedRole({
80
+ apiBase: env.apiBase,
81
+ apiToken: env.apiToken,
82
+ workspaceId: env.workspaceId,
83
+ slug,
84
+ });
85
+ deniedTools = Array.isArray(role?.denied_tools) ? role.denied_tools : [];
86
+ } catch (err) {
87
+ // Role-resolve failures degrade — preflight stays useful for
88
+ // the secret-check half even when the API is unreachable.
89
+ if (!ctx.json && !args.json) {
90
+ console.error(
91
+ `warn: agent-role resolve failed (${err instanceof Error ? err.message : err}); deny-list unverified.`,
92
+ );
93
+ }
94
+ }
95
+ }
96
+ }
97
+
98
+ const ready = missingSecrets.length === 0;
99
+ const result = {
100
+ ready,
101
+ provider,
102
+ missing_secrets: missingSecrets,
103
+ denied_tools: deniedTools,
104
+ routine: args.routine || null,
105
+ specialist: args.specialist || null,
106
+ };
107
+
108
+ if (ctx.json || args.json) {
109
+ console.log(JSON.stringify(result, null, 2));
110
+ process.exit(EXIT_OK);
111
+ }
112
+
113
+ if (!ready) {
114
+ console.error(`Ship preflight: NOT READY — missing ${missingSecrets.join(", ")}`);
115
+ } else {
116
+ console.log(`Ship preflight: ready (${provider}${deniedTools.length ? `, deny ${deniedTools.length}` : ""})`);
117
+ }
118
+ process.exit(EXIT_OK);
119
+ }
120
+
121
+
122
+ function parseArgs(rest) {
123
+ const out = {
124
+ routine: null,
125
+ specialist: null,
126
+ cwd: null,
127
+ json: false,
128
+ help: false,
129
+ };
130
+ const copy = [...rest];
131
+ while (copy.length) {
132
+ const a = copy[0];
133
+ if (a === "--help" || a === "-h") { out.help = true; copy.shift(); continue; }
134
+ if (a === "--json") { out.json = true; copy.shift(); continue; }
135
+ if (a === "--routine" && copy[1] !== undefined) { out.routine = copy[1]; copy.splice(0, 2); continue; }
136
+ if (a === "--specialist" && copy[1] !== undefined) { out.specialist = copy[1]; copy.splice(0, 2); continue; }
137
+ if (a === "--cwd" && copy[1] !== undefined) { out.cwd = path.resolve(copy[1]); copy.splice(0, 2); continue; }
138
+ console.error(`unknown argument: ${a}`);
139
+ process.exit(EXIT_USAGE);
140
+ }
141
+ return out;
142
+ }
143
+
144
+
145
+ function printHelp() {
146
+ console.log(`shipctl preflight — verify the env + role contract before launching the runner.
147
+
148
+ USAGE
149
+ shipctl preflight [--routine <id> | --specialist <slug>] [--json] [--cwd <dir>]
150
+
151
+ OUTPUT
152
+ JSON shape:
153
+ {
154
+ "ready": true|false,
155
+ "provider": "cursor"|...,
156
+ "missing_secrets": ["SHIP_API_TOKEN", ...],
157
+ "denied_tools": ["git_commit", ...], // resolved role's deny list
158
+ "routine": "...",
159
+ "specialist": "..."
160
+ }
161
+
162
+ EXIT
163
+ 0 always (so the workflow's case statement can branch on the JSON);
164
+ the workflow checks 'ready' to decide whether to skip the run.
165
+ `);
166
+ }
167
+
168
+
169
+ function readEnv() {
170
+ return {
171
+ apiBase: stripSlash(
172
+ process.env.SHIP_API_BASE || process.env.SHIP_WORKSPACE_API_BASE || "",
173
+ ),
174
+ apiToken: process.env.SHIP_API_TOKEN || "",
175
+ workspaceId: process.env.SHIP_WORKSPACE_ID || "",
176
+ cursorKey: process.env.CURSOR_API_KEY || "",
177
+ };
178
+ }
179
+
180
+
181
+ function stripSlash(s) {
182
+ return s.replace(/\/+$/, "");
183
+ }
184
+
185
+
186
+ function resolveSpecialistFromRoutine(config, routineId) {
187
+ if (!routineId || !config) return null;
188
+ const routine = config?.process?.routines?.[routineId] || config?.routines?.[routineId];
189
+ if (!routine || typeof routine !== "object") return null;
190
+ const direct = typeof routine.specialist === "string" ? routine.specialist : null;
191
+ if (direct) return direct;
192
+ const nested = typeof routine.specialist?.id === "string" ? routine.specialist.id : null;
193
+ if (nested) return nested;
194
+ // Legacy ``pattern: role-X`` carries the slug as ``X``.
195
+ const pattern = typeof routine.pattern === "string" ? routine.pattern : null;
196
+ if (pattern && pattern.startsWith("role-")) return pattern.slice("role-".length);
197
+ return null;
198
+ }
199
+
200
+
201
+ async function fetchResolvedRole({ apiBase, apiToken, workspaceId, slug }) {
202
+ const url = `${apiBase}/v1/workspaces/${encodeURIComponent(workspaceId)}/agent-roles/${encodeURIComponent(slug)}/resolve`;
203
+ const res = await fetch(url, {
204
+ headers: {
205
+ Accept: "application/json",
206
+ Authorization: `Bearer ${apiToken}`,
207
+ },
208
+ });
209
+ if (!res.ok) {
210
+ throw new Error(`agent-roles resolve ${res.status}`);
211
+ }
212
+ return res.json();
213
+ }