@voidlabs.gg/voidshift-agents 0.0.0-stage → 0.4.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/dist/index.js ADDED
@@ -0,0 +1,1171 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, statSync, symlinkSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { basename, dirname, join, resolve, sep } from 'node:path';
5
+ import { parseArgs } from 'node:util';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { AGENTS, STEPS, TEMPLATES, isProducer, agentsDir, describeSteps, describeTriggers, describeWorkflow, stepEnabled, visibleAgents, readAgentsConfig, writeAgentsConfig, DEFAULTS } from "./config.js";
8
+ import { controlChecks, controlCommands, controlUsage } from "./control.js";
9
+ import { looksLikeLinearKey, readLinearKey, storeLinearKey } from "./keychain.js";
10
+ import { startRunner, stopRunner } from "./launchd.js";
11
+ import { createLinear } from "./linear.js";
12
+ import { duplicateTriggerWarnings, moveKey, parseTrigger, renameKey, sameTrigger, validateConfig, validateWorkflow, workflowsUsing } from "./manage.js";
13
+ import { parseStep, resolveName, stepNames } from "./names.js";
14
+ import { findRepos } from "./repos.js";
15
+ import { defaultBranch, sh } from "./git.js";
16
+ const packDir = fileURLToPath(new URL('../pack/', import.meta.url));
17
+ const packNames = () => readdirSync(packDir).filter((n) => existsSync(join(packDir, n, 'SKILL.md')));
18
+ const has = (cmd) => spawnSync(cmd, ['--version'], { encoding: 'utf8' }).status === 0;
19
+ const tilde = (p) => p.replace(homedir(), '~');
20
+ const homeLabel = (h) => `${h.kind === 'claude' ? 'Claude Code' : 'Codex'} ${tilde(h.dir)}`;
21
+ const expandHome = (p) => (p.startsWith('~') ? join(homedir(), p.slice(1)) : p);
22
+ const isRepo = (dir) => existsSync(join(dir, '.git'));
23
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
24
+ // Claude Code folders the memory tool saved, so one picked there is found here.
25
+ function memoryHomes(configDir) {
26
+ try {
27
+ const saved = JSON.parse(readFileSync(join(configDir, 'memory', 'config.json'), 'utf8')).homes;
28
+ return (Array.isArray(saved) ? saved : []).filter((h) => h?.kind === 'claude' && typeof h.dir === 'string').map((h) => ({ kind: 'claude', dir: resolve(h.dir) }));
29
+ }
30
+ catch {
31
+ return [];
32
+ }
33
+ }
34
+ // Config folders that could hold the agents: Claude Code reads
35
+ // CLAUDE_CONFIG_DIR or ~/.claude, Codex reads CODEX_HOME or ~/.codex, plus any
36
+ // folder chosen before here or in the memory tool.
37
+ function candidateHomes(configDir, saved) {
38
+ const found = [];
39
+ if (has('claude')) {
40
+ for (const d of [process.env.CLAUDE_CONFIG_DIR, join(homedir(), '.claude')])
41
+ if (d)
42
+ found.push({ kind: 'claude', dir: resolve(d) });
43
+ }
44
+ if (has('codex'))
45
+ found.push({ kind: 'codex', dir: resolve(process.env.CODEX_HOME ?? join(homedir(), '.codex')) });
46
+ // Memory-tool homes only count while the folder exists and Claude Code is still installed.
47
+ const fromMemory = has('claude') ? memoryHomes(configDir).filter((h) => existsSync(h.dir)) : [];
48
+ const all = [...saved, ...found, ...fromMemory];
49
+ return all.filter((h, i) => all.findIndex((o) => o.dir === h.dir) === i);
50
+ }
51
+ const kindLabel = (k) => (k === 'claude' ? 'Claude Code' : 'Codex');
52
+ // First which agent CLIs, then where each one keeps its config. Codex has one
53
+ // home, so it only asks about Claude Code, which people often run from
54
+ // several config folders.
55
+ async function chooseHomes(ui, configDir, saved) {
56
+ const candidates = candidateHomes(configDir, saved);
57
+ const kinds = ['claude', 'codex'].filter((k) => candidates.some((h) => h.kind === k));
58
+ const savedKinds = [...new Set(saved.map((h) => h.kind))];
59
+ const picked = kinds.length === 1
60
+ ? [...kinds]
61
+ : await ui.multiselect('Which do you use?', kinds.map((k) => ({ value: k, label: kindLabel(k) })), savedKinds.length ? savedKinds : ['claude']);
62
+ const homes = [];
63
+ for (const kind of picked) {
64
+ const options = candidates.filter((h) => h.kind === kind);
65
+ if (kind === 'codex') {
66
+ homes.push(...options.slice(0, 1));
67
+ continue;
68
+ }
69
+ const savedDirs = saved.filter((h) => h.kind === kind).map((h) => h.dir);
70
+ const OTHER = '__other__';
71
+ const dirs = await ui.multiselect('Where is your Claude Code config? Pick every folder you use.', [...options.map((h) => ({ value: h.dir, label: tilde(h.dir) })), { value: OTHER, label: 'Another folder...' }], savedDirs.length ? savedDirs : options.slice(0, 1).map((h) => h.dir));
72
+ homes.push(...options.filter((h) => dirs.includes(h.dir)));
73
+ if (dirs.includes(OTHER)) {
74
+ const dir = await ui.text('Path to the Claude Code config folder (the one with settings.json)', {
75
+ placeholder: '~/work/claude',
76
+ validate: (v) => (existsSync(expandHome(v.trim())) ? undefined : 'That folder does not exist'),
77
+ });
78
+ homes.push({ kind: 'claude', dir: resolve(expandHome(dir.trim())) });
79
+ }
80
+ }
81
+ return homes;
82
+ }
83
+ // Checks the key against Linear, and only then stores it.
84
+ async function verifyAndStore(key, info, warn) {
85
+ try {
86
+ const me = await createLinear(key).viewer();
87
+ if (!storeLinearKey(key)) {
88
+ warn('Linear accepted the key but the Keychain did not store it.');
89
+ return null;
90
+ }
91
+ info(`Linear connected as ${me.name}. Key stored in your macOS Keychain.`);
92
+ return me.name;
93
+ }
94
+ catch (err) {
95
+ warn(`Linear did not accept that key: ${err.message}. Nothing was stored.`);
96
+ return null;
97
+ }
98
+ }
99
+ // Asks for the key, checks it against Linear, and only then stores it.
100
+ async function setupKey(ui) {
101
+ ui.info('Agents need a Linear personal API key (Linear > Settings > Security & access > API keys).');
102
+ const key = await ui.secret('Paste your Linear API key', (v) => (looksLikeLinearKey(v.trim()) ? undefined : 'Linear keys start with lin_api_'));
103
+ return verifyAndStore(key.trim(), ui.info, ui.warn);
104
+ }
105
+ async function currentLinearUser() {
106
+ try {
107
+ return readLinearKey() ? (await linear().viewer()).name : null;
108
+ }
109
+ catch {
110
+ return null;
111
+ }
112
+ }
113
+ // Ours: points into this install's pack, or into an agents/pack/vs-* folder
114
+ // (the pack of another install or of a source checkout).
115
+ const isOurLink = (path) => {
116
+ try {
117
+ if (!lstatSync(path).isSymbolicLink())
118
+ return false;
119
+ const target = resolve(dirname(path), readlinkSync(path));
120
+ const pack = resolve(packDir);
121
+ return target === pack || target.startsWith(pack + sep) || /[\\/]agents[\\/]pack[\\/]vs-[a-z-]+[\\/]?$/.test(target);
122
+ }
123
+ catch {
124
+ return false;
125
+ }
126
+ };
127
+ const exists = (path) => {
128
+ try {
129
+ lstatSync(path);
130
+ return true;
131
+ }
132
+ catch {
133
+ return false;
134
+ }
135
+ };
136
+ // Links every agent into each chosen home and removes our links from the rest.
137
+ function syncLinks(chosen, all, enabled, log) {
138
+ for (const home of all) {
139
+ const skills = join(home.dir, 'skills');
140
+ const wanted = chosen.some((h) => h.dir === home.dir);
141
+ let existing = [];
142
+ try {
143
+ existing = readdirSync(skills);
144
+ }
145
+ catch {
146
+ // no skills folder yet
147
+ }
148
+ for (const name of existing) {
149
+ const stale = !packNames().includes(name) || !wanted || !enabled.includes(name);
150
+ if (stale && isOurLink(join(skills, name)))
151
+ rmSync(join(skills, name));
152
+ }
153
+ if (!wanted)
154
+ continue;
155
+ mkdirSync(skills, { recursive: true });
156
+ let linked = 0;
157
+ let skipped = 0;
158
+ for (const name of packNames().filter((n) => enabled.includes(n))) {
159
+ const dest = join(skills, name);
160
+ const want = join(packDir, name);
161
+ if (isOurLink(dest)) {
162
+ // Ours but from somewhere else, or broken: point it at this install.
163
+ if (resolve(dirname(dest), readlinkSync(dest)) !== resolve(want)) {
164
+ rmSync(dest);
165
+ symlinkSync(want, dest);
166
+ }
167
+ linked++;
168
+ }
169
+ else if (exists(dest)) {
170
+ skipped++;
171
+ log(`Skipped ${name} in ${tilde(home.dir)}: something else is already there`);
172
+ }
173
+ else {
174
+ symlinkSync(want, dest);
175
+ linked++;
176
+ }
177
+ }
178
+ if (!linked && skipped)
179
+ log(`Agents NOT linked in ${homeLabel(home)}: every name is taken by something else.`);
180
+ else
181
+ log(`Agents linked in ${homeLabel(home)}: ${linked} linked${skipped ? `, ${skipped} skipped (something else is already there)` : ''}`);
182
+ }
183
+ }
184
+ function linear() {
185
+ const key = readLinearKey();
186
+ if (!key)
187
+ throw new Error('No Linear API key. Run vsf agents key');
188
+ return createLinear(key);
189
+ }
190
+ // One project: its repo and the branch pull requests go into. Fields already
191
+ // in `current` are not asked again, unless editing, where they are the defaults.
192
+ async function promptProject(ui, config, current = {}, edit = false, allowExisting = false) {
193
+ const check = (v) => (isRepo(expandHome(v.trim())) ? undefined : 'Not a git repository');
194
+ let path = current.path;
195
+ if (!path || edit) {
196
+ if (edit)
197
+ path = resolve(expandHome((await ui.path('Repo path', { initial: current.path, validate: check })).trim()));
198
+ else {
199
+ const repos = findRepos([process.cwd()]);
200
+ const TYPE = '__type__';
201
+ const picked = await ui.search('Which repo? Type to search.', [
202
+ ...repos.map((r) => ({ value: r, label: r.split('/').pop() ?? r, hint: tilde(r) })),
203
+ { value: TYPE, label: 'Enter a path...', hint: 'not in the list' },
204
+ ], 'repo name', repos.includes(resolve(process.cwd())) ? resolve(process.cwd()) : undefined);
205
+ path = resolve(expandHome(picked === TYPE ? (await ui.path('Repo path', { initial: homedir() + '/', validate: check })).trim() : picked));
206
+ }
207
+ }
208
+ const name = current.name ?? (await ui.text('Name for this project', { initial: basename(path), validate: (v) => (!v.trim() ? 'Required' : !allowExisting && config.projects[v.trim()] ? 'Pick a new name' : undefined) })).trim();
209
+ let base = current.base;
210
+ if (base === undefined || edit) {
211
+ const remote = sh('git', ['branch', '-r', '--format=%(refname:short)'], path).stdout.split('\n')
212
+ .map((b) => b.trim().replace(/^origin\//, '')).filter((b) => b && b !== 'HEAD' && !b.includes(' ') && b !== 'origin');
213
+ let fallback = '';
214
+ try {
215
+ fallback = defaultBranch(path);
216
+ }
217
+ catch {
218
+ // no origin HEAD; the list still works
219
+ }
220
+ const start = base || fallback;
221
+ base = remote.length
222
+ ? await ui.search('Pull requests go into which branch?', remote.map((b) => ({ value: b, label: b, hint: b === fallback ? 'default' : undefined })), 'branch', start || undefined)
223
+ : start;
224
+ }
225
+ const project = { ...config.projects[name], path };
226
+ if (base)
227
+ project.base = base;
228
+ else
229
+ delete project.base;
230
+ return { name, project };
231
+ }
232
+ const describeProject = (config, name) => {
233
+ const p = config.projects[name];
234
+ const used = workflowsUsing(config, name);
235
+ return `${name} ${tilde(p.path)}, PRs into ${p.base ?? 'the default branch'}, ${used.length ? `used by ${used.join(', ')}` : 'not used by any workflow'}`;
236
+ };
237
+ // Named local repos the workflows run in. Triggers point at them by name.
238
+ async function setupProjects(ui, config, log) {
239
+ const names = Object.keys(config.projects);
240
+ if (names.length)
241
+ log(`Projects: ${names.map((n) => `${n} (${tilde(config.projects[n].path)})`).join(', ')}`);
242
+ else
243
+ log('A project is a repo on this Mac where agents do the work. Add one now; workflows run in it.');
244
+ let more = names.length === 0 || (await ui.confirm('Add a project?', false));
245
+ while (more) {
246
+ const { name, project } = await promptProject(ui, config, {}, false, true);
247
+ config.projects[name] = project;
248
+ log(`Project ${name}: ${tilde(project.path)}${project.base ? `, PRs into ${project.base}` : ''}`);
249
+ more = await ui.confirm('Add another project?', false);
250
+ }
251
+ }
252
+ const CUSTOM = '__custom__';
253
+ // Pick steps one at a time from the enabled agents.
254
+ async function pickSteps(ui, config) {
255
+ const steps = [];
256
+ for (;;) {
257
+ const DONE = '__done__';
258
+ const pick = await ui.select(steps.length ? `So far: ${describeSteps(steps)}. Add a step:` : 'First step:', [
259
+ ...Object.keys(STEPS)
260
+ .filter((id) => stepEnabled(id, config.enabled))
261
+ .map((id) => ({ value: id, label: STEPS[id].label, hint: STEPS[id].hint })),
262
+ ...(steps.length ? [{ value: DONE, label: 'Done' }] : []),
263
+ ]);
264
+ if (pick === DONE)
265
+ return steps;
266
+ steps.push(pick);
267
+ }
268
+ }
269
+ // One workflow: what starts it, its steps, its project and its name. Fields
270
+ // already in `current` are not asked again, unless editing, where each one
271
+ // is offered as the default.
272
+ async function promptWorkflow(ui, config, teams, current = {}, edit = false) {
273
+ const w = current.workflow ?? {};
274
+ const keep = async (what, shown) => !edit || (await ui.confirm(`Keep ${what} (${shown})?`, true));
275
+ const myTeams = teams.filter((t) => t.mine);
276
+ let triggers = w.triggers?.length && (await keep('triggers', describeTriggers(w.triggers))) ? w.triggers : undefined;
277
+ if (!triggers) {
278
+ triggers = [];
279
+ do {
280
+ const team = teams.length
281
+ ? await ui.select(triggers.length ? 'Also start for cards in which Linear team?' : 'Starts when a card in which Linear team...', teams.map((t) => ({ value: t.key, label: `${t.key} ${t.name}`, hint: t.mine ? 'your team' : undefined })), myTeams.length === 1 ? myTeams[0].key : undefined)
282
+ : (await ui.text('Starts when a card in which Linear team key...', { validate: (v) => (v.trim() ? undefined : 'Required') })).trim().toUpperCase();
283
+ const label = (await ui.text('...gets which label?', { initial: 'vs-plan', validate: (v) => (v.trim() ? undefined : 'Required') })).trim();
284
+ triggers.push({ source: 'linear', team, label });
285
+ } while (await ui.confirm('Add another way to start this workflow?', false));
286
+ }
287
+ let steps = w.steps?.length && (await keep('steps', describeSteps(w.steps))) ? w.steps : undefined;
288
+ let template;
289
+ if (!steps) {
290
+ const usable = Object.entries(TEMPLATES).filter(([, t]) => t.steps.every((st) => stepEnabled(st, config.enabled)));
291
+ template = await ui.select('Which steps?', [
292
+ ...usable.map(([n, t]) => ({ value: n, label: n, hint: describeSteps(t.steps) })),
293
+ { value: CUSTOM, label: 'Pick my own steps' },
294
+ ], usable.some(([n]) => n === 'build') ? 'build' : undefined);
295
+ steps = template === CUSTOM ? await pickSteps(ui, config) : TEMPLATES[template].steps;
296
+ }
297
+ let maxRounds = w.maxRounds;
298
+ if (maxRounds === undefined || edit) {
299
+ if (template && template !== CUSTOM && maxRounds === undefined)
300
+ maxRounds = TEMPLATES[template].maxRounds;
301
+ else if (steps.some(isProducer)) {
302
+ maxRounds = Number(await ui.text('If a check fails, how many times can it send the work back before parking?', { initial: String(maxRounds ?? 3), validate: (v) => (/^[1-9]$/.test(v) ? undefined : '1 to 9') }));
303
+ }
304
+ else
305
+ maxRounds = 1;
306
+ }
307
+ const projects = Object.keys(config.projects);
308
+ const project = w.project && (await keep('project', w.project))
309
+ ? w.project
310
+ : projects.length === 1
311
+ ? projects[0]
312
+ : await ui.select('Runs in which project?', projects.map((n) => ({ value: n, label: n, hint: tilde(config.projects[n].path) })), w.project);
313
+ let name = current.name;
314
+ if (name === undefined || edit) {
315
+ const suggested = `${project}-${template && template !== CUSTOM ? template : 'custom'}`;
316
+ const start = name ?? (config.workflows[suggested] ? undefined : suggested);
317
+ name = (await ui.text('Name for this workflow', { initial: start, validate: (v) => (v.trim() && (v.trim() === current.name || !config.workflows[v.trim()]) ? undefined : 'Pick a new name') })).trim();
318
+ }
319
+ return { name, workflow: { triggers, steps, maxRounds, project } };
320
+ }
321
+ // A workflow starts on its triggers, runs its steps in order, in its project.
322
+ async function setupWorkflows(ui, config, teams, log) {
323
+ log([
324
+ 'A workflow starts when one of your Linear cards gets a label, then runs agents',
325
+ 'in order in a project. When a check (Plan review or QA) finds problems, the',
326
+ 'work goes back to the step that made it, Plan or Dev, with the findings, until',
327
+ 'the checks pass or the limit parks the card for you.',
328
+ ].join('\n'));
329
+ for (const [name, workflow] of Object.entries(config.workflows))
330
+ log(`Workflow ${name}: ${describeWorkflow(workflow)}`);
331
+ if (!Object.keys(config.projects).length)
332
+ return;
333
+ let more = Object.keys(config.workflows).length === 0 || (await ui.confirm('Create another workflow?', false));
334
+ while (more) {
335
+ const { name, workflow } = await promptWorkflow(ui, config, teams);
336
+ config.workflows[name] = workflow;
337
+ log(`Workflow ${name}: ${describeWorkflow(workflow)}`);
338
+ more = await ui.confirm('Create another workflow?', false);
339
+ }
340
+ }
341
+ // One entry per command, in the order help lists them.
342
+ const USAGE = [
343
+ ['workflows [--json]', 'List workflows in the order they match'],
344
+ ['workflow add [NAME] [--trigger TEAM:LABEL]... [--steps dev,qa | --template build] [--project P] [--rounds N]', 'Create a workflow; prompts for what is missing'],
345
+ ['workflow show NAME [--json]', 'Show one workflow'],
346
+ ['workflow edit NAME [--trigger T:L]... [--add-trigger T:L] [--remove-trigger T:L] [--steps S | --template T] [--project P] [--rounds N]', 'Change a workflow; no flags asks'],
347
+ ['workflow rename OLD NEW', 'Rename a workflow, keeping its place'],
348
+ ['workflow rm NAME [--yes]', 'Delete a workflow'],
349
+ ['workflow move NAME (--first | --last | --before OTHER | --after OTHER)', 'Change the match order'],
350
+ ['projects [--json]', 'List projects'],
351
+ ['project add [NAME] [--path P] [--base B]', 'Add a project; prompts for what is missing'],
352
+ ['project show NAME [--json]', 'Show one project'],
353
+ ['project edit NAME [--path P] [--base B]', 'Change a project; no flags asks'],
354
+ ['project rename OLD NEW', 'Rename a project and the workflows that use it'],
355
+ ['project rm NAME [--yes]', 'Delete a project nothing uses'],
356
+ ['homes [--json]', 'Show where the agents are linked'],
357
+ ['link DIR [--claude | --codex]', 'Link the agents into a config folder'],
358
+ ['unlink DIR', 'Remove the agents from a config folder'],
359
+ ['key', 'Store or replace the Linear API key (reads stdin without a terminal)'],
360
+ ['help [COMMAND]', 'Show this list, or one command'],
361
+ ];
362
+ const usageOf = (name) => USAGE.filter(([c]) => c === name || c.startsWith(`${name} `))
363
+ .map(([c, d]) => `vsf agents ${c}\n ${d}`)
364
+ .join('\n');
365
+ // Control lines are "signature description"; both lists print the same way.
366
+ const fullUsage = () => {
367
+ const control = Object.values(controlUsage).map((l) => l.split(/\s{3,}/));
368
+ return ['vsf agents <command>', '', ...[...control, ...USAGE].map(([c, d]) => ` ${c}\n ${d}`)].join('\n');
369
+ };
370
+ // Parses flags; a bad one prints one plain line plus the usage.
371
+ function parse(args, options, usage, ctx) {
372
+ try {
373
+ const r = parseArgs({ args, options: { help: { type: 'boolean', short: 'h' }, ...options }, allowPositionals: true, strict: true });
374
+ return { values: r.values, positionals: r.positionals };
375
+ }
376
+ catch (err) {
377
+ ctx.log(err.message.split(/\.\s/)[0].replace(/\.$/, '') + '.');
378
+ ctx.log(usage);
379
+ return null;
380
+ }
381
+ }
382
+ const out = (ctx, value) => ctx.log(JSON.stringify(value, null, 2));
383
+ const interactive = () => !!process.stdin.isTTY;
384
+ // Sets up a command: handles --help and bad flags. Returns the parsed args,
385
+ // or the exit code to return.
386
+ function begin(key, args, options, ctx) {
387
+ const usage = usageOf(key);
388
+ const p = parse(args, options, usage, ctx);
389
+ if (!p)
390
+ return 2;
391
+ if (p.values.help) {
392
+ ctx.log(usage);
393
+ return 0;
394
+ }
395
+ return p;
396
+ }
397
+ const needArgs = (p, n, key, ctx) => {
398
+ if (p.positionals.length === n)
399
+ return true;
400
+ ctx.log(`Expected ${plural(n, 'name')}.`);
401
+ ctx.log(usageOf(key));
402
+ return false;
403
+ };
404
+ // Runs a subcommand group: workflow add, project rm, ...
405
+ async function group(name, subs, args, ctx) {
406
+ const [sub, ...rest] = args;
407
+ if (sub === '--help' || sub === '-h') {
408
+ ctx.log(usageOf(name));
409
+ return 0;
410
+ }
411
+ if (!sub || !Object.hasOwn(subs, sub)) {
412
+ ctx.log(sub ? `Unknown ${name} command ${sub}.` : `Say what to do with a ${name}.`);
413
+ ctx.log(usageOf(name));
414
+ return 2;
415
+ }
416
+ return subs[sub](rest, ctx);
417
+ }
418
+ const save = (ctx, config) => writeAgentsConfig(ctx.configDir, config);
419
+ // Refuses a rm without --yes when there is nobody to ask.
420
+ async function confirmed(question, yes, ctx) {
421
+ if (yes)
422
+ return true;
423
+ if (!interactive()) {
424
+ ctx.log('Not asking without a terminal. Add --yes to go ahead.');
425
+ return 'refused';
426
+ }
427
+ return ctx.ui.confirm(question, false);
428
+ }
429
+ // ---- workflows ----
430
+ const roundsText = (w) => plural(w.maxRounds, 'round');
431
+ function listWorkflows(config, ctx) {
432
+ Object.entries(config.workflows).forEach(([n, w], i) => ctx.log(`${i + 1}. ${n} ${describeWorkflow(w)}, ${roundsText(w)}`));
433
+ ctx.log('The first workflow that matches a card wins. vsf agents workflow move changes the order.');
434
+ }
435
+ // The workflow flags shared by add and edit. A string is a complaint to print
436
+ // (code 2 for bad input, 1 for a name that does not resolve).
437
+ function readWorkflowFlags(v, config) {
438
+ const flags = {};
439
+ if (v.trigger?.length) {
440
+ flags.triggers = [];
441
+ for (const t of v.trigger) {
442
+ const parsed = parseTrigger(t);
443
+ if (!parsed)
444
+ return { error: `Bad trigger ${t}. Use TEAM:LABEL, like ENG:vs-plan.`, code: 2 };
445
+ flags.triggers.push(parsed);
446
+ }
447
+ }
448
+ if (v.steps !== undefined && v.template !== undefined)
449
+ return { error: 'Use either --steps or --template, not both.', code: 2 };
450
+ if (v.steps !== undefined) {
451
+ flags.steps = [];
452
+ for (const s of String(v.steps).split(',').map((x) => x.trim()).filter(Boolean)) {
453
+ const id = parseStep(s);
454
+ if (!id)
455
+ return { error: `Unknown step ${s}. Steps: ${stepNames()}.`, code: 2 };
456
+ flags.steps.push(id);
457
+ }
458
+ if (!flags.steps.length)
459
+ return { error: `--steps needs at least one step. Steps: ${stepNames()}.`, code: 2 };
460
+ }
461
+ if (v.template !== undefined) {
462
+ const t = TEMPLATES[v.template];
463
+ if (!t)
464
+ return { error: `Unknown template ${v.template}. Templates: ${Object.keys(TEMPLATES).join(', ')}.`, code: 2 };
465
+ flags.steps = [...t.steps];
466
+ flags.maxRounds = t.maxRounds;
467
+ flags.fromTemplate = true;
468
+ }
469
+ if (v.rounds !== undefined) {
470
+ if (!/^[1-9]$/.test(v.rounds))
471
+ return { error: 'Rounds must be a whole number from 1 to 9.', code: 2 };
472
+ flags.maxRounds = Number(v.rounds);
473
+ }
474
+ if (v.project !== undefined) {
475
+ const r = resolveName(Object.keys(config.projects), v.project, 'project');
476
+ if ('error' in r)
477
+ return { error: r.error, code: 1 };
478
+ flags.project = r.name;
479
+ }
480
+ return flags;
481
+ }
482
+ function pickWorkflow(config, input, ctx) {
483
+ const r = resolveName(Object.keys(config.workflows), input, 'workflow');
484
+ if ('error' in r) {
485
+ ctx.log(r.error);
486
+ return null;
487
+ }
488
+ return r.name;
489
+ }
490
+ async function teamsOrNull(ctx) {
491
+ try {
492
+ return await linear().teams();
493
+ }
494
+ catch (err) {
495
+ ctx.log(`Could not list Linear teams: ${err.message}`);
496
+ return null;
497
+ }
498
+ }
499
+ // Validates, then saves. Prints one line and returns 1 when it is not valid.
500
+ function saveWorkflow(config, name, workflow, ctx, verb = '') {
501
+ const problems = validateWorkflow(config, workflow);
502
+ if (problems.length) {
503
+ ctx.log(`Workflow ${name} is not valid: ${problems.join(' ')}`);
504
+ return 1;
505
+ }
506
+ save(ctx, config);
507
+ ctx.log(`Workflow ${verb}${name}: ${describeWorkflow(workflow)}`);
508
+ for (const line of duplicateTriggerWarnings(config.workflows, name, workflow))
509
+ ctx.log(line);
510
+ return 0;
511
+ }
512
+ const workflowSubs = {
513
+ async add(args, ctx) {
514
+ const options = { trigger: { type: 'string', multiple: true }, steps: { type: 'string' }, template: { type: 'string' }, project: { type: 'string' }, rounds: { type: 'string' } };
515
+ const p = begin('workflow add', args, options, ctx);
516
+ if (typeof p === 'number')
517
+ return p;
518
+ const config = readAgentsConfig(ctx.configDir);
519
+ if (!Object.keys(config.projects).length) {
520
+ ctx.log('No projects yet. vsf agents project add creates one.');
521
+ return 1;
522
+ }
523
+ const flags = readWorkflowFlags(p.values, config);
524
+ if ('error' in flags) {
525
+ ctx.log(flags.error);
526
+ return flags.code;
527
+ }
528
+ if (p.positionals.length > 1) {
529
+ ctx.log('Give at most one name.');
530
+ ctx.log(usageOf('workflow add'));
531
+ return 2;
532
+ }
533
+ const projects = Object.keys(config.projects);
534
+ flags.project ??= projects.length === 1 ? projects[0] : undefined;
535
+ // Steps from flags carry their usual rounds unless --rounds said otherwise.
536
+ if (flags.steps && flags.maxRounds === undefined)
537
+ flags.maxRounds = flags.steps.some(isProducer) ? 3 : 1;
538
+ let name = p.positionals[0]?.trim();
539
+ if (name !== undefined && !name) {
540
+ ctx.log('The name cannot be empty.');
541
+ return 2;
542
+ }
543
+ if (name && config.workflows[name]) {
544
+ ctx.log(`A workflow named ${name} already exists.`);
545
+ return 1;
546
+ }
547
+ const missing = [!flags.triggers && '--trigger', !flags.steps && '--steps (or --template)', !flags.project && '--project'].filter(Boolean);
548
+ let result;
549
+ if (missing.length) {
550
+ if (!interactive()) {
551
+ ctx.log(`Missing ${missing.join(', ')}.`);
552
+ ctx.log(usageOf('workflow add'));
553
+ return 2;
554
+ }
555
+ const teams = await teamsOrNull(ctx);
556
+ if (!teams)
557
+ return 1;
558
+ result = await promptWorkflow(ctx.ui, config, teams, { name, workflow: flags });
559
+ }
560
+ else {
561
+ const w = { triggers: flags.triggers, steps: flags.steps, maxRounds: flags.maxRounds, project: flags.project };
562
+ name ??= `${w.project}-${p.values.template ?? 'custom'}`;
563
+ if (config.workflows[name]) {
564
+ ctx.log(`Missing NAME. ${name} is already taken.`);
565
+ ctx.log(usageOf('workflow add'));
566
+ return 2;
567
+ }
568
+ result = { name, workflow: w };
569
+ }
570
+ if (config.workflows[result.name]) {
571
+ ctx.log(`A workflow named ${result.name} already exists.`);
572
+ return 1;
573
+ }
574
+ config.workflows[result.name] = result.workflow;
575
+ return saveWorkflow(config, result.name, result.workflow, ctx);
576
+ },
577
+ async show(args, ctx) {
578
+ const p = begin('workflow show', args, { json: { type: 'boolean' } }, ctx);
579
+ if (typeof p === 'number')
580
+ return p;
581
+ if (!needArgs(p, 1, 'workflow show', ctx))
582
+ return 2;
583
+ const config = readAgentsConfig(ctx.configDir);
584
+ const name = pickWorkflow(config, p.positionals[0], ctx);
585
+ if (!name)
586
+ return 1;
587
+ const w = config.workflows[name];
588
+ if (p.values.json)
589
+ out(ctx, { name, ...w });
590
+ else {
591
+ ctx.log(`Workflow ${name}: ${describeWorkflow(w)}`);
592
+ for (const t of w.triggers)
593
+ ctx.log(`Trigger: ${t.team} + ${t.label}`);
594
+ ctx.log(`Steps: ${describeSteps(w.steps)}`);
595
+ ctx.log(`Project: ${w.project}`);
596
+ ctx.log(`Rounds: ${w.maxRounds}`);
597
+ }
598
+ return 0;
599
+ },
600
+ async edit(args, ctx) {
601
+ const options = {
602
+ trigger: { type: 'string', multiple: true }, 'add-trigger': { type: 'string', multiple: true }, 'remove-trigger': { type: 'string', multiple: true },
603
+ steps: { type: 'string' }, template: { type: 'string' }, project: { type: 'string' }, rounds: { type: 'string' },
604
+ };
605
+ const p = begin('workflow edit', args, options, ctx);
606
+ if (typeof p === 'number')
607
+ return p;
608
+ if (!needArgs(p, 1, 'workflow edit', ctx))
609
+ return 2;
610
+ const config = readAgentsConfig(ctx.configDir);
611
+ const name = pickWorkflow(config, p.positionals[0], ctx);
612
+ if (!name)
613
+ return 1;
614
+ const flags = readWorkflowFlags(p.values, config);
615
+ if ('error' in flags) {
616
+ ctx.log(flags.error);
617
+ return flags.code;
618
+ }
619
+ const v = p.values;
620
+ const current = config.workflows[name];
621
+ if (!Object.keys(flags).length && !v['add-trigger']?.length && !v['remove-trigger']?.length) {
622
+ if (!interactive()) {
623
+ ctx.log('Nothing to change. Give a flag, or run this in a terminal to be asked.');
624
+ ctx.log(usageOf('workflow edit'));
625
+ return 2;
626
+ }
627
+ const teams = await teamsOrNull(ctx);
628
+ if (!teams)
629
+ return 1;
630
+ const r = await promptWorkflow(ctx.ui, config, teams, { name, workflow: current }, true);
631
+ if (r.name !== name)
632
+ config.workflows = renameKey(config.workflows, name, r.name);
633
+ config.workflows[r.name] = r.workflow;
634
+ return saveWorkflow(config, r.name, r.workflow, ctx);
635
+ }
636
+ const next = { ...current, triggers: [...(flags.triggers ?? current.triggers)] };
637
+ for (const t of (v['add-trigger'] ?? [])) {
638
+ const parsed = parseTrigger(t);
639
+ if (!parsed) {
640
+ ctx.log(`Bad trigger ${t}. Use TEAM:LABEL, like ENG:vs-plan.`);
641
+ return 2;
642
+ }
643
+ if (!next.triggers.some((x) => sameTrigger(x, parsed)))
644
+ next.triggers.push(parsed);
645
+ }
646
+ for (const t of (v['remove-trigger'] ?? [])) {
647
+ const parsed = parseTrigger(t);
648
+ const at = parsed ? next.triggers.findIndex((x) => sameTrigger(x, parsed)) : -1;
649
+ if (at < 0) {
650
+ ctx.log(`${name} has no trigger ${t}.`);
651
+ return 1;
652
+ }
653
+ next.triggers.splice(at, 1);
654
+ }
655
+ if (flags.steps)
656
+ next.steps = flags.steps;
657
+ if (flags.maxRounds !== undefined)
658
+ next.maxRounds = flags.maxRounds;
659
+ if (flags.project)
660
+ next.project = flags.project;
661
+ config.workflows[name] = next;
662
+ return saveWorkflow(config, name, next, ctx);
663
+ },
664
+ async rename(args, ctx) {
665
+ const p = begin('workflow rename', args, {}, ctx);
666
+ if (typeof p === 'number')
667
+ return p;
668
+ if (!needArgs(p, 2, 'workflow rename', ctx))
669
+ return 2;
670
+ const config = readAgentsConfig(ctx.configDir);
671
+ const old = pickWorkflow(config, p.positionals[0], ctx);
672
+ if (!old)
673
+ return 1;
674
+ const next = p.positionals[1].trim();
675
+ if (!next) {
676
+ ctx.log('The new name cannot be empty.');
677
+ return 1;
678
+ }
679
+ if (config.workflows[next]) {
680
+ ctx.log(`A workflow named ${next} already exists.`);
681
+ return 1;
682
+ }
683
+ config.workflows = renameKey(config.workflows, old, next);
684
+ save(ctx, config);
685
+ ctx.log(`Workflow ${old} is now ${next}.`);
686
+ return 0;
687
+ },
688
+ async rm(args, ctx) {
689
+ const p = begin('workflow rm', args, { yes: { type: 'boolean', short: 'y' } }, ctx);
690
+ if (typeof p === 'number')
691
+ return p;
692
+ if (!needArgs(p, 1, 'workflow rm', ctx))
693
+ return 2;
694
+ const config = readAgentsConfig(ctx.configDir);
695
+ const name = pickWorkflow(config, p.positionals[0], ctx);
696
+ if (!name)
697
+ return 1;
698
+ const ok = await confirmed(`Delete workflow ${name}?`, !!p.values.yes, ctx);
699
+ if (ok === 'refused')
700
+ return 2;
701
+ if (!ok) {
702
+ ctx.log('Nothing deleted.');
703
+ return 0;
704
+ }
705
+ delete config.workflows[name];
706
+ save(ctx, config);
707
+ ctx.log(`Deleted workflow ${name}.`);
708
+ return 0;
709
+ },
710
+ async move(args, ctx) {
711
+ const p = begin('workflow move', args, { first: { type: 'boolean' }, last: { type: 'boolean' }, before: { type: 'string' }, after: { type: 'string' } }, ctx);
712
+ if (typeof p === 'number')
713
+ return p;
714
+ if (!needArgs(p, 1, 'workflow move', ctx))
715
+ return 2;
716
+ const v = p.values;
717
+ if ([v.first, v.last, v.before, v.after].filter((x) => x !== undefined && x !== false).length !== 1) {
718
+ ctx.log('Give exactly one of --first, --last, --before, --after.');
719
+ ctx.log(usageOf('workflow move'));
720
+ return 2;
721
+ }
722
+ const config = readAgentsConfig(ctx.configDir);
723
+ const name = pickWorkflow(config, p.positionals[0], ctx);
724
+ if (!name)
725
+ return 1;
726
+ let place;
727
+ if (v.first)
728
+ place = { first: true };
729
+ else if (v.last)
730
+ place = { last: true };
731
+ else {
732
+ const other = pickWorkflow(config, v.before ?? v.after, ctx);
733
+ if (!other)
734
+ return 1;
735
+ if (other === name) {
736
+ ctx.log('Pick a different workflow to put it next to.');
737
+ return 2;
738
+ }
739
+ place = v.before !== undefined ? { before: other } : { after: other };
740
+ }
741
+ config.workflows = moveKey(config.workflows, name, place);
742
+ save(ctx, config);
743
+ listWorkflows(config, ctx);
744
+ return 0;
745
+ },
746
+ };
747
+ // ---- projects ----
748
+ function pickProject(config, input, ctx) {
749
+ const r = resolveName(Object.keys(config.projects), input, 'project');
750
+ if ('error' in r) {
751
+ ctx.log(r.error);
752
+ return null;
753
+ }
754
+ return r.name;
755
+ }
756
+ const projectSubs = {
757
+ async add(args, ctx) {
758
+ const p = begin('project add', args, { path: { type: 'string' }, base: { type: 'string' } }, ctx);
759
+ if (typeof p === 'number')
760
+ return p;
761
+ if (p.positionals.length > 1) {
762
+ ctx.log('Give at most one name.');
763
+ ctx.log(usageOf('project add'));
764
+ return 2;
765
+ }
766
+ const config = readAgentsConfig(ctx.configDir);
767
+ let name = p.positionals[0]?.trim();
768
+ if (name && config.projects[name]) {
769
+ ctx.log(`A project named ${name} already exists.`);
770
+ return 1;
771
+ }
772
+ let project;
773
+ if (p.values.path !== undefined) {
774
+ const path = resolve(expandHome(p.values.path));
775
+ if (!isRepo(path)) {
776
+ ctx.log(`${tilde(path)} is not a git repository.`);
777
+ return 1;
778
+ }
779
+ name ||= basename(path);
780
+ if (config.projects[name]) {
781
+ ctx.log(`A project named ${name} already exists. Give another name.`);
782
+ return 1;
783
+ }
784
+ let base = p.values.base;
785
+ if (!base) {
786
+ try {
787
+ base = defaultBranch(path);
788
+ }
789
+ catch {
790
+ // no origin HEAD; the repo's default is used at run time
791
+ }
792
+ }
793
+ project = { path, ...(base ? { base } : {}) };
794
+ }
795
+ else {
796
+ if (!interactive()) {
797
+ ctx.log('Missing --path.');
798
+ ctx.log(usageOf('project add'));
799
+ return 2;
800
+ }
801
+ const r = await promptProject(ctx.ui, config, { name, base: p.values.base });
802
+ name = r.name;
803
+ project = r.project;
804
+ }
805
+ config.projects[name] = project;
806
+ save(ctx, config);
807
+ ctx.log(`Project ${name}: ${tilde(project.path)}${project.base ? `, PRs into ${project.base}` : ''}`);
808
+ return 0;
809
+ },
810
+ async show(args, ctx) {
811
+ const p = begin('project show', args, { json: { type: 'boolean' } }, ctx);
812
+ if (typeof p === 'number')
813
+ return p;
814
+ if (!needArgs(p, 1, 'project show', ctx))
815
+ return 2;
816
+ const config = readAgentsConfig(ctx.configDir);
817
+ const name = pickProject(config, p.positionals[0], ctx);
818
+ if (!name)
819
+ return 1;
820
+ const used = workflowsUsing(config, name);
821
+ if (p.values.json)
822
+ out(ctx, { name, ...config.projects[name], usedBy: used });
823
+ else {
824
+ const proj = config.projects[name];
825
+ ctx.log(`Project ${name}`);
826
+ ctx.log(`Path: ${tilde(proj.path)}`);
827
+ ctx.log(`PRs into: ${proj.base ?? 'the default branch'}`);
828
+ ctx.log(`Used by: ${used.length ? used.join(', ') : 'no workflows'}`);
829
+ }
830
+ return 0;
831
+ },
832
+ async edit(args, ctx) {
833
+ const p = begin('project edit', args, { path: { type: 'string' }, base: { type: 'string' } }, ctx);
834
+ if (typeof p === 'number')
835
+ return p;
836
+ if (!needArgs(p, 1, 'project edit', ctx))
837
+ return 2;
838
+ const config = readAgentsConfig(ctx.configDir);
839
+ const name = pickProject(config, p.positionals[0], ctx);
840
+ if (!name)
841
+ return 1;
842
+ const current = config.projects[name];
843
+ const v = p.values;
844
+ if (v.path === undefined && v.base === undefined) {
845
+ if (!interactive()) {
846
+ ctx.log('Nothing to change. Give --path or --base, or run this in a terminal to be asked.');
847
+ ctx.log(usageOf('project edit'));
848
+ return 2;
849
+ }
850
+ config.projects[name] = (await promptProject(ctx.ui, config, { name, path: current.path, base: current.base }, true)).project;
851
+ }
852
+ else {
853
+ const path = v.path !== undefined ? resolve(expandHome(v.path)) : current.path;
854
+ if (!isRepo(path)) {
855
+ ctx.log(`${tilde(path)} is not a git repository.`);
856
+ return 1;
857
+ }
858
+ const next = { ...current, path };
859
+ // An empty --base clears it, so PRs go to the repo's default branch.
860
+ if (v.base !== undefined) {
861
+ if (v.base)
862
+ next.base = v.base;
863
+ else
864
+ delete next.base;
865
+ }
866
+ config.projects[name] = next;
867
+ }
868
+ save(ctx, config);
869
+ ctx.log(describeProject(config, name));
870
+ return 0;
871
+ },
872
+ async rename(args, ctx) {
873
+ const p = begin('project rename', args, {}, ctx);
874
+ if (typeof p === 'number')
875
+ return p;
876
+ if (!needArgs(p, 2, 'project rename', ctx))
877
+ return 2;
878
+ const config = readAgentsConfig(ctx.configDir);
879
+ const old = pickProject(config, p.positionals[0], ctx);
880
+ if (!old)
881
+ return 1;
882
+ const next = p.positionals[1].trim();
883
+ if (!next) {
884
+ ctx.log('The new name cannot be empty.');
885
+ return 1;
886
+ }
887
+ if (config.projects[next]) {
888
+ ctx.log(`A project named ${next} already exists.`);
889
+ return 1;
890
+ }
891
+ config.projects = renameKey(config.projects, old, next);
892
+ for (const w of Object.values(config.workflows))
893
+ if (w.project === old)
894
+ w.project = next;
895
+ save(ctx, config);
896
+ ctx.log(`Project ${old} is now ${next}.`);
897
+ return 0;
898
+ },
899
+ async rm(args, ctx) {
900
+ const p = begin('project rm', args, { yes: { type: 'boolean', short: 'y' } }, ctx);
901
+ if (typeof p === 'number')
902
+ return p;
903
+ if (!needArgs(p, 1, 'project rm', ctx))
904
+ return 2;
905
+ const config = readAgentsConfig(ctx.configDir);
906
+ const name = pickProject(config, p.positionals[0], ctx);
907
+ if (!name)
908
+ return 1;
909
+ const used = workflowsUsing(config, name);
910
+ if (used.length) {
911
+ ctx.log(`Used by ${used.join(', ')}. Change or delete those workflows first.`);
912
+ return 1;
913
+ }
914
+ const ok = await confirmed(`Delete project ${name}?`, !!p.values.yes, ctx);
915
+ if (ok === 'refused')
916
+ return 2;
917
+ if (!ok) {
918
+ ctx.log('Nothing deleted.');
919
+ return 0;
920
+ }
921
+ delete config.projects[name];
922
+ save(ctx, config);
923
+ ctx.log(`Deleted project ${name}.`);
924
+ return 0;
925
+ },
926
+ };
927
+ // ---- homes ----
928
+ // Per enabled agent: ours, taken by something else, or missing.
929
+ function homeReport(config, h) {
930
+ const wanted = packNames().filter((n) => !config.enabled.length || config.enabled.includes(n));
931
+ const state = (n) => (isOurLink(join(h.dir, 'skills', n)) ? 'linked' : exists(join(h.dir, 'skills', n)) ? 'taken' : 'missing');
932
+ return {
933
+ kind: h.kind,
934
+ dir: h.dir,
935
+ linked: wanted.filter((n) => state(n) === 'linked'),
936
+ taken: wanted.filter((n) => state(n) === 'taken'),
937
+ missing: wanted.filter((n) => state(n) === 'missing'),
938
+ };
939
+ }
940
+ const effectiveEnabled = (config) => (config.enabled.length ? config.enabled : packNames());
941
+ const homeCommands = {
942
+ async homes(args, ctx) {
943
+ const p = begin('homes', args, { json: { type: 'boolean' } }, ctx);
944
+ if (typeof p === 'number')
945
+ return p;
946
+ const config = readAgentsConfig(ctx.configDir);
947
+ const reports = config.homes.map((h) => homeReport(config, h));
948
+ if (p.values.json) {
949
+ out(ctx, reports);
950
+ return 0;
951
+ }
952
+ if (!reports.length)
953
+ ctx.log('No folders yet. vsf agents link DIR adds one.');
954
+ for (const r of reports) {
955
+ const missing = r.missing.length ? `, missing ${r.missing.join(', ')} (vsf agents link ${tilde(r.dir)})` : '';
956
+ const taken = r.taken.length ? `, ${r.taken.join(', ')} taken by something else` : '';
957
+ ctx.log(`${homeLabel(r)}: ${r.linked.length} linked${missing}${taken}`);
958
+ }
959
+ return 0;
960
+ },
961
+ async link(args, ctx) {
962
+ const p = begin('link', args, { claude: { type: 'boolean' }, codex: { type: 'boolean' } }, ctx);
963
+ if (typeof p === 'number')
964
+ return p;
965
+ if (!needArgs(p, 1, 'link', ctx) || (p.values.claude && p.values.codex)) {
966
+ if (p.values.claude && p.values.codex)
967
+ ctx.log('Use either --claude or --codex, not both.');
968
+ return 2;
969
+ }
970
+ const dir = resolve(expandHome(p.positionals[0]));
971
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) {
972
+ ctx.log(`${tilde(dir)} is not a folder that exists.`);
973
+ return 1;
974
+ }
975
+ const kind = p.values.codex ? 'codex' : p.values.claude ? 'claude' : basename(dir).includes('codex') ? 'codex' : 'claude';
976
+ const config = readAgentsConfig(ctx.configDir);
977
+ let home = config.homes.find((h) => h.dir === dir);
978
+ if (!home) {
979
+ home = { kind, dir };
980
+ config.homes.push(home);
981
+ }
982
+ syncLinks([home], [home], effectiveEnabled(config), ctx.log);
983
+ save(ctx, config);
984
+ return 0;
985
+ },
986
+ async unlink(args, ctx) {
987
+ const p = begin('unlink', args, {}, ctx);
988
+ if (typeof p === 'number')
989
+ return p;
990
+ if (!needArgs(p, 1, 'unlink', ctx))
991
+ return 2;
992
+ const dir = resolve(expandHome(p.positionals[0]));
993
+ const config = readAgentsConfig(ctx.configDir);
994
+ const home = config.homes.find((h) => h.dir === dir);
995
+ if (!home && !existsSync(dir)) {
996
+ ctx.log(`${tilde(dir)} is not linked.`);
997
+ return 1;
998
+ }
999
+ syncLinks([], [home ?? { kind: 'claude', dir }], [], ctx.log);
1000
+ config.homes = config.homes.filter((h) => h.dir !== dir);
1001
+ save(ctx, config);
1002
+ ctx.log(`Agents removed from ${tilde(dir)}.`);
1003
+ return 0;
1004
+ },
1005
+ };
1006
+ const sameModels = (a, b) => a.dev === b.dev && a.review === b.review;
1007
+ const agents = {
1008
+ name: 'agents',
1009
+ summary: 'Agents for Claude Code and Codex that plan, build and QA Linear cards',
1010
+ config: {
1011
+ read: (dir) => readAgentsConfig(dir),
1012
+ write: (dir, value) => {
1013
+ const problems = validateConfig(value, readAgentsConfig(dir));
1014
+ if (!problems.length)
1015
+ writeAgentsConfig(dir, value);
1016
+ return problems;
1017
+ },
1018
+ },
1019
+ async install(ctx) {
1020
+ const { ui } = ctx;
1021
+ const config = readAgentsConfig(ctx.configDir);
1022
+ if (!candidateHomes(ctx.configDir, config.homes).length)
1023
+ throw new Error('install Claude Code or Codex first');
1024
+ const homes = await chooseHomes(ui, ctx.configDir, config.homes);
1025
+ if (!homes.length)
1026
+ throw new Error('pick at least one folder to install the agents into');
1027
+ const enabled = await ui.multiselect('Which agents do you want?', visibleAgents().map((a) => ({ value: a, label: AGENTS[a].label, hint: AGENTS[a].hint })), config.enabled.length ? config.enabled.filter((a) => visibleAgents().includes(a)) : visibleAgents());
1028
+ if (!enabled.length)
1029
+ throw new Error('pick at least one agent');
1030
+ syncLinks(homes, candidateHomes(ctx.configDir, [...config.homes, ...homes]), enabled, ctx.log);
1031
+ config.homes = homes;
1032
+ config.enabled = enabled;
1033
+ const engines = [...new Set(homes.map((h) => h.kind))];
1034
+ const engine = engines.length === 1
1035
+ ? engines[0]
1036
+ : await ui.select('Which one should run the background agents?', [
1037
+ { value: 'claude', label: 'Claude Code', hint: 'your Claude subscription' },
1038
+ { value: 'codex', label: 'Codex', hint: 'your ChatGPT subscription' },
1039
+ ], config.engine);
1040
+ // Only swap models that are still the other engine's defaults.
1041
+ if (engine !== config.engine) {
1042
+ if (engine === 'codex' && sameModels(config.models, DEFAULTS.models))
1043
+ config.models = {};
1044
+ else if (engine === 'claude' && !Object.values(config.models).some(Boolean))
1045
+ config.models = { ...DEFAULTS.models };
1046
+ }
1047
+ config.engine = engine;
1048
+ const name = await currentLinearUser();
1049
+ if (!name || !(await ui.confirm(`Keep using Linear as ${name}?`))) {
1050
+ if (!(await setupKey(ui)))
1051
+ throw new Error('Linear is not connected. Run vsf setup agents to retry.');
1052
+ }
1053
+ await setupProjects(ui, config, ctx.log);
1054
+ await setupWorkflows(ui, config, await linear().teams(), ctx.log);
1055
+ writeAgentsConfig(ctx.configDir, config);
1056
+ if (Object.keys(config.workflows).length && (await ui.confirm('Start the background runner now? It checks Linear every minute for cards that start your workflows.'))) {
1057
+ const ok = startRunner(ctx.home, [...ctx.selfCommand, 'agents', 'tick'], join(agentsDir(ctx.configDir), 'runner.log'));
1058
+ ctx.log(ok ? 'Runner started.' : 'Could not start the runner. Try vsf agents start.');
1059
+ }
1060
+ ctx.log('In Claude Code or Codex, connect Linear too, then use /vs-plan, /vs-plan-review and /vs-publish on a card.');
1061
+ },
1062
+ async uninstall(ctx) {
1063
+ stopRunner(ctx.home);
1064
+ syncLinks([], candidateHomes(ctx.configDir, readAgentsConfig(ctx.configDir).homes), [], ctx.log);
1065
+ ctx.log('Agents unlinked and the runner stopped. Config and the Linear key were kept.');
1066
+ },
1067
+ async checks(ctx) {
1068
+ const checks = [];
1069
+ const saved = readAgentsConfig(ctx.configDir);
1070
+ const homes = saved.homes;
1071
+ const wanted = packNames().filter((n) => !saved.enabled.length || saved.enabled.includes(n));
1072
+ if (!homes.length)
1073
+ checks.push({ name: 'agents linked', status: 'warn', detail: 'in no Claude Code or Codex folder', fix: 'vsf setup agents' });
1074
+ for (const h of homes) {
1075
+ const missing = wanted.filter((n) => !isOurLink(join(h.dir, 'skills', n)));
1076
+ checks.push(missing.length === 0
1077
+ ? { name: `agents in ${homeLabel(h)}`, status: 'ok', detail: `${wanted.length} linked` }
1078
+ : { name: `agents in ${homeLabel(h)}`, status: 'warn', detail: `missing ${missing.join(', ')}`, fix: `vsf agents link ${tilde(h.dir)}` });
1079
+ }
1080
+ try {
1081
+ const me = await linear().viewer();
1082
+ checks.push({ name: 'Linear', status: 'ok', detail: `connected as ${me.name}` });
1083
+ }
1084
+ catch (err) {
1085
+ checks.push({ name: 'Linear', status: 'fail', detail: err.message, fix: 'vsf agents key' });
1086
+ }
1087
+ const workflows = Object.entries(saved.workflows);
1088
+ const broken = workflows.filter(([, c]) => !saved.projects[c.project] || !isRepo(saved.projects[c.project].path)).map(([n]) => n);
1089
+ checks.push(!workflows.length
1090
+ ? { name: 'agent workflows', status: 'warn', detail: 'none yet', fix: 'vsf agents workflow add' }
1091
+ : broken.length
1092
+ ? { name: 'agent workflows', status: 'fail', detail: `${broken.join(', ')} point at a missing project`, fix: 'vsf agents project edit' }
1093
+ : { name: 'agent workflows', status: 'ok', detail: `${workflows.length} ready` });
1094
+ return [...checks, ...(await controlChecks(ctx))];
1095
+ },
1096
+ commands: {
1097
+ ...controlCommands,
1098
+ async workflows(args, ctx) {
1099
+ const p = begin('workflows', args, { json: { type: 'boolean' } }, ctx);
1100
+ if (typeof p === 'number')
1101
+ return p;
1102
+ const config = readAgentsConfig(ctx.configDir);
1103
+ if (p.values.json)
1104
+ out(ctx, Object.entries(config.workflows).map(([name, w]) => ({ name, ...w })));
1105
+ else if (!Object.keys(config.workflows).length)
1106
+ ctx.log('No workflows yet. vsf agents workflow add creates one.');
1107
+ else
1108
+ listWorkflows(config, ctx);
1109
+ return 0;
1110
+ },
1111
+ // Hidden, like tick: the step list and workflow templates, for the dashboard's forms.
1112
+ async steps(_args, ctx) {
1113
+ const steps = Object.keys(STEPS).map((id) => ({ id, name: id.replace(/^vs-/, ''), ...STEPS[id] }));
1114
+ out(ctx, { steps, templates: TEMPLATES });
1115
+ return 0;
1116
+ },
1117
+ workflow: (args, ctx) => group('workflow', workflowSubs, args, ctx),
1118
+ async projects(args, ctx) {
1119
+ const p = begin('projects', args, { json: { type: 'boolean' } }, ctx);
1120
+ if (typeof p === 'number')
1121
+ return p;
1122
+ const config = readAgentsConfig(ctx.configDir);
1123
+ if (p.values.json)
1124
+ out(ctx, Object.entries(config.projects).map(([name, pr]) => ({ name, ...pr, usedBy: workflowsUsing(config, name) })));
1125
+ else if (!Object.keys(config.projects).length)
1126
+ ctx.log('No projects yet. vsf agents project add creates one.');
1127
+ else
1128
+ for (const name of Object.keys(config.projects))
1129
+ ctx.log(describeProject(config, name));
1130
+ return 0;
1131
+ },
1132
+ project: (args, ctx) => group('project', projectSubs, args, ctx),
1133
+ homes: homeCommands.homes,
1134
+ link: homeCommands.link,
1135
+ unlink: homeCommands.unlink,
1136
+ async key(args, ctx) {
1137
+ const p = begin('key', args, {}, ctx);
1138
+ if (typeof p === 'number')
1139
+ return p;
1140
+ if (p.positionals.length) {
1141
+ ctx.log('Keys are not accepted on the command line. Pipe the key in instead.');
1142
+ return 2;
1143
+ }
1144
+ if (interactive())
1145
+ return (await setupKey(ctx.ui)) ? 0 : 1;
1146
+ const key = readFileSync(0, 'utf8').trim();
1147
+ if (!looksLikeLinearKey(key)) {
1148
+ ctx.log('Linear keys start with lin_api_');
1149
+ return 2;
1150
+ }
1151
+ return (await verifyAndStore(key, ctx.log, ctx.log)) ? 0 : 1;
1152
+ },
1153
+ async help(args, ctx) {
1154
+ const want = args.join(' ').trim();
1155
+ if (!want) {
1156
+ ctx.log(fullUsage());
1157
+ return 0;
1158
+ }
1159
+ const mine = usageOf(want);
1160
+ const control = Object.hasOwn(controlUsage, want) ? controlUsage[want] : undefined;
1161
+ if (!mine && !control) {
1162
+ ctx.log(`No command named ${want}.`);
1163
+ ctx.log(fullUsage());
1164
+ return 2;
1165
+ }
1166
+ ctx.log([control, mine].filter(Boolean).join('\n'));
1167
+ return 0;
1168
+ },
1169
+ },
1170
+ };
1171
+ export default agents;