overlord-cli 3.5.3 → 3.8.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.
@@ -11,14 +11,29 @@ import crypto from 'node:crypto';
11
11
  import fs from 'node:fs';
12
12
  import os from 'node:os';
13
13
  import path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
14
15
 
15
- const BUNDLE_VERSION = '1.5.0';
16
+ const BUNDLE_VERSION = '1.8.0';
16
17
  const MD_MARKER_START = '<!-- overlord:managed:start -->';
17
18
  const MD_MARKER_END = '<!-- overlord:managed:end -->';
18
19
  const MANIFEST_DIR = path.join(os.homedir(), '.ovld');
19
20
  const MANIFEST_FILE = path.join(MANIFEST_DIR, 'bundle-manifest.json');
20
-
21
- const supportedAgents = ['claude', 'opencode'];
21
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
22
+ const PACKAGE_PLUGIN_DIR = path.resolve(__dirname, '..', '..', 'plugins', 'overlord');
23
+ const REPO_PLUGIN_DIR = path.resolve(__dirname, '..', '..', '..', '..', 'plugins', 'overlord');
24
+ const CODEX_TARGET_PLUGIN_DIR = path.join(os.homedir(), '.codex', 'plugins', 'overlord');
25
+ const CODEX_TARGET_PLUGIN_MANIFEST = path.join(
26
+ CODEX_TARGET_PLUGIN_DIR,
27
+ '.codex-plugin',
28
+ 'plugin.json'
29
+ );
30
+ const CODEX_TARGET_MARKETPLACE = path.join(os.homedir(), '.agents', 'plugins', 'marketplace.json');
31
+ const CODEX_TARGET_RULES = path.join(os.homedir(), '.codex', 'rules', 'default.rules');
32
+ const CODEX_LEGACY_AGENTS = path.join(os.homedir(), '.codex', 'AGENTS.md');
33
+ const CODEX_RULES_START = '# overlord:permissions:start';
34
+ const CODEX_RULES_END = '# overlord:permissions:end';
35
+
36
+ const supportedAgents = ['claude', 'codex', 'cursor', 'gemini', 'opencode'];
22
37
 
23
38
  // ---------------------------------------------------------------------------
24
39
  // Templates (same content as electron/services/agent-bundle/templates.ts)
@@ -71,6 +86,7 @@ If you receive a prompt with a specified ticket ID, adhere to the following. If
71
86
  \`\`\`bash
72
87
  ovld protocol deliver --session-key <sessionKey> --ticket-id $TICKET_ID --payload-file ./deliver.json
73
88
  \`\`\`
89
+ Treat \`deliver.json\` as ephemeral scratch data only. Create it outside the repository when practical, never commit it, and remove it after delivery.
74
90
 
75
91
  ## Change Rationales
76
92
 
@@ -78,7 +94,7 @@ Always include \`changeRationales\` when delivering. Optionally include them on
78
94
 
79
95
  Before delivering, make sure every meaningful git-tracked file change is represented in \`changeRationales\`; do not send \`file_changes\` as an artifact.
80
96
 
81
- These are structured protocol payloads that Overlord stores as first-class rows in the \`file_changes\` table. Prefer inline JSON or the dedicated command below. For quote-sensitive deliveries, prefer \`--payload-file\` so summary, artifacts, and change rationales stay in one JSON document. Ordinary deliver artifacts should use \`next_steps\`, \`test_results\`, \`migration\`, \`note\`, \`url\`, or \`decision\`.
97
+ These are structured protocol payloads that Overlord stores as first-class rows in the \`file_changes\` table. Prefer inline JSON or the dedicated command below. For quote-sensitive deliveries, prefer \`--payload-file\` so summary, artifacts, and change rationales stay in one JSON document, but treat that JSON as ephemeral scratch data rather than a repository artifact. Ordinary deliver artifacts should use \`next_steps\`, \`test_results\`, \`migration\`, \`note\`, \`url\`, or \`decision\`.
82
98
 
83
99
  \`\`\`bash
84
100
  ovld protocol record-change-rationales --session-key <sessionKey> --ticket-id $TICKET_ID \\\\
@@ -140,10 +156,11 @@ If you receive a prompt with a specified ticket ID, adhere to the following. If
140
156
  --artifacts-json '[{"type":"next_steps","label":"Next steps","content":"..."}]' \\\\
141
157
  --change-rationales-json '[{"label":"Short reviewer title","file_path":"path/to/file.ts","summary":"What changed.","why":"Why it changed.","impact":"Behavioral impact.","hunks":[{"header":"@@ -10,6 +10,14 @@"}]}]'
142
158
  \`\`\`
159
+ If you use \`--payload-file\`, \`--artifacts-file\`, or \`--change-rationales-file\` for larger JSON, treat that file as ephemeral scratch data outside the repository and remove it after delivery. Do not leave delivery JSON checked into the worktree.
143
160
 
144
161
  ## Change Rationales
145
162
 
146
- Always include \`changeRationales\` when delivering. Before delivering, make sure every meaningful git-tracked file change is represented in \`changeRationales\`; do not send \`file_changes\` as an artifact. Record only meaningful behavioral changes. Overlord stores these as structured rows in the \`file_changes\` table.
163
+ Always include \`changeRationales\` when delivering. Before delivering, make sure every meaningful git-tracked file change is represented in \`changeRationales\`; do not send \`file_changes\` as an artifact. Record only meaningful behavioral changes. Overlord stores these as structured rows in the \`file_changes\` table. If you need a JSON file for transport, keep it ephemeral and out of the repository.
147
164
 
148
165
  \`\`\`bash
149
166
  ovld protocol record-change-rationales --session-key <sessionKey> --ticket-id $TICKET_ID \\\\
@@ -185,6 +202,41 @@ fi
185
202
  exit 0
186
203
  `;
187
204
 
205
+ const CURSOR_RULES_CONTENT = `---
206
+ description: Overlord local workflow protocol — attach, update, deliver lifecycle for ticket-driven work.
207
+ globs:
208
+ alwaysApply: true
209
+ ---
210
+
211
+ # Overlord Local Workflow
212
+
213
+ If a prompt includes a TICKET_ID, attach first with:
214
+ \`\`\`bash
215
+ ovld protocol attach --ticket-id $TICKET_ID
216
+ \`\`\`
217
+
218
+ During work, post progress updates with:
219
+ \`\`\`bash
220
+ ovld protocol update --session-key <sessionKey> --ticket-id $TICKET_ID --summary "What you did and why." --phase execute
221
+ \`\`\`
222
+
223
+ If blocked on human input, ask with:
224
+ \`\`\`bash
225
+ ovld protocol ask --session-key <sessionKey> --ticket-id $TICKET_ID --question "Specific question for the PM."
226
+ \`\`\`
227
+
228
+ When done, deliver with artifacts and change rationales:
229
+ \`\`\`bash
230
+ ovld protocol deliver --session-key <sessionKey> --ticket-id $TICKET_ID --summary "Narrative: what you did, next steps." --artifacts-json '[{"type":"next_steps","label":"Next steps","content":"..."}]' --change-rationales-json '[{"label":"Short reviewer title","file_path":"path/to/file.ts","summary":"What changed.","why":"Why it changed.","impact":"Behavioral impact.","hunks":[{"header":"@@ -10,6 +10,14 @@"}]}]'
231
+ \`\`\`
232
+ If you use a JSON file for delivery transport, keep it ephemeral scratch data outside the repository and remove it after the protocol call.
233
+
234
+ Rules:
235
+ - Always attach first and deliver last.
236
+ - Use \`ovld protocol\` commands instead of ad hoc repo scripts for ticket lifecycle work.
237
+ - If the user sends a new message during an active ticket session, publish a \`user_follow_up\` event before doing anything else.
238
+ `;
239
+
188
240
  // ---------------------------------------------------------------------------
189
241
  // Helpers
190
242
  // ---------------------------------------------------------------------------
@@ -215,6 +267,23 @@ function readJsonFile(filePath) {
215
267
  }
216
268
  }
217
269
 
270
+ function readJsonFileOrNull(filePath) {
271
+ try {
272
+ if (!fs.existsSync(filePath)) return null;
273
+ const raw = fs.readFileSync(filePath, 'utf-8');
274
+ const parsed = JSON.parse(raw);
275
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
276
+ return null;
277
+ } catch {
278
+ return null;
279
+ }
280
+ }
281
+
282
+ function localCliVersion() {
283
+ const cliPackage = readJsonFileOrNull(path.resolve(__dirname, '..', '..', 'package.json'));
284
+ return typeof cliPackage?.version === 'string' ? cliPackage.version : null;
285
+ }
286
+
218
287
  function writeJsonFile(filePath, data) {
219
288
  const dir = path.dirname(filePath);
220
289
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
@@ -241,6 +310,11 @@ function deepClone(obj) {
241
310
  return JSON.parse(JSON.stringify(obj));
242
311
  }
243
312
 
313
+ function asStringArray(value) {
314
+ if (!Array.isArray(value)) return [];
315
+ return value.filter(entry => typeof entry === 'string');
316
+ }
317
+
244
318
  function mergeMarkdownSection(existing, newContent) {
245
319
  const wrappedContent = `${MD_MARKER_START}\n${newContent.trim()}\n${MD_MARKER_END}`;
246
320
  const startIdx = existing.indexOf(MD_MARKER_START);
@@ -262,6 +336,316 @@ function writeManifest(manifest) {
262
336
  writeJsonFile(MANIFEST_FILE, manifest);
263
337
  }
264
338
 
339
+ function slashCommandFiles(agent) {
340
+ if (agent === 'claude') {
341
+ const base = path.join(os.homedir(), '.claude', 'commands');
342
+ return [
343
+ {
344
+ path: path.join(base, 'connect.md'),
345
+ content: `---
346
+ description: Connect this session to another Overlord ticket by ticket ID
347
+ argument-hint: <ticket-id>
348
+ disable-model-invocation: true
349
+ ---
350
+
351
+ Run \`ovld protocol connect --ticket-id <ticketId>\` using \`$ARGUMENTS\` as the ticket ID.`
352
+ },
353
+ {
354
+ path: path.join(base, 'load.md'),
355
+ content: `---
356
+ description: Load Overlord ticket context without creating a new session
357
+ argument-hint: <ticket-id>
358
+ disable-model-invocation: true
359
+ ---
360
+
361
+ Run \`ovld protocol load-context --ticket-id <ticketId>\` using \`$ARGUMENTS\` as the ticket ID.`
362
+ },
363
+ {
364
+ path: path.join(base, 'spawn.md'),
365
+ content: `---
366
+ description: Create a new Overlord ticket from the current conversation
367
+ argument-hint: <objective or raw flags>
368
+ disable-model-invocation: true
369
+ ---
370
+
371
+ Run \`ovld protocol spawn\` with \`$ARGUMENTS\`. If no flags are present, treat the arguments as the objective and call \`ovld protocol spawn --objective "<objective>"\`.`
372
+ }
373
+ ];
374
+ }
375
+
376
+ if (agent === 'cursor') {
377
+ const base = path.join(os.homedir(), '.cursor', 'commands');
378
+ return [
379
+ {
380
+ path: path.join(base, 'connect.md'),
381
+ content:
382
+ 'Connect this session to another Overlord ticket.\n\nRun `ovld protocol connect --ticket-id <ticketId>` using the text after `/connect` as the ticket ID.\n'
383
+ },
384
+ {
385
+ path: path.join(base, 'load.md'),
386
+ content:
387
+ 'Load Overlord ticket context without attaching.\n\nRun `ovld protocol load-context --ticket-id <ticketId>` using the text after `/load` as the ticket ID.\n'
388
+ },
389
+ {
390
+ path: path.join(base, 'spawn.md'),
391
+ content:
392
+ 'Create a new Overlord ticket.\n\nRun `ovld protocol spawn --objective "<objective>"` using the text after `/spawn` unless raw flags were provided.\n'
393
+ }
394
+ ];
395
+ }
396
+
397
+ if (agent === 'gemini') {
398
+ const base = path.join(os.homedir(), '.gemini', 'commands');
399
+ return [
400
+ {
401
+ path: path.join(base, 'connect.toml'),
402
+ content:
403
+ 'description = "Connect this session to another Overlord ticket by ticket ID."\nprompt = """\nRun `ovld protocol connect --ticket-id <ticketId>` using `{{args}}` as the ticket ID.\n"""\n'
404
+ },
405
+ {
406
+ path: path.join(base, 'load.toml'),
407
+ content:
408
+ 'description = "Load Overlord ticket context without creating a new session."\nprompt = """\nRun `ovld protocol load-context --ticket-id <ticketId>` using `{{args}}` as the ticket ID.\n"""\n'
409
+ },
410
+ {
411
+ path: path.join(base, 'spawn.toml'),
412
+ content:
413
+ 'description = "Create a new Overlord ticket from the current conversation."\nprompt = """\nRun `ovld protocol spawn --objective "<objective>"` using `{{args}}` as the objective unless raw flags were provided.\n"""\n'
414
+ }
415
+ ];
416
+ }
417
+
418
+ const base = path.join(os.homedir(), '.config', 'opencode', 'commands');
419
+ return [
420
+ {
421
+ path: path.join(base, 'connect.md'),
422
+ content: `---
423
+ description: Connect this session to another Overlord ticket by ticket ID
424
+ agent: build
425
+ ---
426
+
427
+ Run \`ovld protocol connect --ticket-id <ticketId>\` using \`$ARGUMENTS\` as the ticket ID. If no ticket ID was provided, ask the user for one and stop.`
428
+ },
429
+ {
430
+ path: path.join(base, 'load.md'),
431
+ content: `---
432
+ description: Load Overlord ticket context without creating a new session
433
+ agent: build
434
+ ---
435
+
436
+ Run \`ovld protocol load-context --ticket-id <ticketId>\` using \`$ARGUMENTS\` as the ticket ID. If no ticket ID was provided, ask the user for one and stop.`
437
+ },
438
+ {
439
+ path: path.join(base, 'spawn.md'),
440
+ content: `---
441
+ description: Create a new Overlord ticket from the current conversation
442
+ agent: build
443
+ ---
444
+
445
+ Run \`ovld protocol spawn\` with \`$ARGUMENTS\`. If no flags are present, treat the arguments as the objective and call \`ovld protocol spawn --objective "<objective>"\`.`
446
+ }
447
+ ];
448
+ }
449
+
450
+ function installSlashCommands(agent) {
451
+ const backups = [];
452
+ const files = slashCommandFiles(agent);
453
+ for (const file of files) {
454
+ const backup = backupFile(file.path);
455
+ if (backup) backups.push(backup);
456
+ writeTextFile(file.path, `${file.content.trim()}\n`);
457
+ console.log(` ✓ Installed slash command: ${file.path}`);
458
+ }
459
+ return {
460
+ backups,
461
+ managedFiles: files.map(file => file.path)
462
+ };
463
+ }
464
+
465
+ function currentContentHashForAgent(agent) {
466
+ if (agent === 'claude') {
467
+ return contentHash(
468
+ [CLAUDE_SKILL_CONTENT, PERMISSION_HOOK_SCRIPT, ...slashCommandFiles('claude').map(file => file.content)].join('\n')
469
+ );
470
+ }
471
+ if (agent === 'cursor') {
472
+ return contentHash(
473
+ [CURSOR_RULES_CONTENT, ...slashCommandFiles('cursor').map(file => file.content)].join('\n')
474
+ );
475
+ }
476
+ if (agent === 'gemini') {
477
+ return contentHash(slashCommandFiles('gemini').map(file => file.content).join('\n'));
478
+ }
479
+ if (agent === 'codex') return codexContentHash();
480
+ return contentHash(
481
+ [OPENCODE_AGENTS_SECTION, ...slashCommandFiles('opencode').map(file => file.content)].join('\n')
482
+ );
483
+ }
484
+
485
+ async function checkForCliUpdate() {
486
+ const currentVersion = localCliVersion();
487
+ if (!currentVersion) return null;
488
+ const controller = new AbortController();
489
+ const timeout = setTimeout(() => controller.abort(), 2500);
490
+ try {
491
+ const response = await fetch('https://registry.npmjs.org/overlord-cli/latest', {
492
+ signal: controller.signal,
493
+ headers: { Accept: 'application/json' }
494
+ });
495
+ if (!response.ok) return null;
496
+ const payload = await response.json();
497
+ const latestVersion = typeof payload?.version === 'string' ? payload.version : null;
498
+ if (!latestVersion) return null;
499
+ return latestVersion === currentVersion ? null : latestVersion;
500
+ } catch {
501
+ return null;
502
+ } finally {
503
+ clearTimeout(timeout);
504
+ }
505
+ }
506
+
507
+ function codexSourcePluginDir() {
508
+ if (fs.existsSync(PACKAGE_PLUGIN_DIR)) return PACKAGE_PLUGIN_DIR;
509
+ if (fs.existsSync(REPO_PLUGIN_DIR)) return REPO_PLUGIN_DIR;
510
+ throw new Error(
511
+ `Codex plugin bundle not found. Checked ${PACKAGE_PLUGIN_DIR} and ${REPO_PLUGIN_DIR}.`
512
+ );
513
+ }
514
+
515
+ function listFilesRecursive(dir) {
516
+ if (!fs.existsSync(dir)) return [];
517
+ return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
518
+ const resolved = path.join(dir, entry.name);
519
+ if (entry.isDirectory()) return listFilesRecursive(resolved);
520
+ return [resolved];
521
+ });
522
+ }
523
+
524
+ function codexContentHash() {
525
+ const sourceDir = codexSourcePluginDir();
526
+ const hash = crypto.createHash('sha256');
527
+
528
+ for (const filePath of listFilesRecursive(sourceDir).sort()) {
529
+ hash.update(path.relative(sourceDir, filePath));
530
+ hash.update('\0');
531
+ hash.update(fs.readFileSync(filePath));
532
+ hash.update('\0');
533
+ }
534
+
535
+ return hash.digest('hex').slice(0, 16);
536
+ }
537
+
538
+ function mergeCodexRules(existingContent) {
539
+ const managedBlock = [
540
+ CODEX_RULES_START,
541
+ 'prefix_rule(',
542
+ ' pattern = ["npx", "overlord", "protocol"],',
543
+ ' decision = "allow",',
544
+ ' justification = "Allow all Overlord protocol commands without prompts.",',
545
+ ')',
546
+ '',
547
+ 'prefix_rule(',
548
+ ' pattern = ["ovld", "protocol"],',
549
+ ' decision = "allow",',
550
+ ' justification = "Allow all Overlord protocol commands without prompts.",',
551
+ ')',
552
+ '',
553
+ 'prefix_rule(',
554
+ ' pattern = ["curl", "-sS", "-X", "POST"],',
555
+ ' decision = "allow",',
556
+ ' justification = "Allow curl protocol POST commands without prompts.",',
557
+ ')',
558
+ CODEX_RULES_END
559
+ ].join('\n');
560
+
561
+ const startIndex = existingContent.indexOf(CODEX_RULES_START);
562
+ const endIndex = existingContent.indexOf(CODEX_RULES_END);
563
+
564
+ if (startIndex !== -1 && endIndex !== -1 && endIndex > startIndex) {
565
+ const before = existingContent.slice(0, startIndex).trimEnd();
566
+ const after = existingContent.slice(endIndex + CODEX_RULES_END.length).trimStart();
567
+ if (!before && !after) return `${managedBlock}\n`;
568
+ if (!before) return `${managedBlock}\n\n${after}`;
569
+ if (!after) return `${before}\n\n${managedBlock}\n`;
570
+ return `${before}\n\n${managedBlock}\n\n${after}`;
571
+ }
572
+
573
+ const trimmed = existingContent.trimEnd();
574
+ if (!trimmed) return `${managedBlock}\n`;
575
+ return `${trimmed}\n\n${managedBlock}\n`;
576
+ }
577
+
578
+ function pluginVersion(filePath) {
579
+ const parsed = readJsonFileOrNull(filePath);
580
+ return typeof parsed?.version === 'string' ? parsed.version : null;
581
+ }
582
+
583
+ function upsertCodexMarketplaceEntry() {
584
+ const current = readJsonFileOrNull(CODEX_TARGET_MARKETPLACE) ?? {
585
+ name: 'overlord-local',
586
+ interface: { displayName: 'Overlord Local Plugins' },
587
+ plugins: []
588
+ };
589
+
590
+ const nextPlugins = Array.isArray(current.plugins) ? [...current.plugins] : [];
591
+ const entry = {
592
+ name: 'overlord',
593
+ source: {
594
+ source: 'local',
595
+ path: './.codex/plugins/overlord'
596
+ },
597
+ policy: {
598
+ installation: 'AVAILABLE',
599
+ authentication: 'ON_INSTALL'
600
+ },
601
+ category: 'Productivity'
602
+ };
603
+
604
+ const existingIndex = nextPlugins.findIndex(plugin => plugin?.name === 'overlord');
605
+ if (existingIndex === -1) nextPlugins.push(entry);
606
+ else nextPlugins[existingIndex] = entry;
607
+
608
+ writeJsonFile(CODEX_TARGET_MARKETPLACE, {
609
+ name: current.name ?? 'overlord-local',
610
+ interface: {
611
+ displayName: current.interface?.displayName ?? 'Overlord Local Plugins'
612
+ },
613
+ plugins: nextPlugins
614
+ });
615
+ }
616
+
617
+ function removeLegacyCodexBundle() {
618
+ if (fs.existsSync(CODEX_LEGACY_AGENTS)) {
619
+ const existing = readTextFile(CODEX_LEGACY_AGENTS);
620
+ const startIndex = existing.indexOf(MD_MARKER_START);
621
+ const endIndex = existing.indexOf(MD_MARKER_END);
622
+
623
+ if (startIndex !== -1 && endIndex !== -1 && endIndex > startIndex) {
624
+ const before = existing.slice(0, startIndex).trimEnd();
625
+ const after = existing.slice(endIndex + MD_MARKER_END.length).trimStart();
626
+ const cleaned =
627
+ !before && !after
628
+ ? ''
629
+ : !before
630
+ ? `${after}\n`
631
+ : !after
632
+ ? `${before}\n`
633
+ : `${before}\n\n${after}\n`;
634
+
635
+ if (cleaned.trim().length > 0) {
636
+ writeTextFile(CODEX_LEGACY_AGENTS, cleaned);
637
+ } else {
638
+ fs.rmSync(CODEX_LEGACY_AGENTS, { force: true });
639
+ }
640
+ }
641
+ }
642
+
643
+ const manifest = readManifest();
644
+ if (!manifest.codex) return;
645
+ delete manifest.codex;
646
+ writeManifest(manifest);
647
+ }
648
+
265
649
  // ---------------------------------------------------------------------------
266
650
  // Install
267
651
  // ---------------------------------------------------------------------------
@@ -285,6 +669,21 @@ function openCodePaths() {
285
669
  };
286
670
  }
287
671
 
672
+ function cursorPaths() {
673
+ const base = path.join(os.homedir(), '.cursor');
674
+ return {
675
+ rulesFile: path.join(base, 'rules', 'overlord-local.mdc'),
676
+ settingsFile: path.join(base, 'settings.json')
677
+ };
678
+ }
679
+
680
+ function geminiPaths() {
681
+ const base = path.join(os.homedir(), '.gemini');
682
+ return {
683
+ policyFile: path.join(base, 'policies', 'overlord-protocol.toml')
684
+ };
685
+ }
686
+
288
687
  function installClaude() {
289
688
  const paths = claudePaths();
290
689
  const backups = [];
@@ -314,6 +713,17 @@ function installClaude() {
314
713
  const existingPermHooks = Array.isArray(existingHooks.PermissionRequest)
315
714
  ? existingHooks.PermissionRequest
316
715
  : [];
716
+ const existingPermissions =
717
+ existingSettings.permissions && typeof existingSettings.permissions === 'object'
718
+ ? existingSettings.permissions
719
+ : {};
720
+ const mergedAllow = Array.from(
721
+ new Set([
722
+ ...asStringArray(existingPermissions.allow),
723
+ 'Bash(ovld protocol:*)',
724
+ 'Bash(curl -sS -X POST:*)'
725
+ ])
726
+ );
317
727
 
318
728
  // Remove existing Overlord hooks
319
729
  const filteredPermHooks = existingPermHooks.filter(hook => {
@@ -328,21 +738,25 @@ function installClaude() {
328
738
 
329
739
  const merged = deepClone(existingSettings);
330
740
  merged.hooks = { ...existingHooks, PermissionRequest: [...filteredPermHooks, overlordHook] };
741
+ merged.permissions = { ...existingPermissions, allow: mergedAllow };
331
742
  merged.__overlord_managed = {
332
743
  version: BUNDLE_VERSION,
333
- paths: ['hooks.PermissionRequest'],
744
+ paths: ['hooks.PermissionRequest', 'permissions.allow'],
334
745
  updatedAt: new Date().toISOString()
335
746
  };
336
747
  writeJsonFile(paths.settingsFile, merged);
337
748
  console.log(` ✓ Merged hook into: ${paths.settingsFile}`);
338
749
 
750
+ const slashResult = installSlashCommands('claude');
751
+ backups.push(...slashResult.backups);
752
+
339
753
  // 4. Update manifest
340
754
  const manifest = readManifest();
341
755
  manifest.claude = {
342
756
  version: BUNDLE_VERSION,
343
- contentHash: contentHash(CLAUDE_SKILL_CONTENT),
757
+ contentHash: currentContentHashForAgent('claude'),
344
758
  installedAt: new Date().toISOString(),
345
- files: [paths.skillFile, paths.hookScript, paths.settingsFile]
759
+ files: [paths.skillFile, paths.hookScript, paths.settingsFile, ...slashResult.managedFiles]
346
760
  };
347
761
  writeManifest(manifest);
348
762
 
@@ -400,56 +814,124 @@ function installOpenCode() {
400
814
  });
401
815
  console.log(` ✓ Updated config: ${paths.configFile}`);
402
816
 
403
- const commandFiles = [
404
- {
405
- file: path.join(paths.commandsDir, 'connect.md'),
406
- content: `---
407
- description: Connect this session to another Overlord ticket by ticket ID
408
- agent: build
409
- ---
817
+ const slashResult = installSlashCommands('opencode');
818
+ backups.push(...slashResult.backups);
410
819
 
411
- Run \`ovld protocol connect --ticket-id <ticketId>\` using \`$ARGUMENTS\` as the ticket ID. If no ticket ID was provided, ask the user for one and stop.`
412
- },
413
- {
414
- file: path.join(paths.commandsDir, 'load.md'),
415
- content: `---
416
- description: Load Overlord ticket context without creating a new session
417
- agent: build
418
- ---
820
+ const manifest = readManifest();
821
+ manifest.opencode = {
822
+ version: BUNDLE_VERSION,
823
+ contentHash: currentContentHashForAgent('opencode'),
824
+ installedAt: new Date().toISOString(),
825
+ files: [paths.agentsFile, paths.configFile, ...slashResult.managedFiles]
826
+ };
827
+ writeManifest(manifest);
419
828
 
420
- Run \`ovld protocol load-context --ticket-id <ticketId>\` using \`$ARGUMENTS\` as the ticket ID. If no ticket ID was provided, ask the user for one and stop.`
421
- },
422
- {
423
- file: path.join(paths.commandsDir, 'spawn.md'),
424
- content: `---
425
- description: Create a new Overlord ticket from the current conversation
426
- agent: build
427
- ---
829
+ return { ok: true, backups };
830
+ }
428
831
 
429
- Run \`ovld protocol spawn\` with \`$ARGUMENTS\`. If no flags are present, treat the arguments as the objective and call \`ovld protocol spawn --objective "<objective>"\`.`
430
- }
832
+ function installCodex() {
833
+ const sourceDir = codexSourcePluginDir();
834
+ fs.mkdirSync(path.dirname(CODEX_TARGET_PLUGIN_DIR), { recursive: true });
835
+ fs.rmSync(CODEX_TARGET_PLUGIN_DIR, { recursive: true, force: true });
836
+ fs.cpSync(sourceDir, CODEX_TARGET_PLUGIN_DIR, { recursive: true });
837
+ console.log(` ✓ Installed plugin: ${CODEX_TARGET_PLUGIN_DIR}`);
838
+
839
+ writeTextFile(CODEX_TARGET_RULES, mergeCodexRules(readTextFile(CODEX_TARGET_RULES)));
840
+ console.log(` ✓ Updated rules: ${CODEX_TARGET_RULES}`);
841
+
842
+ upsertCodexMarketplaceEntry();
843
+ console.log(` ✓ Updated marketplace: ${CODEX_TARGET_MARKETPLACE}`);
844
+
845
+ removeLegacyCodexBundle();
846
+
847
+ const installedFiles = [
848
+ ...listFilesRecursive(CODEX_TARGET_PLUGIN_DIR),
849
+ CODEX_TARGET_MARKETPLACE,
850
+ CODEX_TARGET_RULES
431
851
  ];
852
+ const manifest = readManifest();
853
+ manifest.codex = {
854
+ version: pluginVersion(CODEX_TARGET_PLUGIN_MANIFEST) ?? '0.0.0',
855
+ contentHash: codexContentHash(),
856
+ installedAt: new Date().toISOString(),
857
+ files: installedFiles
858
+ };
859
+ writeManifest(manifest);
860
+
861
+ return { ok: true, installedFiles };
862
+ }
432
863
 
433
- for (const commandFile of commandFiles) {
434
- const commandBackup = backupFile(commandFile.file);
435
- if (commandBackup) {
436
- backups.push(commandBackup);
437
- console.log(` ✓ Backed up: ${commandFile.file} → ${path.basename(commandBackup)}`);
864
+ function installCursor() {
865
+ const paths = cursorPaths();
866
+ writeTextFile(paths.rulesFile, CURSOR_RULES_CONTENT);
867
+ console.log(` ✓ Installed rules: ${paths.rulesFile}`);
868
+
869
+ const slashResult = installSlashCommands('cursor');
870
+
871
+ const existingSettings = readJsonFile(paths.settingsFile);
872
+ const permissions =
873
+ existingSettings.permissions && typeof existingSettings.permissions === 'object'
874
+ ? existingSettings.permissions
875
+ : {};
876
+ const mergedAllow = Array.from(
877
+ new Set([
878
+ ...asStringArray(permissions.allow),
879
+ 'Shell(ovld protocol:*)',
880
+ 'Shell(curl -sS -X POST:*)'
881
+ ])
882
+ );
883
+ writeJsonFile(paths.settingsFile, {
884
+ ...existingSettings,
885
+ permissions: {
886
+ ...permissions,
887
+ allow: mergedAllow
438
888
  }
439
- writeTextFile(commandFile.file, `${commandFile.content.trim()}\n`);
440
- console.log(` ✓ Installed slash command: ${commandFile.file}`);
441
- }
889
+ });
890
+ console.log(` ✓ Updated permissions: ${paths.settingsFile}`);
442
891
 
443
892
  const manifest = readManifest();
444
- manifest.opencode = {
893
+ manifest.cursor = {
445
894
  version: BUNDLE_VERSION,
446
- contentHash: contentHash(OPENCODE_AGENTS_SECTION),
895
+ contentHash: currentContentHashForAgent('cursor'),
447
896
  installedAt: new Date().toISOString(),
448
- files: [paths.agentsFile, paths.configFile, ...commandFiles.map(entry => entry.file)]
897
+ files: [paths.rulesFile, paths.settingsFile, ...slashResult.managedFiles]
449
898
  };
450
899
  writeManifest(manifest);
451
900
 
452
- return { ok: true, backups };
901
+ return { ok: true };
902
+ }
903
+
904
+ function installGemini() {
905
+ const slashResult = installSlashCommands('gemini');
906
+ const paths = geminiPaths();
907
+ const policyContent = [
908
+ '# Managed by Overlord onboarding',
909
+ '[[rule]]',
910
+ 'toolName = "run_shell_command"',
911
+ 'commandPrefix = "ovld protocol"',
912
+ 'decision = "allow"',
913
+ 'priority = 900',
914
+ '',
915
+ '[[rule]]',
916
+ 'toolName = "run_shell_command"',
917
+ 'commandPrefix = "curl -sS -X POST"',
918
+ 'decision = "allow"',
919
+ 'priority = 900',
920
+ ''
921
+ ].join('\n');
922
+ writeTextFile(paths.policyFile, policyContent);
923
+ console.log(` ✓ Installed policy: ${paths.policyFile}`);
924
+
925
+ const manifest = readManifest();
926
+ manifest.gemini = {
927
+ version: BUNDLE_VERSION,
928
+ contentHash: currentContentHashForAgent('gemini'),
929
+ installedAt: new Date().toISOString(),
930
+ files: [paths.policyFile, ...slashResult.managedFiles]
931
+ };
932
+ writeManifest(manifest);
933
+
934
+ return { ok: true };
453
935
  }
454
936
 
455
937
  // ---------------------------------------------------------------------------
@@ -465,8 +947,16 @@ function doctorAgent(agent) {
465
947
  return false;
466
948
  }
467
949
 
468
- if (entry.version !== BUNDLE_VERSION) {
469
- console.log(` ⚠ ${agent}: stale (installed v${entry.version}, current v${BUNDLE_VERSION})`);
950
+ const currentVersion =
951
+ agent === 'codex'
952
+ ? pluginVersion(path.join(codexSourcePluginDir(), '.codex-plugin', 'plugin.json'))
953
+ : BUNDLE_VERSION;
954
+ const currentHash = currentContentHashForAgent(agent);
955
+
956
+ if (entry.version !== currentVersion || entry.contentHash !== currentHash) {
957
+ console.log(
958
+ ` ⚠ ${agent}: stale (installed v${entry.version}, current v${currentVersion ?? 'unknown'})`
959
+ );
470
960
  return false;
471
961
  }
472
962
 
@@ -491,25 +981,24 @@ export async function runSetupCommand(args) {
491
981
  if (agent === '--help' || agent === '-h' || agent === 'help') {
492
982
  console.log(`Usage:
493
983
  ovld setup claude Install Overlord bundle for Claude Code
984
+ ovld setup codex Install Overlord Codex plugin bundle
985
+ ovld setup cursor Install Overlord rules, slash commands, and permissions for Cursor
986
+ ovld setup gemini Install Overlord slash commands and policy rules for Gemini CLI
494
987
  ovld setup opencode Install Overlord connector for OpenCode
495
988
  ovld setup all Install for all supported agents
496
989
  ovld doctor Validate installed connectors`);
497
990
  return;
498
991
  }
499
992
 
500
- if (agent === 'codex') {
501
- console.error(
502
- 'Codex no longer uses `ovld setup codex`. Install the Overlord Codex chat plugin from the desktop app Settings -> CLI -> Codex -> Chat plugin, or configure Codex cloud/headless access through Settings -> Agents & MCP.'
503
- );
504
- process.exit(1);
505
- }
506
-
507
993
  if (agent === 'all') {
508
994
  console.log('Installing Overlord agent bundle for all supported agents...\n');
509
995
  for (const a of supportedAgents) {
510
996
  console.log(`[${a}]`);
511
997
  try {
512
998
  if (a === 'claude') installClaude();
999
+ else if (a === 'codex') installCodex();
1000
+ else if (a === 'cursor') installCursor();
1001
+ else if (a === 'gemini') installGemini();
513
1002
  else installOpenCode();
514
1003
  } catch (err) {
515
1004
  console.error(` ✗ Failed: ${err.message}`);
@@ -530,6 +1019,9 @@ export async function runSetupCommand(args) {
530
1019
  console.log(`Installing Overlord agent bundle for ${agent}...\n`);
531
1020
  try {
532
1021
  if (agent === 'claude') installClaude();
1022
+ else if (agent === 'codex') installCodex();
1023
+ else if (agent === 'cursor') installCursor();
1024
+ else if (agent === 'gemini') installGemini();
533
1025
  else installOpenCode();
534
1026
  console.log('\nDone.');
535
1027
  } catch (err) {
@@ -544,10 +1036,16 @@ export async function runDoctorCommand() {
544
1036
  for (const agent of supportedAgents) {
545
1037
  if (!doctorAgent(agent)) allOk = false;
546
1038
  }
1039
+ const latestCliVersion = await checkForCliUpdate();
547
1040
  console.log();
548
1041
  if (allOk) {
549
1042
  console.log('All bundles are up to date.');
550
1043
  } else {
551
1044
  console.log('Run `ovld setup <agent>` or `ovld setup all` to install/repair.');
552
1045
  }
1046
+ if (latestCliVersion) {
1047
+ console.log();
1048
+ console.log(`CLI update available: ${latestCliVersion}`);
1049
+ console.log('Run `npm install -g overlord-cli@latest` to update.');
1050
+ }
553
1051
  }