@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
@@ -236,7 +236,7 @@ export const AGENTS = {
236
236
  cloudProvider: 'codex',
237
237
  // Subagents: multi-agent plumbing since 0.117.0; custom agents as
238
238
  // ~/.codex/agents/*.toml (name, description, developer_instructions).
239
- capabilities: { hooks: { since: '0.116.0' }, mcp: true, mcpHttp: true, mcpHeaders: false, allowlist: false, skills: true, commands: { until: '0.117.0' }, plugins: { since: '0.128.0' }, subagents: { since: '0.117.0' }, rules: { file: 'AGENTS.md' }, workflows: false, memory: true, modes: ['plan', 'edit', 'skip'] },
239
+ capabilities: { hooks: { since: '0.116.0' }, mcp: true, mcpHttp: true, mcpHeaders: false, allowlist: { since: '0.138.0' }, skills: true, commands: { until: '0.117.0' }, plugins: { since: '0.128.0' }, subagents: { since: '0.117.0' }, rules: { file: 'AGENTS.md' }, workflows: false, memory: true, modes: ['plan', 'edit', 'skip'] },
240
240
  },
241
241
  gemini: {
242
242
  id: 'gemini',
@@ -265,7 +265,8 @@ export const AGENTS = {
265
265
  url: 'https://developers.googleblog.com/an-important-update-transitioning-gemini-cli-to-antigravity-cli/',
266
266
  },
267
267
  // gemini hooks: shipped in v0.26.0 (Jan 2026); older binaries silently ignore the `hooks` key.
268
- capabilities: { hooks: { since: '0.26.0' }, mcp: true, mcpHttp: true, mcpHeaders: false, allowlist: false, skills: true, commands: true, plugins: false, subagents: false, rules: { file: 'GEMINI.md' }, workflows: false, memory: false, modes: ['plan', 'edit', 'skip'], rulesImports: true },
268
+ // extensions: gemini-extension.json bundles shipped in v0.8.0; custom subagents in v0.36.0.
269
+ capabilities: { hooks: { since: '0.26.0' }, mcp: true, mcpHttp: true, mcpHeaders: false, allowlist: true, skills: true, commands: true, plugins: { since: '0.8.0' }, subagents: { since: '0.36.0' }, rules: { file: 'GEMINI.md' }, workflows: false, memory: false, modes: ['plan', 'edit', 'skip'], rulesImports: true },
269
270
  },
270
271
  cursor: {
271
272
  id: 'cursor',
@@ -405,7 +406,10 @@ export const AGENTS = {
405
406
  configDir: path.join(HOME, '.config', 'goose'),
406
407
  commandsDir: path.join(HOME, '.config', 'goose', 'commands'),
407
408
  commandsSubdir: 'commands',
408
- skillsDir: path.join(HOME, '.config', 'goose', 'skills'),
409
+ // Goose reads skills directly from central ~/.agents/skills/ via the Summon
410
+ // extension (block-goose-cli ≥ 1.25.0). No per-version copy is written.
411
+ skillsDir: path.join(HOME, '.agents', 'skills'),
412
+ nativeAgentsSkillsDir: true,
409
413
  // Hooks: Open Plugins format — auto-discovered from
410
414
  // ~/.agents/plugins/<name>/hooks/hooks.json (shipped block-goose-cli
411
415
  // ≥ 1.34.0). See registerHooksForGoose.
@@ -416,7 +420,8 @@ export const AGENTS = {
416
420
  supportsHooks: true,
417
421
  // Plugins: Open Plugins under ~/.agents/plugins/<name>/ (same layout as
418
422
  // agents-cli source). Version isolation copies into versionHome/.agents/plugins/.
419
- capabilities: { hooks: { since: '1.34.0' }, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist: false, skills: false, commands: false, plugins: true, subagents: false, rules: { file: 'AGENTS.md' }, workflows: false, memory: false, modes: ['edit'] },
423
+ // Workflows sync as Goose recipe YAML; permissions sync to permission.yaml.
424
+ capabilities: { hooks: { since: '1.34.0' }, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist: true, skills: { since: '1.25.0' }, commands: false, plugins: true, subagents: false, rules: { file: 'AGENTS.md' }, workflows: true, memory: false, modes: ['edit'] },
420
425
  },
421
426
  // Google Antigravity CLI (`agy`) — official replacement for Gemini CLI as of IO 2026.
422
427
  // configDir nests inside `~/.gemini/` since agy shares the parent dir with the Gemini
@@ -445,7 +450,7 @@ export const AGENTS = {
445
450
  variableSyntax: '{{args}}',
446
451
  supportsHooks: true,
447
452
  cloudProvider: 'antigravity',
448
- capabilities: { hooks: true, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist: true, skills: true, commands: true, plugins: true, subagents: false, rules: { file: 'AGENTS.md' }, workflows: false, memory: false, modes: ['edit', 'skip'], rulesImports: false },
453
+ capabilities: { hooks: true, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist: true, skills: true, commands: true, plugins: true, subagents: { since: '1.0.16' }, rules: { file: 'AGENTS.md' }, workflows: false, memory: false, modes: ['edit', 'skip'], rulesImports: false },
449
454
  },
450
455
  // xAI Grok Build CLI (`grok`) — early beta, SuperGrok Heavy. Auth via OAuth on
451
456
  // first launch, or XAI_API_KEY env var for headless. MCP servers configured inline
@@ -519,7 +524,7 @@ export const AGENTS = {
519
524
  plugins: true,
520
525
  subagents: true, // YAML agent files under ~/.kimi-code/agents/ (see transformSubagentForKimi)
521
526
  rules: { file: 'AGENTS.md' },
522
- workflows: false,
527
+ workflows: true,
523
528
  memory: false,
524
529
  modes: ['plan', 'edit', 'auto', 'skip'],
525
530
  rulesImports: false,
@@ -560,7 +565,7 @@ export const AGENTS = {
560
565
  authFiles: ['auth.v2.file', 'auth.v2.key'],
561
566
  commandsDir: path.join(HOME, '.factory', 'commands'),
562
567
  commandsSubdir: 'commands',
563
- skillsDir: '', // no skills concept
568
+ skillsDir: path.join(HOME, '.factory', 'skills'),
564
569
  hooksDir: 'hooks',
565
570
  pluginManifestDir: '.factory-plugin',
566
571
  instructionsFile: 'AGENTS.md',
@@ -575,8 +580,8 @@ export const AGENTS = {
575
580
  mcp: true,
576
581
  mcpHttp: false,
577
582
  mcpHeaders: false,
578
- allowlist: false,
579
- skills: false,
583
+ allowlist: { since: '0.57.5' },
584
+ skills: { since: '0.26.0' },
580
585
  commands: true,
581
586
  plugins: true,
582
587
  subagents: true,
@@ -1826,8 +1831,8 @@ export function getUserMcpConfigPath(agentId) {
1826
1831
  // Codex uses TOML config
1827
1832
  return path.join(agent.configDir, 'config.toml');
1828
1833
  case 'opencode':
1829
- // OpenCode uses JSONC config
1830
- return path.join(agent.configDir, 'opencode.jsonc');
1834
+ // OpenCode loads ~/.config/opencode/opencode.jsonc (not ~/.opencode/)
1835
+ return path.join(HOME, '.config', 'opencode', 'opencode.jsonc');
1831
1836
  case 'cursor':
1832
1837
  // Cursor uses mcp.json
1833
1838
  return path.join(agent.configDir, 'mcp.json');
@@ -1865,7 +1870,7 @@ export function getMcpConfigPathForHome(agentId, home) {
1865
1870
  case 'codex':
1866
1871
  return path.join(home, '.codex', 'config.toml');
1867
1872
  case 'opencode':
1868
- return path.join(home, '.opencode', 'opencode.jsonc');
1873
+ return path.join(home, '.config', 'opencode', 'opencode.jsonc');
1869
1874
  case 'cursor':
1870
1875
  return path.join(home, '.cursor', 'mcp.json');
1871
1876
  case 'openclaw':
@@ -1903,7 +1908,8 @@ function getProjectMcpConfigPath(agentId, cwd = process.cwd()) {
1903
1908
  case 'codex':
1904
1909
  return path.join(cwd, `.${agentId}`, 'config.toml');
1905
1910
  case 'opencode':
1906
- return path.join(cwd, `.${agentId}`, 'opencode.jsonc');
1911
+ // Project config is opencode.jsonc at project root (not .opencode/)
1912
+ return path.join(cwd, 'opencode.jsonc');
1907
1913
  case 'cursor':
1908
1914
  return path.join(cwd, `.${agentId}`, 'mcp.json');
1909
1915
  case 'openclaw':
@@ -39,8 +39,26 @@ export declare function terminateDispatchedTask(task: HostTask): void;
39
39
  export interface DispatchOptions {
40
40
  agent: string;
41
41
  prompt: string;
42
+ /** Explicit agent version pin (e.g. "2.1.207") to forward as `agent@version`. */
43
+ version?: string;
44
+ /** Explicit run strategy (e.g. "balanced") to forward as `--strategy <strategy>`. */
45
+ strategy?: string;
42
46
  mode?: string;
43
47
  model?: string;
48
+ /** Reasoning effort to forward as `--effort <effort>`. */
49
+ effort?: string;
50
+ /** Additional directories to grant, already made remote-portable. */
51
+ addDir?: string[];
52
+ /** Stream events as JSON lines. */
53
+ json?: boolean;
54
+ /** Show detailed execution logs. */
55
+ verbose?: boolean;
56
+ /** Kill the agent after this duration, forwarded as `--timeout <duration>`. */
57
+ timeout?: string;
58
+ /** Skip the interactive budget-confirm prompt. */
59
+ yes?: boolean;
60
+ /** Route through the Agent Client Protocol. */
61
+ acp?: boolean;
44
62
  remoteCwd?: string;
45
63
  /**
46
64
  * Force the remote run's NEW session to use this exact id (Claude only, via
@@ -68,10 +86,28 @@ export interface DispatchOptions {
68
86
  export declare function buildRunForwardedArgs(opts: DispatchOptions): string[];
69
87
  export interface InteractiveDispatchOptions {
70
88
  agent: string;
89
+ /** Explicit agent version pin (e.g. "2.1.207") to forward as `agent@version`. */
90
+ version?: string;
91
+ /** Explicit run strategy (e.g. "balanced") to forward as `--strategy <strategy>`. */
92
+ strategy?: string;
71
93
  /** Optional prompt — forwarded only when the caller explicitly forced interactive mode. */
72
94
  prompt?: string;
73
95
  mode?: string;
74
96
  model?: string;
97
+ /** Reasoning effort to forward as `--effort <effort>`. */
98
+ effort?: string;
99
+ /** Additional directories to grant, already made remote-portable. */
100
+ addDir?: string[];
101
+ /** Stream events as JSON lines. */
102
+ json?: boolean;
103
+ /** Show detailed execution logs. */
104
+ verbose?: boolean;
105
+ /** Kill the agent after this duration, forwarded as `--timeout <duration>`. */
106
+ timeout?: string;
107
+ /** Skip the interactive budget-confirm prompt. */
108
+ yes?: boolean;
109
+ /** Route through the Agent Client Protocol. */
110
+ acp?: boolean;
75
111
  remoteCwd?: string;
76
112
  sessionId?: string;
77
113
  name?: string;
@@ -175,11 +175,30 @@ async function launchDetached(host, target, opts) {
175
175
  * resume wins when — defensively — both are set.
176
176
  */
177
177
  export function buildRunForwardedArgs(opts) {
178
- const args = ['run', opts.agent, opts.prompt, '--quiet'];
178
+ const agentArg = opts.version ? `${opts.agent}@${opts.version}` : opts.agent;
179
+ const args = ['run', agentArg, opts.prompt, '--quiet'];
180
+ if (opts.strategy)
181
+ args.push('--strategy', opts.strategy);
179
182
  if (opts.mode)
180
183
  args.push('--mode', opts.mode);
181
184
  if (opts.model)
182
185
  args.push('--model', opts.model);
186
+ if (opts.effort)
187
+ args.push('--effort', opts.effort);
188
+ if (opts.addDir) {
189
+ for (const dir of opts.addDir)
190
+ args.push('--add-dir', dir);
191
+ }
192
+ if (opts.json)
193
+ args.push('--json');
194
+ if (opts.verbose)
195
+ args.push('--verbose');
196
+ if (opts.timeout)
197
+ args.push('--timeout', opts.timeout);
198
+ if (opts.yes)
199
+ args.push('--yes');
200
+ if (opts.acp)
201
+ args.push('--acp');
183
202
  if (opts.name)
184
203
  args.push('--name', opts.name);
185
204
  if (opts.resume)
@@ -196,15 +215,34 @@ export function buildRunForwardedArgs(opts) {
196
215
  * infer headless from the prompt).
197
216
  */
198
217
  export function buildInteractiveRunForwardedArgs(opts) {
199
- const args = ['run', opts.agent];
218
+ const agentArg = opts.version ? `${opts.agent}@${opts.version}` : opts.agent;
219
+ const args = ['run', agentArg];
200
220
  if (opts.prompt && opts.forceInteractive)
201
221
  args.push(opts.prompt);
202
222
  if (opts.forceInteractive)
203
223
  args.push('--interactive');
224
+ if (opts.strategy)
225
+ args.push('--strategy', opts.strategy);
204
226
  if (opts.mode)
205
227
  args.push('--mode', opts.mode);
206
228
  if (opts.model)
207
229
  args.push('--model', opts.model);
230
+ if (opts.effort)
231
+ args.push('--effort', opts.effort);
232
+ if (opts.addDir) {
233
+ for (const dir of opts.addDir)
234
+ args.push('--add-dir', dir);
235
+ }
236
+ if (opts.json)
237
+ args.push('--json');
238
+ if (opts.verbose)
239
+ args.push('--verbose');
240
+ if (opts.timeout)
241
+ args.push('--timeout', opts.timeout);
242
+ if (opts.yes)
243
+ args.push('--yes');
244
+ if (opts.acp)
245
+ args.push('--acp');
208
246
  if (opts.name)
209
247
  args.push('--name', opts.name);
210
248
  if (opts.resume)
@@ -28,6 +28,7 @@ const REMOTE_PASSTHROUGH = {
28
28
  view: {},
29
29
  usage: {},
30
30
  cost: {},
31
+ output: {},
31
32
  doctor: {},
32
33
  inspect: {},
33
34
  list: {},
package/dist/lib/mcp.js CHANGED
@@ -485,7 +485,7 @@ function installMcpToForgeConfig(server, versionHome) {
485
485
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
486
486
  }
487
487
  function installMcpToOpenCodeConfig(server, versionHome) {
488
- const configPath = path.join(versionHome, '.opencode', 'opencode.jsonc');
488
+ const configPath = path.join(versionHome, '.config', 'opencode', 'opencode.jsonc');
489
489
  let config = {};
490
490
  if (fs.existsSync(configPath)) {
491
491
  const content = fs.readFileSync(configPath, 'utf-8');
@@ -0,0 +1,74 @@
1
+ /** Commit tally for one author email. */
2
+ export interface AuthorCommits {
3
+ author: string;
4
+ commits: number;
5
+ }
6
+ /** The shipped-work rollup for a window. */
7
+ export interface GitOutputSummary {
8
+ reposScanned: number;
9
+ commits: number;
10
+ byAuthor: AuthorCommits[];
11
+ prsOpened: number;
12
+ prsMerged: number;
13
+ /**
14
+ * Deduped commit SHAs authored by us in the window. Carried so `--all-hosts`
15
+ * can UNION across machines — a repo cloned on several boxes exposes the same
16
+ * commits to `git log` on each, so summing counts would multi-count.
17
+ */
18
+ commitShas: string[];
19
+ /** false when `gh` is missing/unauthed — PR counts are then 0 and not trustworthy. */
20
+ ghAvailable: boolean;
21
+ /** Author emails counted as "ours". */
22
+ authors: string[];
23
+ /** gh logins queried for PRs. */
24
+ logins: string[];
25
+ sinceIso: string;
26
+ }
27
+ export interface GitOutputOptions {
28
+ /** Root scanned for git repos (e.g. ~/src). */
29
+ reposDir: string;
30
+ /** Window start, epoch ms. */
31
+ sinceMs: number;
32
+ /** Restrict commit authorship to these emails; default: identities discovered from git config. */
33
+ authors?: string[];
34
+ /** gh logins to search PRs for; default: the current `gh api user` login. */
35
+ logins?: string[];
36
+ /** Repo discovery depth below reposDir (default 4 — covers ~/src/host/org/repo). */
37
+ maxDepth?: number;
38
+ /** Query PRs via gh (default true). */
39
+ includePrs?: boolean;
40
+ }
41
+ /**
42
+ * Find git repositories under `root` up to `maxDepth` levels deep. A directory
43
+ * with a `.git` entry is a repo and is NOT descended into (so nested worktrees
44
+ * / submodules don't double-count).
45
+ */
46
+ export declare function findGitRepos(root: string, maxDepth?: number): string[];
47
+ /**
48
+ * Count commits by our authors across all repos, deduped by SHA (so the same
49
+ * commit reachable via multiple repo clones/worktrees on this machine — or, at
50
+ * the fleet layer, across machines — is counted once), tallied per email.
51
+ */
52
+ export declare function collectCommits(repos: string[], sinceIso: string, authors: string[]): Promise<{
53
+ total: number;
54
+ byAuthor: AuthorCommits[];
55
+ shas: string[];
56
+ }>;
57
+ /**
58
+ * Count PRs opened and merged in the window across the given logins. `gh search
59
+ * prs` searches all of GitHub, so one authed gh can cover multiple accounts'
60
+ * logins. Returns ghAvailable=false if gh can't be reached at all.
61
+ */
62
+ export declare function collectPrs(logins: string[], sinceDate: string): Promise<{
63
+ opened: number;
64
+ merged: number;
65
+ logins: string[];
66
+ ghAvailable: boolean;
67
+ }>;
68
+ /** Format an epoch-ms as a YYYY-MM-DD date (UTC), for gh's date-range search. */
69
+ export declare function toSearchDate(ms: number): string;
70
+ /**
71
+ * Collect the full shipped-work summary for a window: commits (across accounts)
72
+ * plus PRs opened/merged.
73
+ */
74
+ export declare function collectGitOutput(options: GitOutputOptions): Promise<GitOutputSummary>;
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Git / GitHub "output" collector — the shipped-work half of `agents output`.
3
+ *
4
+ * `agents cost` answers "what did we burn?"; this answers "what did we ship?".
5
+ * It counts commits (across every author identity, so multi-account totals stay
6
+ * correct regardless of which `gh` login is active) and PRs opened / merged in a
7
+ * time window. Pure `git`/`gh` over child_process — no server, no telemetry,
8
+ * mirroring the offline spirit of the cost rollup.
9
+ */
10
+ import { execFile } from 'child_process';
11
+ import { promisify } from 'util';
12
+ import * as fs from 'fs';
13
+ import * as os from 'os';
14
+ import * as path from 'path';
15
+ const execFileAsync = promisify(execFile);
16
+ /** Directory names never descended into during repo discovery. */
17
+ const SKIP_DIRS = new Set(['node_modules', '.git', '.agents', '.worktrees', 'dist', 'build', '.next', '.cache']);
18
+ /**
19
+ * Find git repositories under `root` up to `maxDepth` levels deep. A directory
20
+ * with a `.git` entry is a repo and is NOT descended into (so nested worktrees
21
+ * / submodules don't double-count).
22
+ */
23
+ export function findGitRepos(root, maxDepth = 4) {
24
+ const repos = [];
25
+ const walk = (dir, depth) => {
26
+ let entries;
27
+ try {
28
+ entries = fs.readdirSync(dir, { withFileTypes: true });
29
+ }
30
+ catch {
31
+ return; // unreadable dir — skip
32
+ }
33
+ if (entries.some(e => e.name === '.git')) {
34
+ repos.push(dir);
35
+ return; // don't descend into a repo
36
+ }
37
+ if (depth >= maxDepth)
38
+ return;
39
+ for (const e of entries) {
40
+ if (!e.isDirectory() || e.name.startsWith('.') || SKIP_DIRS.has(e.name))
41
+ continue;
42
+ walk(path.join(dir, e.name), depth + 1);
43
+ }
44
+ };
45
+ walk(root, 0);
46
+ return repos;
47
+ }
48
+ /** git log for one repo (SHA + author email per commit), tolerant of empty/broken repos. */
49
+ async function repoLog(repoDir, sinceIso) {
50
+ try {
51
+ const { stdout } = await execFileAsync('git', ['-C', repoDir, 'log', '--all', '--no-merges', `--since=${sinceIso}`, '--pretty=format:%H%x09%ae'], { maxBuffer: 64 * 1024 * 1024 });
52
+ const refs = [];
53
+ for (const line of stdout.split('\n')) {
54
+ const tab = line.indexOf('\t');
55
+ if (tab < 0)
56
+ continue;
57
+ refs.push({ sha: line.slice(0, tab).trim(), email: line.slice(tab + 1).trim() });
58
+ }
59
+ return refs;
60
+ }
61
+ catch {
62
+ return []; // no commits yet / not a real repo / detached weirdness
63
+ }
64
+ }
65
+ /**
66
+ * Discover the user's own author emails so commit counts exclude teammates.
67
+ * Union of `git config --global user.email` and each repo's local `user.email`.
68
+ */
69
+ async function discoverAuthorEmails(repos) {
70
+ const emails = new Set();
71
+ try {
72
+ const { stdout } = await execFileAsync('git', ['config', '--global', 'user.email']);
73
+ const e = stdout.trim();
74
+ if (e)
75
+ emails.add(e.toLowerCase());
76
+ }
77
+ catch {
78
+ /* no global identity */
79
+ }
80
+ await Promise.all(repos.map(async (repo) => {
81
+ try {
82
+ const { stdout } = await execFileAsync('git', ['-C', repo, 'config', '--get', 'user.email']);
83
+ const e = stdout.trim();
84
+ if (e)
85
+ emails.add(e.toLowerCase());
86
+ }
87
+ catch {
88
+ /* repo has no local identity */
89
+ }
90
+ }));
91
+ return [...emails];
92
+ }
93
+ /**
94
+ * Count commits by our authors across all repos, deduped by SHA (so the same
95
+ * commit reachable via multiple repo clones/worktrees on this machine — or, at
96
+ * the fleet layer, across machines — is counted once), tallied per email.
97
+ */
98
+ export async function collectCommits(repos, sinceIso, authors) {
99
+ const ours = new Set(authors.map(a => a.toLowerCase()));
100
+ const tally = new Map();
101
+ const seen = new Set();
102
+ const logs = await Promise.all(repos.map(r => repoLog(r, sinceIso)));
103
+ for (const refs of logs) {
104
+ for (const { sha, email } of refs) {
105
+ const key = email.toLowerCase();
106
+ if (ours.size > 0 && !ours.has(key))
107
+ continue;
108
+ if (seen.has(sha))
109
+ continue; // same commit seen via another clone/ref
110
+ seen.add(sha);
111
+ tally.set(key, (tally.get(key) ?? 0) + 1);
112
+ }
113
+ }
114
+ const byAuthor = [...tally.entries()]
115
+ .map(([author, commits]) => ({ author, commits }))
116
+ .sort((a, b) => b.commits - a.commits);
117
+ return { total: seen.size, byAuthor, shas: [...seen] };
118
+ }
119
+ /** Resolve the current gh login, or null if gh is unavailable/unauthed. */
120
+ async function currentGhLogin() {
121
+ try {
122
+ const { stdout } = await execFileAsync('gh', ['api', 'user', '--jq', '.login']);
123
+ const login = stdout.trim();
124
+ return login || null;
125
+ }
126
+ catch {
127
+ return null;
128
+ }
129
+ }
130
+ /** Count PRs matching a gh search query (author + date), returning null on failure. */
131
+ async function ghSearchCount(args) {
132
+ try {
133
+ const { stdout } = await execFileAsync('gh', [...args, '--limit', '1000', '--json', 'number'], {
134
+ maxBuffer: 32 * 1024 * 1024,
135
+ });
136
+ const rows = JSON.parse(stdout);
137
+ return Array.isArray(rows) ? rows.length : 0;
138
+ }
139
+ catch {
140
+ return null;
141
+ }
142
+ }
143
+ /**
144
+ * Count PRs opened and merged in the window across the given logins. `gh search
145
+ * prs` searches all of GitHub, so one authed gh can cover multiple accounts'
146
+ * logins. Returns ghAvailable=false if gh can't be reached at all.
147
+ */
148
+ export async function collectPrs(logins, sinceDate) {
149
+ let resolved = logins;
150
+ if (resolved.length === 0) {
151
+ const login = await currentGhLogin();
152
+ if (!login)
153
+ return { opened: 0, merged: 0, logins: [], ghAvailable: false };
154
+ resolved = [login];
155
+ }
156
+ let opened = 0;
157
+ let merged = 0;
158
+ let anyOk = false;
159
+ for (const login of resolved) {
160
+ const o = await ghSearchCount(['search', 'prs', '--author', login, '--created', `>=${sinceDate}`]);
161
+ const m = await ghSearchCount(['search', 'prs', '--author', login, '--merged', `>=${sinceDate}`]);
162
+ if (o !== null) {
163
+ opened += o;
164
+ anyOk = true;
165
+ }
166
+ if (m !== null) {
167
+ merged += m;
168
+ anyOk = true;
169
+ }
170
+ }
171
+ return { opened, merged, logins: resolved, ghAvailable: anyOk };
172
+ }
173
+ /** Format an epoch-ms as a YYYY-MM-DD date (UTC), for gh's date-range search. */
174
+ export function toSearchDate(ms) {
175
+ return new Date(ms).toISOString().slice(0, 10);
176
+ }
177
+ /**
178
+ * Collect the full shipped-work summary for a window: commits (across accounts)
179
+ * plus PRs opened/merged.
180
+ */
181
+ export async function collectGitOutput(options) {
182
+ const reposDir = options.reposDir.replace(/^~(?=$|\/)/, os.homedir());
183
+ const sinceIso = new Date(options.sinceMs).toISOString();
184
+ const sinceDate = toSearchDate(options.sinceMs);
185
+ const repos = findGitRepos(reposDir, options.maxDepth ?? 4);
186
+ const authors = options.authors && options.authors.length > 0
187
+ ? options.authors.map(a => a.toLowerCase())
188
+ : await discoverAuthorEmails(repos);
189
+ const { total: commits, byAuthor, shas: commitShas } = await collectCommits(repos, sinceIso, authors);
190
+ let prsOpened = 0;
191
+ let prsMerged = 0;
192
+ let ghAvailable = false;
193
+ let logins = options.logins ?? [];
194
+ if (options.includePrs !== false) {
195
+ const prs = await collectPrs(logins, sinceDate);
196
+ prsOpened = prs.opened;
197
+ prsMerged = prs.merged;
198
+ ghAvailable = prs.ghAvailable;
199
+ logins = prs.logins;
200
+ }
201
+ return {
202
+ reposScanned: repos.length,
203
+ commits,
204
+ byAuthor,
205
+ commitShas,
206
+ prsOpened,
207
+ prsMerged,
208
+ ghAvailable,
209
+ authors,
210
+ logins,
211
+ sinceIso,
212
+ };
213
+ }
@@ -106,17 +106,22 @@ export declare function convertToClaudeFormat(set: PermissionSet): ClaudePermiss
106
106
  export declare function convertToCursorFormat(set: PermissionSet): CursorPermissions;
107
107
  /**
108
108
  * Convert canonical permission set to Gemini format.
109
- * Gemini reads tool allow-lists from settings.json under `tools.allowed`.
110
- * Bash permissions map to run_shell_command(prefix) — the prefix is extracted
111
- * from the canonical "Bash(cmd:*)" pattern by stripping the trailing ":*".
112
- * Blanket Bash grants map to bare "run_shell_command" (no prefix filter).
109
+ * Gemini reads tool allow/deny lists from settings.json under
110
+ * `tools.core` / `tools.exclude`. Bash permissions map to ShellTool(pattern).
111
+ * Blanket Bash grants map to bare "ShellTool" (no command filter).
113
112
  * Non-Bash tool patterns are skipped; Gemini uses different tool names.
114
113
  */
115
114
  export declare function convertToGeminiFormat(set: PermissionSet): {
116
115
  tools: {
117
- allowed: string[];
116
+ core: string[];
117
+ exclude?: string[];
118
118
  };
119
119
  };
120
+ /** Convert canonical Bash rules into Droid's command arrays. */
121
+ export declare function convertToDroidFormat(set: PermissionSet): {
122
+ commandAllowlist: string[];
123
+ commandDenylist: string[];
124
+ };
120
125
  /**
121
126
  * Convert canonical permission set to Antigravity format.
122
127
  * Antigravity reads ~/.gemini/antigravity-cli/settings.json with
@@ -135,6 +140,15 @@ export declare function convertToAntigravityFormat(set: PermissionSet): {
135
140
  deny?: string[];
136
141
  };
137
142
  };
143
+ export interface GoosePermissionConfig {
144
+ user: {
145
+ always_allow: string[];
146
+ ask_before: string[];
147
+ never_allow: string[];
148
+ };
149
+ }
150
+ /** Convert canonical permissions to Goose's per-tool permission.yaml shape. */
151
+ export declare function convertToGooseFormat(set: PermissionSet): GoosePermissionConfig;
138
152
  /**
139
153
  * Convert canonical permission set to Grok format.
140
154
  * Grok reads ~/.grok/config.toml with
@@ -212,6 +226,13 @@ export declare function applyClaudePermissions(set: PermissionSet, scope?: 'user
212
226
  success: boolean;
213
227
  error?: string;
214
228
  };
229
+ /**
230
+ * Path OpenCode actually loads for global config:
231
+ * ~/.config/opencode/opencode.jsonc (or .json)
232
+ * Project: <cwd>/opencode.jsonc (or .json) at project root — not .opencode/.
233
+ * See https://opencode.ai/docs/config/
234
+ */
235
+ export declare function openCodeConfigPath(scope: 'user' | 'project', cwd?: string, home?: string): string;
215
236
  /**
216
237
  * Apply a permission set to a specific version's home directory.
217
238
  * This writes to {versionHome}/.{agent}/settings.json (or equivalent).