@elmundi/ship-cli 0.12.2 → 0.14.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.
package/bin/shipctl.mjs CHANGED
@@ -152,7 +152,9 @@ try {
152
152
  process.exit(0);
153
153
  }
154
154
 
155
- if (cmd === "run") {
155
+ if (cmd === "run" || cmd === "agent-run") {
156
+ // ``agent-run`` is a back-compat alias for ``run`` — older trigger
157
+ // workflows still spell it that way until they re-seed.
156
158
  const { runCommand } = await import("../lib/commands/run.mjs");
157
159
  await runCommand(ctx, rest);
158
160
  process.exit(0);
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Cursor Cloud Agent runtime adapter.
3
+ *
4
+ * Wraps the public ``POST https://api.cursor.com/v0/agents`` API the
5
+ * ElMundi sibling repo uses (``tools/linear-agent/scripts/cloud-agent-launch.mjs``).
6
+ * The shape Ship needs:
7
+ *
8
+ * 1. Launch an agent against ``repo`` at ``ref`` with ``prompt``.
9
+ * 2. Poll until the agent's status is one of the terminal values
10
+ * (``FINISHED`` / ``ERRORED`` / ``CANCELLED``).
11
+ * 3. Return the branch name + final status to ``shipctl run``. Side
12
+ * effects (tracker writes / inbox rows) happen via the agent's
13
+ * own ``POST /agent-runs/finish`` call from inside Cursor — the
14
+ * CLI no longer reads a state file off the branch.
15
+ *
16
+ * Auth: ``CURSOR_API_KEY`` env var, sent as Basic ``<key>:`` per Cursor's
17
+ * docs.
18
+ */
19
+
20
+ import { Buffer } from "node:buffer";
21
+
22
+ const BASE = "https://api.cursor.com";
23
+ const TERMINAL_STATUSES = new Set(["FINISHED", "ERRORED", "CANCELLED"]);
24
+
25
+
26
+ function authHeader() {
27
+ const key = (process.env.CURSOR_API_KEY || "").trim();
28
+ if (!key) {
29
+ throw new Error("CURSOR_API_KEY is not set");
30
+ }
31
+ return "Basic " + Buffer.from(`${key}:`).toString("base64");
32
+ }
33
+
34
+
35
+ async function postJson(path, body) {
36
+ const res = await fetch(`${BASE}${path}`, {
37
+ method: "POST",
38
+ headers: {
39
+ "Content-Type": "application/json",
40
+ Authorization: authHeader(),
41
+ },
42
+ body: JSON.stringify(body),
43
+ });
44
+ const text = await res.text();
45
+ if (!res.ok) {
46
+ throw new Error(`Cursor API ${path} ${res.status}: ${text.slice(0, 500)}`);
47
+ }
48
+ return text ? JSON.parse(text) : {};
49
+ }
50
+
51
+
52
+ async function getJson(path) {
53
+ const res = await fetch(`${BASE}${path}`, {
54
+ headers: { Authorization: authHeader() },
55
+ });
56
+ const text = await res.text();
57
+ if (!res.ok) {
58
+ throw new Error(`Cursor API ${path} ${res.status}: ${text.slice(0, 500)}`);
59
+ }
60
+ return text ? JSON.parse(text) : {};
61
+ }
62
+
63
+
64
+ /**
65
+ * Launch a single Cursor agent and poll until it terminates.
66
+ *
67
+ * @param {object} opts
68
+ * @param {string} opts.repoUrl e.g. ``https://github.com/owner/repo``
69
+ * @param {string} opts.ref branch the agent checks out (default ``main``)
70
+ * @param {string} opts.branchName branch the agent commits onto
71
+ * @param {string} opts.prompt full prompt body
72
+ * @param {boolean} [opts.autoCreatePr] open a PR when done (default false)
73
+ * @param {number} [opts.pollIntervalMs] poll cadence (default 15s)
74
+ * @param {number} [opts.timeoutMs] total deadline (default 30 min)
75
+ * @param {(line: string) => void} [opts.onLog] streaming log hook
76
+ * @returns {Promise<{ agentId: string, branchName: string, status: string, raw: object }>}
77
+ */
78
+ export async function runCursorAgent({
79
+ repoUrl,
80
+ ref = "main",
81
+ branchName,
82
+ prompt,
83
+ autoCreatePr = false,
84
+ pollIntervalMs = 15_000,
85
+ timeoutMs = 30 * 60 * 1000,
86
+ onLog = (l) => console.error(`[cursor] ${l}`),
87
+ } = {}) {
88
+ if (!repoUrl) throw new Error("runCursorAgent: repoUrl required");
89
+ if (!branchName) throw new Error("runCursorAgent: branchName required");
90
+ if (!prompt || typeof prompt !== "string") {
91
+ throw new Error("runCursorAgent: prompt required");
92
+ }
93
+
94
+ const launch = await postJson("/v0/agents", {
95
+ prompt: { text: prompt },
96
+ source: { repository: repoUrl, ref },
97
+ target: {
98
+ branchName,
99
+ autoCreatePr,
100
+ openAsCursorGithubApp: false,
101
+ },
102
+ });
103
+ const agentId = launch.id || launch.agentId || launch.agent_id;
104
+ if (!agentId) {
105
+ throw new Error(`Cursor launch returned no agent id: ${JSON.stringify(launch).slice(0, 300)}`);
106
+ }
107
+ onLog(`launched agent=${agentId} branch=${branchName} ref=${ref}`);
108
+
109
+ const deadline = Date.now() + timeoutMs;
110
+ let lastStatus = launch.status || "CREATING";
111
+ while (Date.now() < deadline) {
112
+ if (TERMINAL_STATUSES.has(String(lastStatus).toUpperCase())) {
113
+ onLog(`agent terminal: status=${lastStatus}`);
114
+ return { agentId, branchName, status: lastStatus, raw: launch };
115
+ }
116
+ await sleep(pollIntervalMs);
117
+ let snapshot;
118
+ try {
119
+ snapshot = await getJson(`/v0/agents/${encodeURIComponent(agentId)}`);
120
+ } catch (err) {
121
+ onLog(`poll error (continuing): ${err.message}`);
122
+ continue;
123
+ }
124
+ const next = (snapshot.status || "").toString();
125
+ if (next && next !== lastStatus) {
126
+ onLog(`status: ${lastStatus} → ${next}`);
127
+ lastStatus = next;
128
+ }
129
+ if (TERMINAL_STATUSES.has(next.toUpperCase())) {
130
+ return { agentId, branchName, status: next, raw: snapshot };
131
+ }
132
+ }
133
+ throw new Error(
134
+ `Cursor agent ${agentId} did not reach a terminal state within ${Math.round(
135
+ timeoutMs / 1000,
136
+ )}s (last status: ${lastStatus})`,
137
+ );
138
+ }
139
+
140
+
141
+ function sleep(ms) {
142
+ return new Promise((r) => setTimeout(r, ms));
143
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Agent runtime dispatcher.
3
+ *
4
+ * E14 picks the runtime per ``.ship/config.yml`` ``agent.default.provider``
5
+ * (and per-routine override under ``agent.overrides.<routine>``). Today
6
+ * Cursor Cloud is the only one wired in; ``claude`` / ``codex`` slots
7
+ * exist so adding them later is a single new file plus an entry in
8
+ * ``RUNTIMES`` here.
9
+ */
10
+
11
+ import { runCursorAgent } from "./cursor.mjs";
12
+
13
+
14
+ const RUNTIMES = {
15
+ cursor: runCursorAgent,
16
+ // claude: runClaudeCodeAgent, // TODO when we add it
17
+ // codex: runCodexAgent, // TODO when we add it
18
+ };
19
+
20
+ const DEFAULT_PROVIDER = "cursor";
21
+
22
+
23
+ export function resolveProvider(config, routineId) {
24
+ const override = config?.agent?.overrides?.[routineId]?.provider;
25
+ if (override) return String(override).toLowerCase();
26
+ const def = config?.agent?.default?.provider;
27
+ if (def) return String(def).toLowerCase();
28
+ return DEFAULT_PROVIDER;
29
+ }
30
+
31
+
32
+ /**
33
+ * Run the configured agent runtime.
34
+ *
35
+ * @param {string} provider — one of ``RUNTIMES``' keys.
36
+ * @param {object} opts — passed straight through to the runtime.
37
+ * @returns {Promise<{ agentId: string, branchName: string, status: string, raw: object }>}
38
+ */
39
+ export async function runAgent(provider, opts) {
40
+ const fn = RUNTIMES[provider];
41
+ if (!fn) {
42
+ const known = Object.keys(RUNTIMES).join(", ") || "(none)";
43
+ throw new Error(
44
+ `agent runtime '${provider}' is not wired in this build. Known: ${known}`,
45
+ );
46
+ }
47
+ return fn(opts);
48
+ }
49
+
50
+
51
+ export const SUPPORTED_PROVIDERS = Object.freeze(Object.keys(RUNTIMES));
@@ -72,12 +72,13 @@ COMMANDS
72
72
  shipctl trigger --event schedule --repo <id|owner/name> [--workspace <id>] [--json]
73
73
  — compute due routines locally, then claim
74
74
  the schedule window in Ship.
75
- shipctl run --routine <id> [--pattern <id>] [--fanout matrix|sequential|concurrent]
76
- [--trigger event|schedule|manual|once]
77
- [--dry-run] [--offline] [--json] [--cwd <dir>]
78
- [--ship-run-id <uuid>] [--ship-callback-url <url>] [--ship-run-token <jwt>]
79
- one-shot routine dispatch entry point.
80
- Use --lane as a legacy alias.
75
+ shipctl run --routine <id> [--dry-run] [--json] [--cwd <dir>]
76
+ execute one routine end-to-end:
77
+ resolve pattern, fetch a ticket
78
+ (if FSM-staged), launch the agent
79
+ runtime, exit on terminal status.
80
+ 'shipctl agent-run' is a back-compat
81
+ alias.
81
82
  shipctl lanes install [--only <csv>] [--ref <git-ref>] [--owner <gh>] [--repo <name>]
82
83
  [--shipctl-version <v>] [--dry-run] [--force] [--json] [--cwd <dir>]
83
84
  shipctl lanes list [--json] [--cwd <dir>]
@@ -1,14 +1,14 @@
1
1
  /**
2
2
  * `shipctl lanes` — generate and manage the thin GitHub Actions caller
3
- * workflows that delegate to the reusable `run-agent.yml` (RFC-0007 Phase 3).
3
+ * workflows that delegate to a **vendored** reusable `run-agent.yml` in the
4
+ * same repository (RFC-0007 Phase 3).
4
5
  *
5
6
  * Each lane in `.ship/config.yml` (v2) gets one file at
6
7
  * .github/workflows/ship-<lane_id>.yml
7
- * whose body is nothing but the `on:` triggers (derived from the lane
8
- * kind) and a single `uses: ElMundiUA/ship/.github/workflows/run-agent.yml@<ref>`
9
- * call. Any customisation flags, payload shape, callback URL wiring — is
10
- * centralised in the reusable workflow so upgrading customer fleets is a
11
- * single ref bump.
8
+ * whose body is the `on:` triggers (derived from the lane kind) and
9
+ * `uses: ./.github/workflows/run-agent.yml`
10
+ * `shipctl lanes install` also writes `.github/workflows/run-agent.yml`
11
+ * from the template bundled inside `@elmundi/ship-cli`.
12
12
  *
13
13
  * Subcommands:
14
14
  * install — render wrappers for every declared lane (or just --only X).
@@ -21,6 +21,7 @@
21
21
 
22
22
  import fs from "node:fs";
23
23
  import path from "node:path";
24
+ import { fileURLToPath } from "node:url";
24
25
  import YAML from "yaml";
25
26
 
26
27
  import { findShipRoot, readConfig } from "../config/io.mjs";
@@ -28,7 +29,6 @@ import {
28
29
  validateConfig,
29
30
  CONFIG_SCHEMA_VERSION,
30
31
  lanePatterns,
31
- lanePrimaryPattern,
32
32
  laneFanout,
33
33
  } from "../config/schema.mjs";
34
34
 
@@ -42,9 +42,16 @@ const BANNER_HEADER = `${BANNER_MARKER}
42
42
  # Regenerate via: shipctl lanes install
43
43
  # Hand edits OUTSIDE the \`ship-cli:\` markers will NOT be overwritten.`;
44
44
 
45
- const DEFAULT_REUSABLE_OWNER = "ElMundiUA";
46
- const DEFAULT_REUSABLE_REPO = "ship";
47
- const DEFAULT_REUSABLE_PATH = ".github/workflows/run-agent.yml";
45
+ /** Same-repo reusable workflow path (GitHub Actions). */
46
+ const LOCAL_REUSABLE_USES = "./.github/workflows/run-agent.yml";
47
+
48
+ const RUN_AGENT_MARKER = "# ship-cli: run-agent v1";
49
+
50
+ function readRunAgentTemplate() {
51
+ const dir = path.dirname(fileURLToPath(import.meta.url));
52
+ const vendor = path.join(dir, "..", "vendor", "run-agent.workflow.yml");
53
+ return fs.readFileSync(vendor, "utf8");
54
+ }
48
55
 
49
56
  function printHelp() {
50
57
  console.log(`shipctl lanes — manage GitHub Actions caller workflows for the
@@ -62,11 +69,8 @@ USAGE
62
69
 
63
70
  FLAGS (install)
64
71
  --only <ids> Comma-separated lane ids to render (default: all).
65
- --ref <git-ref> Git ref of ElMundiUA/ship to pin the reusable workflow
66
- to (default: the shipctl_min version from config.yml,
67
- prefixed with 'v' — e.g. v0.12.0).
68
- --owner <gh-owner> GitHub owner of the ship repo (default: ElMundiUA).
69
- --repo <name> GitHub repo name (default: ship).
72
+ --ref, --owner, --repo Ignored (legacy); reusable workflow is always
73
+ ${LOCAL_REUSABLE_USES} in the caller repo.
70
74
  --shipctl-version <v> Pin the @elmundi/ship-cli version the reusable
71
75
  workflow installs on the runner (default: latest).
72
76
  --force Overwrite wrappers that exist but were not generated
@@ -97,6 +101,45 @@ export async function lanesCommand(ctx, rest) {
97
101
 
98
102
  /* ─────────────────────────── install ─────────────────────────── */
99
103
 
104
+ /**
105
+ * Copy vendored `run-agent.workflow.yml` into `.github/workflows/run-agent.yml`.
106
+ */
107
+ function syncRunAgentTemplate({ runAgentFile, runAgentRel, dryRun, force, installed, skipped }) {
108
+ let template;
109
+ try {
110
+ template = readRunAgentTemplate();
111
+ } catch (err) {
112
+ throw new Error(
113
+ `cannot read bundled run-agent template: ${err instanceof Error ? err.message : err}`,
114
+ );
115
+ }
116
+ const existed = fs.existsSync(runAgentFile);
117
+ if (existed) {
118
+ const cur = fs.readFileSync(runAgentFile, "utf8");
119
+ if (!cur.includes(RUN_AGENT_MARKER) && !force) {
120
+ skipped.push({ path: runAgentRel, reason: "run-agent-exists-without-banner" });
121
+ return;
122
+ }
123
+ if (cur === template) {
124
+ skipped.push({ path: runAgentRel, reason: "run-agent-up-to-date" });
125
+ return;
126
+ }
127
+ }
128
+ if (dryRun) {
129
+ installed.push({
130
+ path: runAgentRel,
131
+ action: existed ? "would-update-run-agent" : "would-write-run-agent",
132
+ });
133
+ return;
134
+ }
135
+ fs.mkdirSync(path.dirname(runAgentFile), { recursive: true });
136
+ fs.writeFileSync(runAgentFile, template, "utf8");
137
+ installed.push({
138
+ path: runAgentRel,
139
+ action: existed ? "updated-run-agent" : "wrote-run-agent",
140
+ });
141
+ }
142
+
100
143
  async function installCmd(ctx, rest) {
101
144
  const args = parseInstallArgs(rest);
102
145
  args.dryRun = args.dryRun || Boolean(ctx.dryRun);
@@ -112,20 +155,26 @@ async function installCmd(ctx, rest) {
112
155
  );
113
156
  }
114
157
 
115
- const ref = args.ref || deriveDefaultRef(config);
116
- const reusable = formatReusableRef({
117
- owner: args.owner || DEFAULT_REUSABLE_OWNER,
118
- repo: args.repo || DEFAULT_REUSABLE_REPO,
119
- ref,
120
- });
121
-
158
+ const reusable = LOCAL_REUSABLE_USES;
122
159
  const targetDir = path.join(shipRoot, WORKFLOW_DIR);
123
- if (!args.dryRun) fs.mkdirSync(targetDir, { recursive: true });
160
+ const runAgentFile = path.join(targetDir, "run-agent.yml");
161
+ const runAgentRel = path.relative(shipRoot, runAgentFile) || runAgentFile;
124
162
 
125
163
  const installed = [];
126
164
  const skipped = [];
127
165
  const errors = [];
128
166
 
167
+ syncRunAgentTemplate({
168
+ runAgentFile,
169
+ runAgentRel,
170
+ dryRun: args.dryRun,
171
+ force: args.force,
172
+ installed,
173
+ skipped,
174
+ });
175
+
176
+ if (!args.dryRun) fs.mkdirSync(targetDir, { recursive: true });
177
+
129
178
  for (const [laneId, lane] of wanted) {
130
179
  const file = path.join(targetDir, `ship-${laneId}.yml`);
131
180
  const rel = path.relative(shipRoot, file) || file;
@@ -169,7 +218,7 @@ async function installCmd(ctx, rest) {
169
218
  };
170
219
 
171
220
  emitSummary(ctx, args, payload, () => {
172
- console.log(`Ship lanes → ${reusable}`);
221
+ console.log(`Ship lanes → reusable ${reusable}`);
173
222
  for (const row of installed) console.log(` ${row.action}: ${row.path}`);
174
223
  for (const row of skipped) console.log(` skipped (${row.reason}): ${row.path}`);
175
224
  for (const row of errors) console.log(` ERROR: ${row.lane}: ${row.error}`);
@@ -191,7 +240,7 @@ function listCmd(ctx, rest) {
191
240
  kind: lane.kind,
192
241
  // ``pattern`` keeps the single-string shape for humans/scripts
193
242
  // that eyeball the first pattern; ``patterns`` always lists all
194
- // so multi-pattern lanes (RFC-0008 C3.1) surface correctly.
243
+ // so multi-pattern routines (RFC-0008 C3.1) surface correctly.
195
244
  pattern: pats[0] || null,
196
245
  patterns: pats,
197
246
  // ``fanout`` resolves to the runtime default (``matrix``) when
@@ -310,16 +359,6 @@ function selectLanes(config, onlyCsv) {
310
359
  return all.filter(([id]) => wanted.has(id));
311
360
  }
312
361
 
313
- function deriveDefaultRef(config) {
314
- const min = config.shipctl_min;
315
- if (typeof min === "string" && /^\d+\.\d+\.\d+/.test(min)) return `v${min}`;
316
- return "main";
317
- }
318
-
319
- function formatReusableRef({ owner, repo, ref }) {
320
- return `${owner}/${repo}/${DEFAULT_REUSABLE_PATH}@${ref}`;
321
- }
322
-
323
362
  /**
324
363
  * Render the caller workflow YAML. The body is structured first as a JS
325
364
  * object so the output is guaranteed to be syntactically valid; we then