@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.
- package/CHANGELOG.md +23 -1
- package/README.md +15 -7
- package/dist/bin/agents +0 -0
- package/dist/commands/exec.js +39 -2
- package/dist/commands/output.d.ts +19 -0
- package/dist/commands/output.js +333 -0
- package/dist/commands/secrets.js +6 -6
- package/dist/index.js +2 -1
- package/dist/lib/agents.js +19 -13
- package/dist/lib/hosts/dispatch.d.ts +36 -0
- package/dist/lib/hosts/dispatch.js +40 -2
- package/dist/lib/hosts/passthrough.js +1 -0
- package/dist/lib/mcp.js +1 -1
- package/dist/lib/output/git-output.d.ts +74 -0
- package/dist/lib/output/git-output.js +213 -0
- package/dist/lib/permissions.d.ts +26 -5
- package/dist/lib/permissions.js +212 -37
- package/dist/lib/plugins.d.ts +8 -0
- package/dist/lib/plugins.js +108 -0
- package/dist/lib/project-root.js +2 -1
- package/dist/lib/resources/mcp.js +1 -1
- package/dist/lib/resources/permissions.d.ts +1 -1
- package/dist/lib/resources/permissions.js +8 -2
- package/dist/lib/resources/skills.js +6 -1
- package/dist/lib/resources/types.d.ts +1 -1
- package/dist/lib/routines.d.ts +16 -0
- package/dist/lib/routines.js +46 -1
- package/dist/lib/secrets/remote.d.ts +7 -2
- package/dist/lib/secrets/remote.js +11 -10
- package/dist/lib/session/db.d.ts +3 -0
- package/dist/lib/session/db.js +20 -4
- package/dist/lib/session/discover.d.ts +2 -0
- package/dist/lib/session/discover.js +40 -4
- package/dist/lib/session/types.d.ts +2 -0
- package/dist/lib/shims.js +13 -3
- package/dist/lib/skills.js +14 -1
- package/dist/lib/staleness/detectors/permissions.js +50 -3
- package/dist/lib/staleness/detectors/subagents.js +31 -12
- package/dist/lib/staleness/detectors/workflows.js +33 -0
- package/dist/lib/staleness/writers/commands.js +3 -3
- package/dist/lib/staleness/writers/subagents.js +13 -5
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/subagents.d.ts +11 -1
- package/dist/lib/subagents.js +117 -26
- package/dist/lib/versions.js +12 -2
- package/dist/lib/workflows.d.ts +5 -3
- package/dist/lib/workflows.js +246 -9
- package/package.json +1 -1
package/dist/lib/agents.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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: '',
|
|
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:
|
|
579
|
-
skills:
|
|
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
|
|
1830
|
-
return path.join(
|
|
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
|
-
|
|
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
|
|
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
|
|
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)
|
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
|
|
110
|
-
* Bash permissions map to
|
|
111
|
-
*
|
|
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
|
-
|
|
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).
|