overlord-cli 0.2605281741.0 → 0.2605290912.0

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/README.md CHANGED
@@ -60,6 +60,8 @@ Common commands:
60
60
  ovld auth login
61
61
  ovld auth status
62
62
  ovld attach
63
+ ovld claude "Refactor the auth middleware" --model opus --thinking high
64
+ ovld codex "Investigate the memory leak" -- --search --full-auto
63
65
  ovld create "Investigate the failing build" --agent codex
64
66
  ovld prompt "Draft a fix for the onboarding flow"
65
67
  ovld version
@@ -85,6 +87,8 @@ ovld doctor
85
87
 
86
88
  For ticket-scoped protocol and launch commands, `ticket_id` values such as `1:899` carry the organization id. The CLI uses that first, then `--organization-id` for UUID compatibility, then stored auth. `ovld auth login` stores the first available organization as a default when an account belongs to multiple organizations; pass `--organization-id <id>` only when you want a different default.
87
89
 
90
+ When `ovld launch` has an explicit `--working-directory`, or when you run it from a registered project directory containing `.overlord/project.json`, it exports `TMPDIR`, `TMP`, `TEMP`, and `OVERLORD_TMPDIR` to that project's `.overlord/tmp/` directory before spawning the agent. Otherwise it falls back to the system temp directory.
91
+
88
92
  ## Requirements
89
93
 
90
94
  - Node.js 20 or newer
@@ -96,6 +100,7 @@ Find full CLI docs here: https://www.ovld.ai/docs/surfaces/cli
96
100
 
97
101
  Top-level commands (see `ovld help`):
98
102
 
103
+ - `<agent> "<prompt>"` - create a ticket from the prompt (project inferred from the current directory) and launch the agent on it in one step, e.g. `ovld claude "fix the flaky login test" --model opus`. Built-in agents need an installed connector (`ovld setup <agent>`, override with `--allow-uninstalled`); your custom agents launch by id. Flags after a standalone `--` pass through to the agent binary.
99
104
  - `attach` - search tickets and launch an agent interactively (`ovld attach [ticketId] [agent]`)
100
105
  - `create` - create a ticket with numbered project selection; supports `--agent`, `--model`, `--delegate` (same delegate flags as `ovld protocol create`)
101
106
  - `prompt` - create a ticket, then launch an agent on it
@@ -0,0 +1,366 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Direct agent launch: `ovld <agent> "<prompt>" [overlord flags] [-- passthrough]`.
5
+ *
6
+ * Creates a ticket from the prompt (project inferred from the working directory),
7
+ * then launches the chosen agent on it — giving power users all of Overlord's
8
+ * tracking without ever opening the desktop/web UI. Launch is limited to agents
9
+ * with an installed connector (built-ins) or one of the user's custom agents.
10
+ *
11
+ * This is a thin composition over existing pieces:
12
+ * - `/api/protocol/prompt` creates + auto-titles the ticket and resolves the
13
+ * project from the working directory.
14
+ * - `runLauncherCommand('run' | 'launch-custom', …)` launches the agent locally.
15
+ */
16
+
17
+ import { buildAuthHeaders, resolveAuth } from './credentials.mjs';
18
+ import {
19
+ BUILTIN_LAUNCH_AGENTS,
20
+ agentIdentifierMap,
21
+ runLauncherCommand
22
+ } from './launcher.mjs';
23
+ import { isAgentConnectorInstalled } from './setup.mjs';
24
+
25
+ /** Overlord flags that take a value (everything else after `--key` is a boolean). */
26
+ const VALUE_FLAGS = new Set([
27
+ 'project-id',
28
+ 'priority',
29
+ 'title',
30
+ 'acceptance-criteria',
31
+ 'available-tools',
32
+ 'working-directory',
33
+ 'model',
34
+ 'thinking',
35
+ 'delegate',
36
+ 'pre-command',
37
+ 'launch-mode',
38
+ 'organization-id',
39
+ 'ssh-command',
40
+ 'remote-working-directory',
41
+ 'server-multiplexer',
42
+ 'tmux-command'
43
+ ]);
44
+
45
+ const BOOLEAN_FLAGS = new Set(['personal', 'for-human', 'allow-uninstalled']);
46
+
47
+ /** Launch flags forwarded verbatim to `ovld launch`/`launch-custom`. */
48
+ const FORWARDED_VALUE_FLAGS = [
49
+ 'pre-command',
50
+ 'launch-mode',
51
+ 'organization-id',
52
+ 'ssh-command',
53
+ 'remote-working-directory',
54
+ 'server-multiplexer',
55
+ 'tmux-command'
56
+ ];
57
+
58
+ export function isBuiltinLaunchAgent(name) {
59
+ return BUILTIN_LAUNCH_AGENTS.includes(String(name ?? '').trim().toLowerCase());
60
+ }
61
+
62
+ /**
63
+ * Substitute `{{token}}` placeholders in a custom-agent command template.
64
+ * Mirrors `resolveCustomAgentCommand` in lib/helpers/custom-agent.ts (kept in
65
+ * sync by hand because the published CLI cannot import the app's TypeScript).
66
+ */
67
+ export function resolveTemplate(template, values) {
68
+ const substituted = String(template).replace(
69
+ /\{\{\s*([\w.-]+)\s*\}\}/g,
70
+ (_match, token) => {
71
+ const value = values[token];
72
+ return typeof value === 'string' ? value.trim() : '';
73
+ }
74
+ );
75
+ return substituted.replace(/\s+/g, ' ').trim();
76
+ }
77
+
78
+ /** Default placeholder values for a custom agent given a model/thinking choice. */
79
+ export function buildCustomAgentValues(agent, model, thinking) {
80
+ const values = {};
81
+ for (const placeholder of agent.placeholders ?? []) {
82
+ if (placeholder.role === 'model' && model) {
83
+ values[placeholder.token] = model;
84
+ } else if (placeholder.role === 'thinking' && thinking) {
85
+ values[placeholder.token] = thinking;
86
+ } else if (Array.isArray(placeholder.options) && placeholder.options.length > 0) {
87
+ values[placeholder.token] = placeholder.options[0].value;
88
+ }
89
+ }
90
+ return values;
91
+ }
92
+
93
+ /**
94
+ * Parse direct-launch args into the objective, recognized Overlord flags, the
95
+ * repeatable `--flag` values, and everything after a standalone `--`.
96
+ */
97
+ export function parseDirectLaunchArgs(rawArgs) {
98
+ const args = Array.isArray(rawArgs) ? rawArgs : [];
99
+ const sepIdx = args.indexOf('--');
100
+ const head = sepIdx === -1 ? args : args.slice(0, sepIdx);
101
+ const passthrough = sepIdx === -1 ? [] : args.slice(sepIdx + 1);
102
+
103
+ const flags = {};
104
+ const repeatedFlags = [];
105
+ const positionals = [];
106
+
107
+ for (let i = 0; i < head.length; i++) {
108
+ const arg = head[i];
109
+ if (!arg.startsWith('--')) {
110
+ positionals.push(arg);
111
+ continue;
112
+ }
113
+
114
+ const eqIdx = arg.indexOf('=');
115
+ const key = eqIdx === -1 ? arg.slice(2) : arg.slice(2, eqIdx);
116
+ const inlineValue = eqIdx === -1 ? undefined : arg.slice(eqIdx + 1);
117
+
118
+ if (key === 'flag') {
119
+ const value = inlineValue !== undefined ? inlineValue : head[++i];
120
+ if (value !== undefined) repeatedFlags.push(value);
121
+ continue;
122
+ }
123
+
124
+ if (BOOLEAN_FLAGS.has(key)) {
125
+ flags[key] =
126
+ inlineValue === undefined ? true : /^(1|true|yes|)$/i.test(inlineValue);
127
+ continue;
128
+ }
129
+
130
+ if (inlineValue !== undefined) {
131
+ flags[key] = inlineValue;
132
+ continue;
133
+ }
134
+
135
+ // Value-taking flag (known or unknown): consume the next token unless it
136
+ // looks like another flag.
137
+ if (i + 1 < head.length && !head[i + 1].startsWith('--')) {
138
+ flags[key] = head[++i];
139
+ } else {
140
+ flags[key] = true;
141
+ }
142
+ }
143
+
144
+ return {
145
+ objective: positionals.join(' ').trim(),
146
+ flags,
147
+ repeatedFlags,
148
+ passthrough
149
+ };
150
+ }
151
+
152
+ function resolveForHuman(flags) {
153
+ const raw = flags['for-human'];
154
+ if (raw === undefined) return false;
155
+ if (raw === true) return true;
156
+ const normalized = String(raw).trim().toLowerCase();
157
+ return normalized === '' || normalized === 'true' || normalized === '1' || normalized === 'yes';
158
+ }
159
+
160
+ async function readStdin() {
161
+ const chunks = [];
162
+ for await (const chunk of process.stdin) {
163
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
164
+ }
165
+ return Buffer.concat(chunks).toString('utf8');
166
+ }
167
+
168
+ async function fetchUserAgents({ platformUrl, bearerToken, localSecret, organizationId }) {
169
+ const res = await fetch(`${platformUrl}/api/protocol/agents`, {
170
+ headers: buildAuthHeaders(bearerToken, localSecret, organizationId)
171
+ });
172
+ const data = await res.json().catch(() => ({}));
173
+ if (!res.ok) {
174
+ throw new Error(
175
+ `Failed to list agents (${res.status}): ${data.error ?? JSON.stringify(data)}`
176
+ );
177
+ }
178
+ return {
179
+ builtins: Array.isArray(data.builtins) ? data.builtins : [],
180
+ customAgents: Array.isArray(data.customAgents) ? data.customAgents : []
181
+ };
182
+ }
183
+
184
+ async function createPromptTicket(
185
+ { platformUrl, bearerToken, localSecret, organizationId },
186
+ body
187
+ ) {
188
+ const res = await fetch(`${platformUrl}/api/protocol/prompt`, {
189
+ method: 'POST',
190
+ headers: {
191
+ ...buildAuthHeaders(bearerToken, localSecret, organizationId),
192
+ 'Content-Type': 'application/json'
193
+ },
194
+ body: JSON.stringify(body)
195
+ });
196
+ const data = await res.json().catch(() => ({}));
197
+ if (!res.ok) {
198
+ const detail = data.error ?? JSON.stringify(data);
199
+ throw new Error(`Failed to create ticket (${res.status}): ${detail}`);
200
+ }
201
+ return data;
202
+ }
203
+
204
+ function printDirectLaunchHelp(agent = '<agent>') {
205
+ console.log(`Usage:
206
+ ovld ${agent} "<prompt>" [overlord flags] [-- <agent passthrough flags>]
207
+
208
+ Creates a ticket from the prompt (project inferred from the current directory),
209
+ then launches the agent on it and runs the normal ticket lifecycle.
210
+
211
+ Overlord flags:
212
+ --model <identifier> Preferred model (sets the ticket delegate and is forwarded to the agent)
213
+ --thinking <level> Agent reasoning/effort level
214
+ --flag <value> Extra agent flag (repeatable)
215
+ --pre-command <command> Command to run before the agent binary (e.g. ollama)
216
+ --project-id <uuid> Target project (skips working-directory resolution)
217
+ --personal Create the ticket in your personal project
218
+ --for-human Create a human-review ticket
219
+ --priority <low|medium|high|urgent>
220
+ --title "<title>" Override the auto-generated title
221
+ --working-directory <path> Directory used for project resolution and launch
222
+ --allow-uninstalled Launch a built-in agent even if its connector isn't installed
223
+ --launch-mode <run|ask> Ask mode adjusts the fetched prompt context
224
+ --ssh-command / --remote-working-directory / --server-multiplexer / --tmux-command
225
+
226
+ Agent passthrough:
227
+ Anything after a standalone \`--\` is forwarded verbatim to the agent binary.
228
+
229
+ Examples:
230
+ ovld claude "refactor the auth middleware" --model opus --thinking high
231
+ ovld codex "investigate the memory leak" -- --search --full-auto
232
+ ovld cursor "tidy the dashboard styles" --project-id <uuid> --for-human
233
+ echo "summarize today's diffs" | ovld claude`);
234
+ }
235
+
236
+ /**
237
+ * Entry point for `ovld <agent> …`. `agent` is argv[0]; `rawArgs` is the rest.
238
+ */
239
+ export async function runDirectLaunch(agent, rawArgs = []) {
240
+ if (rawArgs[0] === '--help' || rawArgs[0] === '-h' || rawArgs[0] === 'help') {
241
+ printDirectLaunchHelp(agent);
242
+ return;
243
+ }
244
+
245
+ const normalizedAgent = String(agent ?? '').trim().toLowerCase();
246
+ const { objective: parsedObjective, flags, repeatedFlags, passthrough } =
247
+ parseDirectLaunchArgs(rawArgs);
248
+
249
+ let objective = parsedObjective;
250
+ if (!objective && !process.stdin.isTTY) {
251
+ objective = (await readStdin()).trim();
252
+ }
253
+
254
+ const builtin = isBuiltinLaunchAgent(normalizedAgent);
255
+ const auth = await resolveAuth();
256
+
257
+ // Classify + gate the agent before we create a ticket.
258
+ let customAgent = null;
259
+ if (builtin) {
260
+ if (!flags['allow-uninstalled'] && !isAgentConnectorInstalled(normalizedAgent)) {
261
+ console.error(
262
+ `The ${normalizedAgent} connector is not installed. Run \`ovld setup ${normalizedAgent}\` first, ` +
263
+ `or pass --allow-uninstalled to launch anyway.`
264
+ );
265
+ process.exit(1);
266
+ }
267
+ } else {
268
+ const { customAgents } = await fetchUserAgents(auth);
269
+ customAgent =
270
+ customAgents.find(candidate => candidate.id === agent) ??
271
+ customAgents.find(candidate => candidate.id === normalizedAgent) ??
272
+ customAgents.find(candidate => candidate.name === agent) ??
273
+ null;
274
+ if (!customAgent) {
275
+ console.error(`Unknown agent: "${agent}".`);
276
+ console.error(`Built-in agents: ${BUILTIN_LAUNCH_AGENTS.join(', ')}`);
277
+ console.error(
278
+ customAgents.length
279
+ ? `Your custom agents: ${customAgents.map(a => a.id).join(', ')}`
280
+ : 'You have no custom agents. Create one in Overlord settings to launch it by id.'
281
+ );
282
+ process.exit(1);
283
+ }
284
+ }
285
+
286
+ if (!objective) {
287
+ console.error('An objective is required. Provide it as a quoted argument or pipe it via stdin.');
288
+ printDirectLaunchHelp(agent);
289
+ process.exit(1);
290
+ }
291
+
292
+ const personal = Boolean(flags.personal);
293
+ const explicitProject =
294
+ typeof flags['project-id'] === 'string' && flags['project-id'].trim().length > 0;
295
+ const explicitWorkingDirectory =
296
+ typeof flags['working-directory'] === 'string' && flags['working-directory'].trim().length > 0
297
+ ? flags['working-directory'].trim()
298
+ : '';
299
+ // Send cwd so the server can resolve the project, unless the caller pinned a
300
+ // project or asked for a personal ticket.
301
+ const workingDirectory =
302
+ explicitWorkingDirectory || (!explicitProject && !personal ? process.cwd() : '');
303
+
304
+ const model = typeof flags.model === 'string' ? flags.model.trim() : '';
305
+ const thinking = typeof flags.thinking === 'string' ? flags.thinking.trim() : '';
306
+ const agentIdentifier = builtin ? agentIdentifierMap[normalizedAgent] : 'custom';
307
+ const delegate =
308
+ (typeof flags.delegate === 'string' && flags.delegate.trim()) || model || agentIdentifier;
309
+
310
+ const body = {
311
+ objectives: [{ objective }],
312
+ agentIdentifier,
313
+ connectionMethod: 'cli',
314
+ metadata: { launchedVia: 'ovld-direct', ...(model ? { model } : {}) },
315
+ delegate,
316
+ ...(flags.title ? { title: String(flags.title) } : {}),
317
+ ...(flags.priority ? { priority: String(flags.priority) } : {}),
318
+ ...(explicitProject ? { projectId: String(flags['project-id']).trim() } : {}),
319
+ ...(personal ? { personal: true } : {}),
320
+ ...(workingDirectory ? { workingDirectory } : {}),
321
+ ...(flags['acceptance-criteria']
322
+ ? { acceptanceCriteria: String(flags['acceptance-criteria']) }
323
+ : {}),
324
+ ...(flags['available-tools'] ? { availableTools: String(flags['available-tools']) } : {}),
325
+ ...(resolveForHuman(flags) ? { forHuman: true } : {})
326
+ };
327
+
328
+ const result = await createPromptTicket(auth, body);
329
+ const ticketId = result.ticket?.ticket_id ?? result.ticket?.id;
330
+ if (!ticketId) {
331
+ console.error('Ticket creation did not return a ticket id.');
332
+ process.exit(1);
333
+ }
334
+
335
+ console.log(`Created ticket ${ticketId} — launching ${agent}…`);
336
+ process.env.TICKET_ID = String(ticketId);
337
+
338
+ const baseLaunchArgs = ['--ticket-id', String(ticketId)];
339
+ if (workingDirectory) baseLaunchArgs.push('--working-directory', workingDirectory);
340
+ for (const name of FORWARDED_VALUE_FLAGS) {
341
+ const value = flags[name];
342
+ if (typeof value === 'string' && value.trim()) {
343
+ baseLaunchArgs.push(`--${name}`, value.trim());
344
+ }
345
+ }
346
+
347
+ if (builtin) {
348
+ const launchArgs = [normalizedAgent, ...baseLaunchArgs];
349
+ if (model) launchArgs.push('--model', model);
350
+ if (thinking) launchArgs.push('--thinking', thinking);
351
+ for (const flag of repeatedFlags) launchArgs.push('--flag', flag);
352
+ // Forward `--` passthrough as individual agent flags (same channel as --flag).
353
+ for (const token of passthrough) launchArgs.push('--flag', token);
354
+ await runLauncherCommand('run', launchArgs);
355
+ return;
356
+ }
357
+
358
+ // Custom agent: resolve the template locally, then append any --flag/passthrough
359
+ // tokens to the resolved command (launch-custom runs `<command> <context>`).
360
+ const values = buildCustomAgentValues(customAgent, model, thinking);
361
+ const extraTokens = [...repeatedFlags, ...passthrough].filter(Boolean);
362
+ const resolvedCommand = [resolveTemplate(customAgent.commandTemplate, values), ...extraTokens]
363
+ .join(' ')
364
+ .trim();
365
+ await runLauncherCommand('launch-custom', ['--command', resolvedCommand, ...baseLaunchArgs]);
366
+ }
@@ -29,6 +29,7 @@ function printHelp(primaryCommand) {
29
29
  Primary command: ${primaryCommand}
30
30
 
31
31
  Usage:
32
+ ${primaryCommand} <agent> "<prompt>" [flags] Create a ticket from the prompt and launch the agent on it
32
33
  ${primaryCommand} attach [ticketId] [agent] Search tickets and launch an agent (interactive)
33
34
  ${primaryCommand} create "<objective>" Create a ticket with numbered project selection; supports --agent/--model/--delegate
34
35
  ${primaryCommand} prompt "<objective>" Create a ticket, then launch an agent on it
@@ -48,6 +49,10 @@ Usage:
48
49
  ${primaryCommand} help Show this help message
49
50
 
50
51
  Agents:
52
+ ${primaryCommand} <agent> "<prompt>" [--model <id>] [--thinking <level>] [-- <agent flags>]
53
+ Built-in agents (claude, codex, cursor, antigravity, opencode) must have an
54
+ installed connector (${primaryCommand} setup <agent>); your custom agents launch by id.
55
+ Anything after a standalone -- is forwarded verbatim to the agent binary.
51
56
  Use ${primaryCommand} protocol help for ticket lifecycle commands.
52
57
  Key protocol commands: auth-status, discover-project, create, prompt, attach, connect, load-context.
53
58
 
@@ -172,12 +177,23 @@ export async function runCli({ primaryCommand }) {
172
177
  command === 'connect' ||
173
178
  command === 'restart' ||
174
179
  command === 'run' ||
175
- command === 'resume'
180
+ command === 'resume' ||
181
+ command === 'launch-custom'
176
182
  ) {
177
183
  await runLauncherCommand(command, rest);
178
184
  return;
179
185
  }
180
186
 
187
+ // Direct agent launch: `ovld <agent> "<prompt>" [flags]`. Built-in agents
188
+ // dispatch immediately; an unknown command with a candidate objective may be
189
+ // a user's custom agent, so we let direct-launch resolve and report it.
190
+ const { isBuiltinLaunchAgent, runDirectLaunch } = await import('./direct-launch.mjs');
191
+ const hasCandidateObjective = rest.some(arg => !arg.startsWith('--'));
192
+ if (isBuiltinLaunchAgent(command) || hasCandidateObjective) {
193
+ await runDirectLaunch(command, rest);
194
+ return;
195
+ }
196
+
181
197
  console.error(`Unknown command: ${command}\n`);
182
198
  printHelp(primaryCommand);
183
199
  process.exit(1);
@@ -17,6 +17,29 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
17
  const PACKAGE_CLAUDE_PLUGIN_DIR = path.resolve(__dirname, '..', '..', 'plugins', 'claude');
18
18
  const REPO_CLAUDE_PLUGIN_DIR = path.resolve(__dirname, '..', '..', '..', '..', 'plugins', 'claude');
19
19
 
20
+ function resolveLaunchScratchDir(workingDirectory, { explicit = false } = {}) {
21
+ const trimmed = typeof workingDirectory === 'string' ? workingDirectory.trim() : '';
22
+ const candidate = trimmed || process.cwd();
23
+ const useProjectScratch =
24
+ explicit || fs.existsSync(path.join(candidate, '.overlord', 'project.json'));
25
+ if (!useProjectScratch) {
26
+ return os.tmpdir();
27
+ }
28
+ const scratchDir = path.join(candidate, '.overlord', 'tmp');
29
+ fs.mkdirSync(scratchDir, { recursive: true });
30
+ return scratchDir;
31
+ }
32
+
33
+ function withLaunchTempEnv(baseEnv, scratchDir) {
34
+ return {
35
+ ...baseEnv,
36
+ TMPDIR: scratchDir,
37
+ TMP: scratchDir,
38
+ TEMP: scratchDir,
39
+ OVERLORD_TMPDIR: scratchDir
40
+ };
41
+ }
42
+
20
43
  function claudeSourcePluginDir() {
21
44
  if (fs.existsSync(PACKAGE_CLAUDE_PLUGIN_DIR)) return PACKAGE_CLAUDE_PLUGIN_DIR;
22
45
  if (fs.existsSync(REPO_CLAUDE_PLUGIN_DIR)) return REPO_CLAUDE_PLUGIN_DIR;
@@ -53,8 +76,8 @@ function getInstructionMode(agent) {
53
76
  return 'legacy';
54
77
  }
55
78
 
56
- function buildAgyLaunchArgv({ contextFile, mode, sessionId, extraArgs }) {
57
- const argv = [...extraArgs, '--add-dir', os.tmpdir()];
79
+ function buildAgyLaunchArgv({ contextFile, scratchDir, mode, sessionId, extraArgs }) {
80
+ const argv = [...extraArgs, '--add-dir', scratchDir];
58
81
  if (mode === 'resume') {
59
82
  const agySessionId = sessionId?.trim();
60
83
  if (agySessionId) {
@@ -98,10 +121,29 @@ const agentIdentifierMap = {
98
121
 
99
122
  const supportedAgents = ['claude', 'codex', 'cursor', 'antigravity', 'opencode', 'pi'];
100
123
 
124
+ // Re-exported under clearer names for the direct-launch dispatcher in index.mjs.
125
+ export { supportedAgents as BUILTIN_LAUNCH_AGENTS, agentIdentifierMap };
126
+
101
127
  function shellQuote(value) {
102
128
  return `'${String(value).replace(/'/g, `'\\''`)}'`;
103
129
  }
104
130
 
131
+ /**
132
+ * Run an agent binary, optionally routed through a user-defined pre-command
133
+ * (e.g. `ollama` to run claude-code through Ollama). When a pre-command is set,
134
+ * its first token becomes the executable and the agent binary + args follow it:
135
+ * preCommand="ollama" → ollama claude <args...>
136
+ */
137
+ function execAgentBinary(binary, args, opts, preCommand) {
138
+ const pre = typeof preCommand === 'string' ? preCommand.trim() : '';
139
+ if (pre) {
140
+ const preTokens = pre.split(/\s+/).filter(Boolean);
141
+ execFileSync(preTokens[0], [...preTokens.slice(1), binary, ...args], opts);
142
+ return;
143
+ }
144
+ execFileSync(binary, args, opts);
145
+ }
146
+
105
147
  function toTomlString(value) {
106
148
  return `"${String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
107
149
  }
@@ -178,6 +220,9 @@ function buildRemoteLaunchCommand(agent, options) {
178
220
  if (options.launchMode === 'ask') {
179
221
  nestedParts.push('--launch-mode', 'ask');
180
222
  }
223
+ if (options.preCommand) {
224
+ nestedParts.push('--pre-command', shellQuote(options.preCommand));
225
+ }
181
226
  if (options.model) {
182
227
  nestedParts.push('--model', shellQuote(options.model));
183
228
  }
@@ -234,6 +279,7 @@ Options:
234
279
  --model <identifier> Preferred model identifier
235
280
  --thinking <level> Agent reasoning/effort level
236
281
  --flag <value> Extra agent flag (repeatable)
282
+ --pre-command <command> Command to run before the agent binary (e.g. ollama)
237
283
  --ssh-command <command> Launch remotely over SSH by running ovld on the target host
238
284
  --remote-working-directory <path> Change to this path on the remote host before launch
239
285
  --server-multiplexer <none|tmux> Wrap remote launches in tmux
@@ -307,6 +353,10 @@ async function runAgent(agent, mode = 'run', options = {}) {
307
353
  process.chdir(options.workingDirectory);
308
354
  }
309
355
 
356
+ const scratchDir = resolveLaunchScratchDir(options.workingDirectory, {
357
+ explicit: Boolean(options.workingDirectory)
358
+ });
359
+
310
360
  const launchOrganizationId = resolveLaunchOrganizationId(
311
361
  ticketId,
312
362
  options.organizationId,
@@ -344,13 +394,13 @@ async function runAgent(agent, mode = 'run', options = {}) {
344
394
  agent
345
395
  );
346
396
 
347
- const childEnv = {
397
+ const childEnv = withLaunchTempEnv({
348
398
  ...process.env,
349
399
  AGENT_IDENTIFIER: agentIdentifierMap[agent],
350
400
  ...(resolvedLaunchOrganizationId
351
401
  ? { OVERLORD_ORGANIZATION_ID: String(resolvedLaunchOrganizationId) }
352
402
  : {})
353
- };
403
+ }, scratchDir);
354
404
  const extraArgs = buildExtraArgs(agent, options);
355
405
 
356
406
  try {
@@ -363,7 +413,7 @@ async function runAgent(agent, mode = 'run', options = {}) {
363
413
  : ['--continue', context];
364
414
  args.unshift(...extraArgs);
365
415
  if (pluginDir) args.unshift('--plugin-dir', pluginDir);
366
- execFileSync('claude', args, { stdio: 'inherit', env: childEnv });
416
+ execAgentBinary('claude', args, { stdio: 'inherit', env: childEnv }, options.preCommand);
367
417
  } else {
368
418
  const args = [
369
419
  '--append-system-prompt',
@@ -372,11 +422,7 @@ async function runAgent(agent, mode = 'run', options = {}) {
372
422
  'Begin working on this ticket. Start by calling the attach endpoint, then proceed with the objective described in your system prompt.'
373
423
  ];
374
424
  if (pluginDir) args.unshift('--plugin-dir', pluginDir);
375
- execFileSync(
376
- 'claude',
377
- args,
378
- { stdio: 'inherit', env: childEnv }
379
- );
425
+ execAgentBinary('claude', args, { stdio: 'inherit', env: childEnv }, options.preCommand);
380
426
  }
381
427
  } else if (agent === 'codex') {
382
428
  if (mode === 'resume') {
@@ -385,12 +431,22 @@ async function runAgent(agent, mode = 'run', options = {}) {
385
431
  ? ['resume', codexSessionId, context]
386
432
  : ['resume', '--last', context];
387
433
  args.splice(1, 0, ...extraArgs);
388
- execFileSync('codex', args, { stdio: 'inherit', env: childEnv });
434
+ execAgentBinary('codex', args, { stdio: 'inherit', env: childEnv }, options.preCommand);
389
435
  } else {
390
- execFileSync('codex', [...extraArgs, context], { stdio: 'inherit', env: childEnv });
436
+ execAgentBinary(
437
+ 'codex',
438
+ [...extraArgs, context],
439
+ { stdio: 'inherit', env: childEnv },
440
+ options.preCommand
441
+ );
391
442
  }
392
443
  } else if (agent === 'cursor') {
393
- execFileSync('agent', [...extraArgs, context], { stdio: 'inherit', env: childEnv });
444
+ execAgentBinary(
445
+ 'agent',
446
+ [...extraArgs, context],
447
+ { stdio: 'inherit', env: childEnv },
448
+ options.preCommand
449
+ );
394
450
  } else if (agent === 'opencode') {
395
451
  if (mode === 'resume') {
396
452
  const openCodeSessionId = process.env.OPENCODE_SESSION_ID?.trim();
@@ -398,12 +454,14 @@ async function runAgent(agent, mode = 'run', options = {}) {
398
454
  ? ['--continue', '--session', openCodeSessionId, '--prompt', context]
399
455
  : ['--continue', '--prompt', context];
400
456
  args.unshift(...extraArgs);
401
- execFileSync('opencode', args, { stdio: 'inherit', env: childEnv });
457
+ execAgentBinary('opencode', args, { stdio: 'inherit', env: childEnv }, options.preCommand);
402
458
  } else {
403
- execFileSync('opencode', [...extraArgs, '--prompt', context], {
404
- stdio: 'inherit',
405
- env: childEnv
406
- });
459
+ execAgentBinary(
460
+ 'opencode',
461
+ [...extraArgs, '--prompt', context],
462
+ { stdio: 'inherit', env: childEnv },
463
+ options.preCommand
464
+ );
407
465
  }
408
466
  } else if (agent === 'pi') {
409
467
  if (mode === 'resume') {
@@ -412,27 +470,34 @@ async function runAgent(agent, mode = 'run', options = {}) {
412
470
  ? ['--session', piSessionId, context]
413
471
  : ['--continue', context];
414
472
  args.unshift(...extraArgs);
415
- execFileSync('pi', args, { stdio: 'inherit', env: childEnv });
473
+ execAgentBinary('pi', args, { stdio: 'inherit', env: childEnv }, options.preCommand);
416
474
  } else {
417
- execFileSync('pi', [...extraArgs, context], { stdio: 'inherit', env: childEnv });
475
+ execAgentBinary(
476
+ 'pi',
477
+ [...extraArgs, context],
478
+ { stdio: 'inherit', env: childEnv },
479
+ options.preCommand
480
+ );
418
481
  }
419
482
  } else if (agent === 'antigravity') {
420
483
  const tag = `overlord-${ticketId.slice(-8)}-${Date.now()}`;
421
- const contextFile = path.join(os.tmpdir(), `${tag}-ctx.md`);
484
+ const contextFile = path.join(scratchDir, `${tag}-ctx.md`);
422
485
  fs.writeFileSync(contextFile, context, 'utf-8');
423
486
  setTimeout(() => { try { fs.unlinkSync(contextFile); } catch { /* already gone */ } }, 30 * 60_000).unref();
424
487
 
425
488
  const agySessionId =
426
489
  process.env.AGY_SESSION_ID?.trim() ?? process.env.GEMINI_SESSION_ID?.trim();
427
- execFileSync(
490
+ execAgentBinary(
428
491
  'agy',
429
492
  buildAgyLaunchArgv({
430
493
  contextFile,
494
+ scratchDir,
431
495
  mode,
432
496
  sessionId: agySessionId,
433
497
  extraArgs
434
498
  }),
435
- { stdio: 'inherit', env: childEnv }
499
+ { stdio: 'inherit', env: childEnv },
500
+ options.preCommand
436
501
  );
437
502
  }
438
503
  } catch (error) {
@@ -460,12 +525,89 @@ async function runAgent(agent, mode = 'run', options = {}) {
460
525
  }
461
526
  }
462
527
 
528
+ /**
529
+ * Launch a user-defined custom agent. The resolved launch command (with all
530
+ * `{{token}}` placeholders already substituted) is passed via --command; we
531
+ * fetch the ticket context and run `<command> <context>` in the shell.
532
+ */
533
+ async function runCustomAgent(args) {
534
+ const { flags } = parseLauncherArgs(args);
535
+ const command = typeof flags.command === 'string' ? flags.command.trim() : '';
536
+ const ticketId = typeof flags['ticket-id'] === 'string' ? flags['ticket-id'].trim() : '';
537
+ if (!command) {
538
+ console.error('Missing required option: --command "<resolved launch command>"');
539
+ process.exit(1);
540
+ }
541
+ if (ticketId) {
542
+ process.env.TICKET_ID = ticketId;
543
+ }
544
+ const resolvedTicketId = process.env.TICKET_ID;
545
+ if (!resolvedTicketId) {
546
+ console.error('Missing required environment variable: TICKET_ID');
547
+ process.exit(1);
548
+ }
549
+
550
+ if (typeof flags['working-directory'] === 'string' && flags['working-directory'].trim()) {
551
+ process.chdir(flags['working-directory'].trim());
552
+ }
553
+
554
+ const scratchDir = resolveLaunchScratchDir(flags['working-directory'], {
555
+ explicit: typeof flags['working-directory'] === 'string' && flags['working-directory'].trim().length > 0
556
+ });
557
+
558
+ const launchOrganizationId = resolveLaunchOrganizationId(
559
+ resolvedTicketId,
560
+ flags['organization-id'],
561
+ null
562
+ );
563
+ const { platformUrl, bearerToken, localSecret, organizationId } = await resolveAuth({
564
+ organizationIdHint: launchOrganizationId
565
+ });
566
+ const resolvedLaunchOrganizationId = resolveLaunchOrganizationId(
567
+ resolvedTicketId,
568
+ flags['organization-id'],
569
+ organizationId
570
+ );
571
+ // Custom agents have no Overlord plugin/bundle; use the generic "claude" context.
572
+ const context = await fetchContext(
573
+ platformUrl,
574
+ bearerToken,
575
+ localSecret,
576
+ resolvedLaunchOrganizationId,
577
+ resolvedTicketId,
578
+ 'claude'
579
+ );
580
+
581
+ const childEnv = withLaunchTempEnv({
582
+ ...process.env,
583
+ AGENT_IDENTIFIER: 'custom',
584
+ ...(resolvedLaunchOrganizationId
585
+ ? { OVERLORD_ORGANIZATION_ID: String(resolvedLaunchOrganizationId) }
586
+ : {})
587
+ }, scratchDir);
588
+
589
+ try {
590
+ execFileSync('sh', ['-lc', `${command} ${shellQuote(context)}`], {
591
+ stdio: 'inherit',
592
+ env: childEnv
593
+ });
594
+ } catch (error) {
595
+ console.error(error instanceof Error ? error.message : String(error));
596
+ process.exit(1);
597
+ }
598
+ }
599
+
463
600
  export async function runLauncherCommand(command, args) {
464
601
  if (args[0] === '--help' || args[0] === '-h' || args[0] === 'help') {
465
602
  printLauncherHelp();
466
603
  return;
467
604
  }
468
605
 
606
+ if (command === 'launch-custom') {
607
+ await runCustomAgent(args);
608
+ return;
609
+ }
610
+
469
611
  const normalizedCommand =
470
612
  command === 'launch' || command === 'connect'
471
613
  ? 'run'
@@ -486,6 +628,7 @@ export async function runLauncherCommand(command, args) {
486
628
  launchMode: flags['launch-mode'] === 'ask' ? 'ask' : 'run',
487
629
  model: typeof flags.model === 'string' ? flags.model.trim() : '',
488
630
  thinking: typeof flags.thinking === 'string' ? flags.thinking.trim() : '',
631
+ preCommand: typeof flags['pre-command'] === 'string' ? flags['pre-command'].trim() : '',
489
632
  flags: repeatedFlags,
490
633
  organizationId:
491
634
  typeof flags['organization-id'] === 'string' ? flags['organization-id'].trim() : '',
@@ -485,6 +485,24 @@ function writeManifest(manifest) {
485
485
  writeJsonFile(MANIFEST_FILE, manifest);
486
486
  }
487
487
 
488
+ /**
489
+ * Quietly report whether an agent connector is installed: present in the bundle
490
+ * manifest with all of its recorded files still on disk. Unlike `doctorAgent`,
491
+ * this logs nothing and ignores version/hash staleness — a stale-but-present
492
+ * connector can still launch. Used to gate `ovld <agent>` direct launches.
493
+ */
494
+ export function isAgentConnectorInstalled(agent) {
495
+ const manifest = readManifest();
496
+ const entry = manifest[agent];
497
+ if (!entry || !Array.isArray(entry.files)) return false;
498
+ return entry.files.every(file => fs.existsSync(file));
499
+ }
500
+
501
+ /** The built-in agents whose connectors are currently installed. */
502
+ export function listInstalledConnectors() {
503
+ return supportedAgents.filter(agent => isAgentConnectorInstalled(agent));
504
+ }
505
+
488
506
  function slashCommandFiles(agent) {
489
507
  if (agent === 'claude') {
490
508
  const base = path.join(os.homedir(), '.claude', 'commands');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "overlord-cli",
3
- "version": "0.2605281741.0",
3
+ "version": "0.2605290912.0",
4
4
  "description": "Overlord CLI — launch AI agents on tickets from anywhere",
5
5
  "homepage": "https://www.ovld.ai/docs/",
6
6
  "type": "module",