@phnx-labs/agents-cli 1.20.58 → 1.20.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +23 -1
  2. package/README.md +15 -7
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/exec.js +39 -2
  5. package/dist/commands/output.d.ts +19 -0
  6. package/dist/commands/output.js +333 -0
  7. package/dist/commands/secrets.js +6 -6
  8. package/dist/index.js +2 -1
  9. package/dist/lib/agents.js +19 -13
  10. package/dist/lib/hosts/dispatch.d.ts +36 -0
  11. package/dist/lib/hosts/dispatch.js +40 -2
  12. package/dist/lib/hosts/passthrough.js +1 -0
  13. package/dist/lib/mcp.js +1 -1
  14. package/dist/lib/output/git-output.d.ts +74 -0
  15. package/dist/lib/output/git-output.js +213 -0
  16. package/dist/lib/permissions.d.ts +26 -5
  17. package/dist/lib/permissions.js +212 -37
  18. package/dist/lib/plugins.d.ts +8 -0
  19. package/dist/lib/plugins.js +108 -0
  20. package/dist/lib/project-root.js +2 -1
  21. package/dist/lib/resources/mcp.js +1 -1
  22. package/dist/lib/resources/permissions.d.ts +1 -1
  23. package/dist/lib/resources/permissions.js +8 -2
  24. package/dist/lib/resources/skills.js +6 -1
  25. package/dist/lib/resources/types.d.ts +1 -1
  26. package/dist/lib/routines.d.ts +16 -0
  27. package/dist/lib/routines.js +46 -1
  28. package/dist/lib/secrets/remote.d.ts +7 -2
  29. package/dist/lib/secrets/remote.js +11 -10
  30. package/dist/lib/session/db.d.ts +3 -0
  31. package/dist/lib/session/db.js +20 -4
  32. package/dist/lib/session/discover.d.ts +2 -0
  33. package/dist/lib/session/discover.js +40 -4
  34. package/dist/lib/session/types.d.ts +2 -0
  35. package/dist/lib/shims.js +13 -3
  36. package/dist/lib/skills.js +14 -1
  37. package/dist/lib/staleness/detectors/permissions.js +50 -3
  38. package/dist/lib/staleness/detectors/subagents.js +31 -12
  39. package/dist/lib/staleness/detectors/workflows.js +33 -0
  40. package/dist/lib/staleness/writers/commands.js +3 -3
  41. package/dist/lib/staleness/writers/subagents.js +13 -5
  42. package/dist/lib/startup/command-registry.d.ts +1 -0
  43. package/dist/lib/startup/command-registry.js +2 -0
  44. package/dist/lib/subagents.d.ts +11 -1
  45. package/dist/lib/subagents.js +117 -26
  46. package/dist/lib/versions.js +12 -2
  47. package/dist/lib/workflows.d.ts +5 -3
  48. package/dist/lib/workflows.js +246 -9
  49. package/package.json +1 -1
@@ -306,14 +306,21 @@ export function buildPermissionsFromGroups(groupNames) {
306
306
  // Matches lines like: - "Bash(git *)" or - "WebFetch(domain:example.com)"
307
307
  // Handles nested quotes that break YAML parsers
308
308
  const lines = content.split('\n');
309
+ let section = null;
309
310
  for (const line of lines) {
311
+ const sectionMatch = line.match(/^\s*(allow|deny)\s*:\s*(?:#.*)?$/);
312
+ if (sectionMatch) {
313
+ section = sectionMatch[1];
314
+ continue;
315
+ }
310
316
  // Match: optional whitespace, dash, whitespace, quote, content, quote
311
317
  // Use greedy match to capture everything between first and last quote
312
318
  const match = line.match(/^\s*-\s*"(.+)"$/);
313
319
  if (match) {
314
320
  const rule = match[1];
315
- // 99-deny group rules go to deny, others to allow
316
- if (groupName === '99-deny' || groupName.includes('-deny')) {
321
+ // 99-deny group rules go to deny, others follow their YAML section.
322
+ // Legacy group files used bare lists with no section; keep those as allow.
323
+ if (section === 'deny' || groupName === '99-deny' || groupName.includes('-deny')) {
317
324
  allDeny.push(rule);
318
325
  }
319
326
  else {
@@ -486,32 +493,40 @@ function parseCanonicalPattern(permission) {
486
493
  const BLANKET_BASH_FORMS = new Set(['Bash', 'Bash(*)', 'Bash(**)']);
487
494
  /**
488
495
  * Convert canonical permission set to Gemini format.
489
- * Gemini reads tool allow-lists from settings.json under `tools.allowed`.
490
- * Bash permissions map to run_shell_command(prefix) — the prefix is extracted
491
- * from the canonical "Bash(cmd:*)" pattern by stripping the trailing ":*".
492
- * Blanket Bash grants map to bare "run_shell_command" (no prefix filter).
496
+ * Gemini reads tool allow/deny lists from settings.json under
497
+ * `tools.core` / `tools.exclude`. Bash permissions map to ShellTool(pattern).
498
+ * Blanket Bash grants map to bare "ShellTool" (no command filter).
493
499
  * Non-Bash tool patterns are skipped; Gemini uses different tool names.
494
500
  */
495
501
  export function convertToGeminiFormat(set) {
496
- const allowed = new Set();
497
- for (const perm of set.allow) {
498
- if (BLANKET_BASH_FORMS.has(perm)) {
499
- allowed.add('run_shell_command');
500
- continue;
501
- }
502
- const parsed = parseCanonicalPattern(perm);
503
- if (!parsed || parsed.tool !== 'bash')
504
- continue;
505
- const colonIdx = parsed.pattern.lastIndexOf(':');
506
- const prefix = colonIdx > 0 ? parsed.pattern.slice(0, colonIdx) : parsed.pattern;
507
- if (prefix === '*' || prefix === '**') {
508
- allowed.add('run_shell_command');
509
- }
510
- else {
511
- allowed.add(`run_shell_command(${prefix})`);
502
+ const serialize = (permissions) => {
503
+ const tools = new Set();
504
+ for (const perm of permissions) {
505
+ if (BLANKET_BASH_FORMS.has(perm)) {
506
+ tools.add('ShellTool');
507
+ continue;
508
+ }
509
+ const parsed = parseCanonicalPattern(perm);
510
+ if (!parsed || parsed.tool !== 'bash')
511
+ continue;
512
+ const command = normalizeBashPattern(parsed.pattern);
513
+ if (command === '*') {
514
+ tools.add('ShellTool');
515
+ }
516
+ else {
517
+ tools.add(`ShellTool(${command})`);
518
+ }
512
519
  }
513
- }
514
- return { tools: { allowed: Array.from(allowed) } };
520
+ return Array.from(tools);
521
+ };
522
+ const core = serialize(set.allow);
523
+ const exclude = serialize(set.deny ?? []);
524
+ return {
525
+ tools: {
526
+ core,
527
+ ...(exclude.length ? { exclude } : {}),
528
+ },
529
+ };
515
530
  }
516
531
  /**
517
532
  * Strip Claude's `:*` subcommand-wildcard suffix and return a space-glob form.
@@ -525,6 +540,26 @@ function normalizeBashPattern(pattern) {
525
540
  return pattern.slice(0, -2) + ' *';
526
541
  return pattern;
527
542
  }
543
+ /** Convert canonical Bash rules into Droid's command arrays. */
544
+ export function convertToDroidFormat(set) {
545
+ const commands = (permissions) => {
546
+ const result = new Set();
547
+ for (const permission of permissions) {
548
+ if (BLANKET_BASH_FORMS.has(permission)) {
549
+ result.add('*');
550
+ continue;
551
+ }
552
+ const parsed = parseCanonicalPattern(permission);
553
+ if (parsed?.tool === 'bash')
554
+ result.add(normalizeBashPattern(parsed.pattern));
555
+ }
556
+ return Array.from(result);
557
+ };
558
+ return {
559
+ commandAllowlist: commands(set.allow),
560
+ commandDenylist: commands(set.deny ?? []),
561
+ };
562
+ }
528
563
  /**
529
564
  * Convert canonical permission set to Antigravity format.
530
565
  * Antigravity reads ~/.gemini/antigravity-cli/settings.json with
@@ -576,6 +611,49 @@ const ANTIGRAVITY_ACTION_BY_TOOL = {
576
611
  write: 'write_file',
577
612
  webfetch: 'read_url',
578
613
  };
614
+ const GOOSE_TOOL_BY_CANONICAL = {
615
+ bash: 'developer__shell',
616
+ read: 'developer__text_editor',
617
+ write: 'developer__text_editor',
618
+ edit: 'developer__text_editor',
619
+ grep: 'developer__analyze',
620
+ glob: 'developer__analyze',
621
+ webfetch: 'developer__fetch',
622
+ mcp: undefined,
623
+ };
624
+ function canonicalToGooseTool(permission) {
625
+ if (BLANKET_BASH_FORMS.has(permission))
626
+ return 'developer__shell';
627
+ const parsed = parseCanonicalPattern(permission);
628
+ if (!parsed)
629
+ return null;
630
+ return GOOSE_TOOL_BY_CANONICAL[parsed.tool] ?? null;
631
+ }
632
+ /** Convert canonical permissions to Goose's per-tool permission.yaml shape. */
633
+ export function convertToGooseFormat(set) {
634
+ const alwaysAllow = new Set();
635
+ const neverAllow = new Set();
636
+ for (const permission of set.allow) {
637
+ const tool = canonicalToGooseTool(permission);
638
+ if (tool)
639
+ alwaysAllow.add(tool);
640
+ }
641
+ for (const permission of set.deny ?? []) {
642
+ const tool = canonicalToGooseTool(permission);
643
+ if (tool)
644
+ neverAllow.add(tool);
645
+ }
646
+ for (const tool of neverAllow) {
647
+ alwaysAllow.delete(tool);
648
+ }
649
+ return {
650
+ user: {
651
+ always_allow: Array.from(alwaysAllow).sort(),
652
+ ask_before: [],
653
+ never_allow: Array.from(neverAllow).sort(),
654
+ },
655
+ };
656
+ }
579
657
  /**
580
658
  * Convert canonical permission set to Grok format.
581
659
  * Grok reads ~/.grok/config.toml with
@@ -919,9 +997,7 @@ function readClaudePermissions(scope = 'user', cwd, options) {
919
997
  */
920
998
  function readOpenCodePermissions(scope = 'user', cwd, options) {
921
999
  const home = options?.home || HOME;
922
- const configPath = scope === 'user'
923
- ? path.join(home, '.opencode', 'opencode.jsonc')
924
- : path.join(cwd || process.cwd(), '.opencode', 'opencode.jsonc');
1000
+ const configPath = openCodeConfigPath(scope, cwd, home);
925
1001
  if (!fs.existsSync(configPath)) {
926
1002
  return null;
927
1003
  }
@@ -1031,14 +1107,36 @@ export function applyClaudePermissions(set, scope = 'user', cwd, merge = true) {
1031
1107
  return { success: false, error: err.message };
1032
1108
  }
1033
1109
  }
1110
+ /**
1111
+ * Path OpenCode actually loads for global config:
1112
+ * ~/.config/opencode/opencode.jsonc (or .json)
1113
+ * Project: <cwd>/opencode.jsonc (or .json) at project root — not .opencode/.
1114
+ * See https://opencode.ai/docs/config/
1115
+ */
1116
+ export function openCodeConfigPath(scope, cwd, home = HOME) {
1117
+ if (scope === 'project') {
1118
+ const root = cwd || process.cwd();
1119
+ for (const name of ['opencode.jsonc', 'opencode.json']) {
1120
+ const candidate = path.join(root, name);
1121
+ if (fs.existsSync(candidate))
1122
+ return candidate;
1123
+ }
1124
+ return path.join(root, 'opencode.jsonc');
1125
+ }
1126
+ const globalDir = path.join(home, '.config', 'opencode');
1127
+ for (const name of ['opencode.jsonc', 'opencode.json']) {
1128
+ const candidate = path.join(globalDir, name);
1129
+ if (fs.existsSync(candidate))
1130
+ return candidate;
1131
+ }
1132
+ return path.join(globalDir, 'opencode.jsonc');
1133
+ }
1034
1134
  /**
1035
1135
  * Apply a permission set to OpenCode's opencode.jsonc.
1036
1136
  */
1037
1137
  function applyOpenCodePermissions(set, scope = 'user', cwd, merge = true) {
1038
- const configDir = scope === 'user'
1039
- ? path.join(HOME, '.opencode')
1040
- : path.join(cwd || process.cwd(), '.opencode');
1041
- const configPath = path.join(configDir, 'opencode.jsonc');
1138
+ const configPath = openCodeConfigPath(scope, cwd);
1139
+ const configDir = path.dirname(configPath);
1042
1140
  try {
1043
1141
  // Ensure directory exists
1044
1142
  if (!fs.existsSync(configDir)) {
@@ -1172,7 +1270,10 @@ export function applyPermissionsToVersion(agentId, set, versionHome, merge = tru
1172
1270
  return { success: true };
1173
1271
  }
1174
1272
  if (agentId === 'opencode') {
1175
- const configPath = path.join(configDir, 'opencode.jsonc');
1273
+ // OpenCode loads ~/.config/opencode/opencode.jsonc under the version home
1274
+ // (HOME isolation), not ~/.opencode/opencode.jsonc.
1275
+ const configPath = openCodeConfigPath('user', undefined, versionHome);
1276
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
1176
1277
  let config = {};
1177
1278
  if (fs.existsSync(configPath)) {
1178
1279
  const content = stripJsonComments(fs.readFileSync(configPath, 'utf-8'));
@@ -1231,17 +1332,29 @@ export function applyPermissionsToVersion(agentId, set, versionHome, merge = tru
1231
1332
  const geminiPerms = convertToGeminiFormat(set);
1232
1333
  const settingsPath = path.join(versionHome, '.gemini', 'settings.json');
1233
1334
  updateGeminiSettings(settingsPath, (settings) => {
1234
- // Remove stale permissions key written by earlier versions of this serializer.
1335
+ // Remove stale keys written by earlier serializer versions:
1336
+ // top-level `permissions`, and Gemini's pre-core/exclude `tools.allowed`.
1235
1337
  delete settings.permissions;
1236
1338
  const tools = (typeof settings.tools === 'object' && settings.tools !== null && !Array.isArray(settings.tools))
1237
1339
  ? settings.tools
1238
1340
  : {};
1341
+ delete tools.allowed;
1239
1342
  if (merge) {
1240
- const existing = Array.isArray(tools.allowed) ? tools.allowed : [];
1241
- tools.allowed = Array.from(new Set([...existing, ...geminiPerms.tools.allowed]));
1343
+ const existingCore = Array.isArray(tools.core) ? tools.core : [];
1344
+ const existingExclude = Array.isArray(tools.exclude) ? tools.exclude : [];
1345
+ tools.core = Array.from(new Set([...existingCore, ...geminiPerms.tools.core]));
1346
+ const mergedExclude = Array.from(new Set([...existingExclude, ...(geminiPerms.tools.exclude ?? [])]));
1347
+ if (mergedExclude.length)
1348
+ tools.exclude = mergedExclude;
1349
+ else
1350
+ delete tools.exclude;
1242
1351
  }
1243
1352
  else {
1244
- tools.allowed = geminiPerms.tools.allowed;
1353
+ tools.core = geminiPerms.tools.core;
1354
+ if (geminiPerms.tools.exclude?.length)
1355
+ tools.exclude = geminiPerms.tools.exclude;
1356
+ else
1357
+ delete tools.exclude;
1245
1358
  }
1246
1359
  settings.tools = tools;
1247
1360
  });
@@ -1306,6 +1419,47 @@ export function applyPermissionsToVersion(agentId, set, versionHome, merge = tru
1306
1419
  fs.writeFileSync(configPath, TOML.stringify(config), 'utf-8');
1307
1420
  return { success: true };
1308
1421
  }
1422
+ if (agentId === 'goose') {
1423
+ const permissionsPath = path.join(versionHome, '.config', 'goose', 'permission.yaml');
1424
+ let config = {};
1425
+ if (fs.existsSync(permissionsPath)) {
1426
+ const parsed = yaml.parse(fs.readFileSync(permissionsPath, 'utf-8'));
1427
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
1428
+ config = parsed;
1429
+ }
1430
+ }
1431
+ const converted = convertToGooseFormat(set).user;
1432
+ const user = (typeof config.user === 'object' && config.user !== null && !Array.isArray(config.user))
1433
+ ? config.user
1434
+ : {};
1435
+ const currentAlways = Array.isArray(user.always_allow) ? user.always_allow.filter((v) => typeof v === 'string') : [];
1436
+ const currentAsk = Array.isArray(user.ask_before) ? user.ask_before.filter((v) => typeof v === 'string') : [];
1437
+ const currentNever = Array.isArray(user.never_allow) ? user.never_allow.filter((v) => typeof v === 'string') : [];
1438
+ if (merge) {
1439
+ const incomingAlways = new Set(converted.always_allow);
1440
+ const incomingNever = new Set(converted.never_allow);
1441
+ const touched = new Set([...incomingAlways, ...incomingNever]);
1442
+ const always = new Set(currentAlways.filter(tool => !touched.has(tool)));
1443
+ const ask = new Set(currentAsk.filter(tool => !touched.has(tool)));
1444
+ const never = new Set(currentNever.filter(tool => !touched.has(tool)));
1445
+ for (const tool of incomingAlways)
1446
+ always.add(tool);
1447
+ for (const tool of incomingNever)
1448
+ never.add(tool);
1449
+ user.always_allow = Array.from(always).sort();
1450
+ user.ask_before = Array.from(ask).sort();
1451
+ user.never_allow = Array.from(never).sort();
1452
+ }
1453
+ else {
1454
+ user.always_allow = converted.always_allow;
1455
+ user.ask_before = converted.ask_before;
1456
+ user.never_allow = converted.never_allow;
1457
+ }
1458
+ config.user = user;
1459
+ fs.mkdirSync(path.dirname(permissionsPath), { recursive: true });
1460
+ fs.writeFileSync(permissionsPath, yaml.stringify(config), 'utf-8');
1461
+ return { success: true };
1462
+ }
1309
1463
  if (agentId === 'kimi') {
1310
1464
  const configPath = path.join(versionHome, '.kimi-code', 'config.toml');
1311
1465
  let config = {};
@@ -1364,6 +1518,27 @@ export function applyPermissionsToVersion(agentId, set, versionHome, merge = tru
1364
1518
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
1365
1519
  return { success: true };
1366
1520
  }
1521
+ if (agentId === 'droid') {
1522
+ const configPath = path.join(versionHome, '.factory', 'settings.json');
1523
+ let config = {};
1524
+ if (fs.existsSync(configPath)) {
1525
+ config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
1526
+ }
1527
+ const converted = convertToDroidFormat(set);
1528
+ if (merge) {
1529
+ const existingAllow = Array.isArray(config.commandAllowlist) ? config.commandAllowlist : [];
1530
+ const existingDeny = Array.isArray(config.commandDenylist) ? config.commandDenylist : [];
1531
+ config.commandAllowlist = Array.from(new Set([...existingAllow, ...converted.commandAllowlist]));
1532
+ config.commandDenylist = Array.from(new Set([...existingDeny, ...converted.commandDenylist]));
1533
+ }
1534
+ else {
1535
+ config.commandAllowlist = converted.commandAllowlist;
1536
+ config.commandDenylist = converted.commandDenylist;
1537
+ }
1538
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
1539
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
1540
+ return { success: true };
1541
+ }
1367
1542
  if (agentId === 'kiro') {
1368
1543
  const permissionsPath = path.join(versionHome, '.kiro', 'settings', 'permissions.yaml');
1369
1544
  let config = {};
@@ -1496,7 +1671,7 @@ export function exportPermissionsFromPath(filePath) {
1496
1671
  if (fileName === 'settings.json' && parentDir === '.claude') {
1497
1672
  agentId = 'claude';
1498
1673
  }
1499
- else if (fileName === 'opencode.jsonc' || parentDir === '.opencode') {
1674
+ else if (fileName === 'opencode.jsonc' || fileName === 'opencode.json' || parentDir === 'opencode' || parentDir === '.opencode') {
1500
1675
  agentId = 'opencode';
1501
1676
  }
1502
1677
  else if (fileName === 'config.toml' && parentDir === '.codex') {
@@ -191,6 +191,14 @@ export declare function resolveOpenCodePluginSources(pluginRoot: string): string
191
191
  export declare function installOpenCodePlugin(plugin: DiscoveredPlugin, versionHome: string): boolean;
192
192
  export declare function isOpenCodePluginInstalled(pluginName: string, versionHome: string): boolean;
193
193
  export declare function removeOpenCodePlugin(pluginName: string, versionHome: string): boolean;
194
+ /**
195
+ * Gemini CLI extensions live under `$HOME/.gemini/extensions/<name>/` and
196
+ * require a `gemini-extension.json` manifest at the extension root.
197
+ */
198
+ export declare function geminiExtensionsDir(versionHome: string): string;
199
+ export declare function installGeminiPlugin(plugin: DiscoveredPlugin, versionHome: string): boolean;
200
+ export declare function isGeminiPluginInstalled(pluginName: string, versionHome: string): boolean;
201
+ export declare function removeGeminiPlugin(pluginName: string, versionHome: string): boolean;
194
202
  /**
195
203
  * Goose auto-discovers Open Plugins at `$HOME/.agents/plugins/<name>/`.
196
204
  * Under agents-cli version isolation HOME is the version home, so we install to:
@@ -21,6 +21,7 @@ import { shouldInstallCommandAsSkill, installCommandSkillToVersion } from './com
21
21
  import { copyPluginToMarketplace, syncMarketplaceManifest, registerMarketplace, unregisterMarketplace, addPluginToSettings, removePluginFromSettings, removePluginFromMarketplace, registerDroidInstalledPlugin, unregisterDroidInstalledPlugin, isDroidPluginInstalled, registerCopilotInstalledPlugin, unregisterCopilotInstalledPlugin, marketplaceIsEmpty, removeEmptyMarketplaceDir, isInstalledInMarketplace, marketplaceRoot, discoverMarketplaces, marketplaceNameFor, MARKETPLACE_NAME, PROJECT_MARKETPLACE_NAME, SYSTEM_MARKETPLACE_NAME, } from './plugin-marketplace.js';
22
22
  const PLUGIN_MANIFEST_DIR = '.claude-plugin';
23
23
  const PLUGIN_MANIFEST_FILE = 'plugin.json';
24
+ const GEMINI_EXTENSION_MANIFEST_FILE = 'gemini-extension.json';
24
25
  const USER_CONFIG_FILE = '.user-config.json';
25
26
  const SOURCE_FILE = '.source';
26
27
  export const PLUGIN_EXEC_SURFACE_LABELS = {
@@ -502,6 +503,27 @@ export function syncPluginToVersion(plugin, agent, versionHome, options = {}) {
502
503
  result.skills.push(plugin.name);
503
504
  return result;
504
505
  }
506
+ // Gemini CLI loads extensions from $HOME/.gemini/extensions/<name>/.
507
+ // Copy the plugin bundle as an extension and synthesize gemini-extension.json.
508
+ if (agent === 'gemini') {
509
+ const enablePlugin = options.allowExecSurfaces === true || !hasPluginExecSurfaces(inspectPluginCapabilities(plugin.root));
510
+ if (!enablePlugin) {
511
+ return result;
512
+ }
513
+ const ok = installGeminiPlugin(plugin, versionHome);
514
+ result.success = ok;
515
+ if (ok) {
516
+ result.skills = plugin.skills.map(s => `${plugin.name}:${s}`);
517
+ result.commands = plugin.commands.map(c => `${plugin.name}:${c}`);
518
+ result.agentDefs = plugin.agentDefs.map(a => `${plugin.name}:${a}`);
519
+ result.bin = plugin.bin;
520
+ result.hooks = plugin.hooks;
521
+ result.mcp = plugin.hasMcp;
522
+ result.settings = plugin.hasSettings;
523
+ result.permissions = pluginHasPermissions(plugin);
524
+ }
525
+ return result;
526
+ }
505
527
  // Goose loads Open Plugins from $HOME/.agents/plugins/<name>/ (same layout as
506
528
  // agents-cli's source tree). Under the shim HOME is the version home.
507
529
  if (agent === 'goose') {
@@ -901,6 +923,82 @@ export function removeOpenCodePlugin(pluginName, versionHome) {
901
923
  }
902
924
  return removed;
903
925
  }
926
+ // ─── Gemini extensions ───────────────────────────────────────────────────────
927
+ /**
928
+ * Gemini CLI extensions live under `$HOME/.gemini/extensions/<name>/` and
929
+ * require a `gemini-extension.json` manifest at the extension root.
930
+ */
931
+ export function geminiExtensionsDir(versionHome) {
932
+ return path.join(versionHome, '.gemini', 'extensions');
933
+ }
934
+ function readPluginMcpConfigForGemini(pluginRoot) {
935
+ const mcpPath = path.join(pluginRoot, '.mcp.json');
936
+ if (!fs.existsSync(mcpPath))
937
+ return undefined;
938
+ try {
939
+ const parsed = JSON.parse(fs.readFileSync(mcpPath, 'utf-8'));
940
+ if (!parsed.mcpServers || typeof parsed.mcpServers !== 'object')
941
+ return undefined;
942
+ return rewriteGeminiExtensionVars(parsed.mcpServers);
943
+ }
944
+ catch {
945
+ return undefined;
946
+ }
947
+ }
948
+ function rewriteGeminiExtensionVars(value) {
949
+ if (typeof value === 'string') {
950
+ return value
951
+ .replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, '${extensionPath}')
952
+ .replace(/\$\{CLAUDE_PLUGIN_DATA\}/g, '${extensionPath}/.data');
953
+ }
954
+ if (Array.isArray(value))
955
+ return value.map(rewriteGeminiExtensionVars);
956
+ if (value && typeof value === 'object') {
957
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, rewriteGeminiExtensionVars(item)]));
958
+ }
959
+ return value;
960
+ }
961
+ function writeGeminiExtensionManifest(plugin, destRoot) {
962
+ const manifest = {
963
+ name: plugin.manifest.name,
964
+ version: plugin.manifest.version,
965
+ description: plugin.manifest.description,
966
+ };
967
+ const mcpServers = readPluginMcpConfigForGemini(destRoot);
968
+ if (mcpServers && Object.keys(mcpServers).length > 0) {
969
+ manifest.mcpServers = mcpServers;
970
+ }
971
+ fs.writeFileSync(path.join(destRoot, GEMINI_EXTENSION_MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`, 'utf-8');
972
+ }
973
+ export function installGeminiPlugin(plugin, versionHome) {
974
+ const destRoot = path.join(geminiExtensionsDir(versionHome), plugin.name);
975
+ try {
976
+ if (fs.existsSync(destRoot)) {
977
+ fs.rmSync(destRoot, { recursive: true, force: true });
978
+ }
979
+ fs.cpSync(plugin.root, destRoot, { recursive: true });
980
+ const userConfig = loadUserConfig(plugin.name);
981
+ if (Object.keys(userConfig).length > 0) {
982
+ expandUserConfigInDir(destRoot, userConfig);
983
+ }
984
+ writeGeminiExtensionManifest(plugin, destRoot);
985
+ fs.writeFileSync(path.join(destRoot, '.agents-cli-managed'), `plugin=${plugin.name}\n`, 'utf-8');
986
+ return true;
987
+ }
988
+ catch {
989
+ return false;
990
+ }
991
+ }
992
+ export function isGeminiPluginInstalled(pluginName, versionHome) {
993
+ return fs.existsSync(path.join(geminiExtensionsDir(versionHome), pluginName, GEMINI_EXTENSION_MANIFEST_FILE));
994
+ }
995
+ export function removeGeminiPlugin(pluginName, versionHome) {
996
+ const destRoot = path.join(geminiExtensionsDir(versionHome), pluginName);
997
+ if (!fs.existsSync(destRoot))
998
+ return false;
999
+ fs.rmSync(destRoot, { recursive: true, force: true });
1000
+ return true;
1001
+ }
904
1002
  // ─── Goose plugins (Open Plugins under .agents/plugins/) ─────────────────────
905
1003
  /**
906
1004
  * Goose auto-discovers Open Plugins at `$HOME/.agents/plugins/<name>/`.
@@ -949,6 +1047,9 @@ export function isPluginSynced(plugin, agent, versionHome) {
949
1047
  if (agent === 'opencode') {
950
1048
  return isOpenCodePluginInstalled(plugin.name, versionHome);
951
1049
  }
1050
+ if (agent === 'gemini') {
1051
+ return isGeminiPluginInstalled(plugin.name, versionHome);
1052
+ }
952
1053
  if (agent === 'goose') {
953
1054
  return isGoosePluginInstalled(plugin.name, versionHome);
954
1055
  }
@@ -986,6 +1087,13 @@ export function removePluginFromVersion(pluginName, pluginRoot, agent, versionHo
986
1087
  }
987
1088
  return result;
988
1089
  }
1090
+ // Gemini: remove extension directory from ~/.gemini/extensions/.
1091
+ if (agent === 'gemini') {
1092
+ if (removeGeminiPlugin(pluginName, versionHome)) {
1093
+ result.skills.push(pluginName);
1094
+ }
1095
+ return result;
1096
+ }
989
1097
  // Goose: remove Open Plugin directory from versionHome/.agents/plugins/.
990
1098
  if (agent === 'goose') {
991
1099
  if (removeGoosePlugin(pluginName, versionHome)) {
@@ -16,6 +16,7 @@ import * as path from 'path';
16
16
  import * as fs from 'fs';
17
17
  import { readMeta, updateMeta } from './state.js';
18
18
  import { getMainRepoRoot } from './git.js';
19
+ import { toPosix } from './platform/index.js';
19
20
  const HOME = process.env.HOME ?? os.homedir();
20
21
  /** Rewrite an absolute path under the local home to a `~/`-relative string; pass others through. */
21
22
  export function toHomeRelative(abs) {
@@ -23,7 +24,7 @@ export function toHomeRelative(abs) {
23
24
  if (rel === '')
24
25
  return '~';
25
26
  if (!rel.startsWith('..') && !path.isAbsolute(rel))
26
- return `~/${rel}`;
27
+ return `~/${toPosix(rel)}`;
27
28
  return abs;
28
29
  }
29
30
  /** Expand a leading `~`/`$HOME` against the LOCAL home. Other paths pass through unchanged. */
@@ -111,7 +111,7 @@ export function getMcpConfigPath(agent, versionHome) {
111
111
  case 'codex':
112
112
  return path.join(versionHome, '.codex', 'config.toml');
113
113
  case 'opencode':
114
- return path.join(versionHome, '.opencode', 'opencode.jsonc');
114
+ return path.join(versionHome, '.config', 'opencode', 'opencode.jsonc');
115
115
  case 'cursor':
116
116
  return path.join(versionHome, '.cursor', 'mcp.json');
117
117
  case 'gemini':
@@ -4,7 +4,7 @@
4
4
  * Permissions are stored as YAML files in permissions/ directories at each layer.
5
5
  * Resolution: project > user > system (higher layer wins on name conflict).
6
6
  * Unlike other resources, permissions merge into agent-specific config files
7
- * (Claude: settings.json, Codex: config.toml, OpenCode: opencode.jsonc).
7
+ * (Claude/Gemini: settings.json, Codex: config.toml, OpenCode: opencode.jsonc).
8
8
  */
9
9
  import type { ResourceHandler } from './types.js';
10
10
  import type { PermissionSet } from '../types.js';
@@ -4,7 +4,7 @@
4
4
  * Permissions are stored as YAML files in permissions/ directories at each layer.
5
5
  * Resolution: project > user > system (higher layer wins on name conflict).
6
6
  * Unlike other resources, permissions merge into agent-specific config files
7
- * (Claude: settings.json, Codex: config.toml, OpenCode: opencode.jsonc).
7
+ * (Claude/Gemini: settings.json, Codex: config.toml, OpenCode: opencode.jsonc).
8
8
  */
9
9
  import * as fs from 'fs';
10
10
  import * as path from 'path';
@@ -65,12 +65,18 @@ function getAgentConfigPath(agent, versionHome) {
65
65
  return path.join(versionHome, '.claude', 'settings.json');
66
66
  case 'codex':
67
67
  return path.join(versionHome, '.codex', 'config.toml');
68
+ case 'gemini':
69
+ return path.join(versionHome, '.gemini', 'settings.json');
68
70
  case 'opencode':
69
- return path.join(versionHome, '.opencode', 'opencode.jsonc');
71
+ return path.join(versionHome, '.config', 'opencode', 'opencode.jsonc');
70
72
  case 'kimi':
71
73
  return path.join(versionHome, '.kimi-code', 'config.toml');
74
+ case 'droid':
75
+ return path.join(versionHome, '.factory', 'settings.json');
72
76
  case 'kiro':
73
77
  return path.join(versionHome, '.kiro', 'settings', 'permissions.yaml');
78
+ case 'goose':
79
+ return path.join(versionHome, '.config', 'goose', 'permission.yaml');
74
80
  default:
75
81
  return null;
76
82
  }
@@ -5,7 +5,7 @@
5
5
  * Format is the same for all agents. Resolution order: project > user > system.
6
6
  */
7
7
  import * as fs from 'fs';
8
- import { agentConfigDirName } from '../agents.js';
8
+ import { AGENTS, agentConfigDirName } from '../agents.js';
9
9
  import * as path from 'path';
10
10
  import * as yaml from 'yaml';
11
11
  import { getSystemSkillsDir, getUserSkillsDir, getProjectAgentsDir, getEnabledExtraRepos, } from '../state.js';
@@ -201,6 +201,11 @@ export function createSkillsHandler(provider = defaultProvider) {
201
201
  return null;
202
202
  },
203
203
  sync(agent, versionHome, cwd) {
204
+ // Agents that read directly from central ~/.agents/skills/ (e.g. Gemini,
205
+ // Goose via the Summon extension) should not get a per-version copy.
206
+ if (AGENTS[agent]?.nativeAgentsSkillsDir) {
207
+ return;
208
+ }
204
209
  const targetDir = path.join(versionHome, agentConfigDirName(agent), 'skills');
205
210
  // Ensure target directory exists
206
211
  if (!fs.existsSync(targetDir)) {
@@ -5,7 +5,7 @@
5
5
  * - Union: All resources from all layers are combined
6
6
  * - Override on name conflict: Higher layer wins (project > user > system)
7
7
  */
8
- export type AgentId = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode' | 'openclaw' | 'kiro' | 'antigravity' | 'grok' | 'kimi' | 'hermes' | 'forge';
8
+ export type AgentId = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode' | 'openclaw' | 'kiro' | 'goose' | 'antigravity' | 'grok' | 'kimi' | 'droid' | 'hermes' | 'forge';
9
9
  export type Layer = 'system' | 'user' | 'project';
10
10
  export type ResourceKind = 'command' | 'hook' | 'skill' | 'rule' | 'mcp' | 'permission' | 'subagent' | 'workflow' | 'memory';
11
11
  /** A resolved resource with its origin layer. */
@@ -138,6 +138,22 @@ export declare function readJob(name: string, cwd?: string): JobConfig | null;
138
138
  * name, the write fails explicitly so we never choose or drop a sibling.
139
139
  */
140
140
  export declare function writeJob(config: JobConfig): void;
141
+ /**
142
+ * Serialize a job config, preserving the on-disk formatting of an existing file.
143
+ *
144
+ * A full `yaml.stringify(config)` re-emits the whole document — restyling every
145
+ * scalar (unquoting `schedule`, re-wrapping the folded `prompt` block, reordering
146
+ * keys). When a routine is only being toggled (pause/resume) or re-pinned
147
+ * (`devices --set`), that rewrites the entire file, leaving the git-backed
148
+ * `~/.agents` tree perpetually dirty so `agents repo pull` refuses to sync across
149
+ * the fleet. To keep the diff to the field that actually changed, we edit the
150
+ * existing document in place and only re-render touched nodes; untouched nodes
151
+ * (notably the large `prompt` block) keep their byte-for-byte formatting.
152
+ *
153
+ * `existingText` is the current file contents, or null for a new file. New,
154
+ * unparseable, and non-mapping documents fall back to canonical `yaml.stringify`.
155
+ */
156
+ export declare function serializeJob(output: Record<string, unknown>, existingText: string | null): string;
141
157
  /** Delete a job config file by name. Returns true if the file existed. */
142
158
  export declare function deleteJob(name: string): boolean;
143
159
  /** Enable or disable a job by name. */