@phnx-labs/agents-cli 1.20.57 → 1.20.59
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 +30 -0
- package/README.md +40 -4
- package/dist/bin/agents +0 -0
- package/dist/commands/defaults.js +24 -0
- package/dist/commands/exec.js +29 -5
- package/dist/commands/output.d.ts +19 -0
- package/dist/commands/output.js +333 -0
- package/dist/commands/secrets.js +34 -25
- package/dist/commands/versions.js +11 -3
- package/dist/commands/view.js +19 -4
- package/dist/index.js +2 -1
- package/dist/lib/agents.d.ts +21 -0
- package/dist/lib/agents.js +42 -14
- package/dist/lib/daemon.d.ts +5 -5
- package/dist/lib/daemon.js +65 -17
- package/dist/lib/git.d.ts +9 -0
- package/dist/lib/git.js +12 -0
- package/dist/lib/hosts/dispatch.d.ts +21 -0
- package/dist/lib/hosts/dispatch.js +88 -5
- 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 +31 -1
- package/dist/lib/permissions.js +210 -9
- package/dist/lib/project-root.d.ts +65 -0
- package/dist/lib/project-root.js +134 -0
- package/dist/lib/resources/mcp.js +1 -1
- package/dist/lib/resources/permissions.js +5 -1
- package/dist/lib/resources/skills.js +6 -1
- package/dist/lib/resources/types.d.ts +1 -1
- package/dist/lib/secrets/agent.d.ts +26 -23
- package/dist/lib/secrets/agent.js +196 -216
- package/dist/lib/secrets/remote.d.ts +7 -2
- package/dist/lib/secrets/remote.js +12 -10
- package/dist/lib/session/active.d.ts +3 -0
- package/dist/lib/session/active.js +1 -0
- 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/parse.js +38 -15
- package/dist/lib/session/state.d.ts +4 -1
- package/dist/lib/session/state.js +18 -1
- package/dist/lib/session/types.d.ts +10 -0
- package/dist/lib/staleness/detectors/permissions.js +64 -1
- package/dist/lib/staleness/detectors/subagents.js +30 -0
- package/dist/lib/staleness/writers/commands.js +3 -3
- package/dist/lib/staleness/writers/subagents.js +13 -1
- 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 +23 -0
- package/dist/lib/subagents.js +161 -12
- package/dist/lib/teams/agents.d.ts +14 -0
- package/dist/lib/teams/agents.js +158 -22
- package/dist/lib/types.d.ts +13 -0
- package/dist/lib/versions.d.ts +39 -0
- package/dist/lib/versions.js +199 -12
- package/package.json +1 -1
- package/scripts/postinstall.js +26 -11
package/dist/lib/git.d.ts
CHANGED
|
@@ -107,6 +107,15 @@ export declare function isGitRepo(dir: string): boolean;
|
|
|
107
107
|
* {@link isGitRepo} above). Throws if `dir` is not inside a git repository.
|
|
108
108
|
*/
|
|
109
109
|
export declare function getGitRoot(dir: string): Promise<string>;
|
|
110
|
+
/**
|
|
111
|
+
* Return the absolute path to the **main** working-tree root for `dir`.
|
|
112
|
+
*
|
|
113
|
+
* Unlike {@link getGitRoot}, this stays correct when `dir` is inside a *linked*
|
|
114
|
+
* worktree: `--show-toplevel` there returns the worktree's own path, but the
|
|
115
|
+
* common git dir (`--git-common-dir`) always points at the primary repo's
|
|
116
|
+
* `.git`, whose parent is the main checkout. Throws if `dir` is not in a repo.
|
|
117
|
+
*/
|
|
118
|
+
export declare function getMainRepoRoot(dir: string): Promise<string>;
|
|
110
119
|
/**
|
|
111
120
|
* Initialize a git repo in an existing directory.
|
|
112
121
|
*/
|
package/dist/lib/git.js
CHANGED
|
@@ -427,6 +427,18 @@ export async function getGitRoot(dir) {
|
|
|
427
427
|
const root = await simpleGit(dir).revparse(['--show-toplevel']);
|
|
428
428
|
return root.trim();
|
|
429
429
|
}
|
|
430
|
+
/**
|
|
431
|
+
* Return the absolute path to the **main** working-tree root for `dir`.
|
|
432
|
+
*
|
|
433
|
+
* Unlike {@link getGitRoot}, this stays correct when `dir` is inside a *linked*
|
|
434
|
+
* worktree: `--show-toplevel` there returns the worktree's own path, but the
|
|
435
|
+
* common git dir (`--git-common-dir`) always points at the primary repo's
|
|
436
|
+
* `.git`, whose parent is the main checkout. Throws if `dir` is not in a repo.
|
|
437
|
+
*/
|
|
438
|
+
export async function getMainRepoRoot(dir) {
|
|
439
|
+
const common = await simpleGit(dir).raw(['rev-parse', '--path-format=absolute', '--git-common-dir']);
|
|
440
|
+
return path.dirname(common.trim());
|
|
441
|
+
}
|
|
430
442
|
/**
|
|
431
443
|
* Initialize a git repo in an existing directory.
|
|
432
444
|
*/
|
|
@@ -10,11 +10,32 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import type { Host } from './types.js';
|
|
12
12
|
import { type HostTask } from './tasks.js';
|
|
13
|
+
/**
|
|
14
|
+
* Build a `cd <dir> && ` prefix that resolves on the REMOTE host.
|
|
15
|
+
*
|
|
16
|
+
* A `~`/`$HOME`-anchored path must resolve against the REMOTE user's home, not
|
|
17
|
+
* the local one (`/home/<me>` vs `/Users/<me>`). We emit an unquoted `"$HOME"`
|
|
18
|
+
* for that segment — the remote login shell expands it — and shell-quote the
|
|
19
|
+
* remainder. Any other path (absolute or relative) is quoted verbatim.
|
|
20
|
+
*/
|
|
21
|
+
export declare function remoteCdPrefix(remoteCwd?: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* Launch a detached login-shell command in its own Unix session/process group.
|
|
24
|
+
*
|
|
25
|
+
* Node is already a hard requirement for a host that can run `agents`. Its
|
|
26
|
+
* `detached: true` contract calls setsid(2) on Unix, unlike `nohup ... &` under
|
|
27
|
+
* a non-interactive shell where the background wrapper can remain in the SSH
|
|
28
|
+
* shell's process group. Returning the group leader PID makes `kill(-pid)` a
|
|
29
|
+
* reliable whole-tree operation for both normal stops and rollback cleanup.
|
|
30
|
+
*/
|
|
31
|
+
export declare function buildDetachedLaunchCommand(inner: string): string;
|
|
13
32
|
export interface DispatchResult {
|
|
14
33
|
task: HostTask;
|
|
15
34
|
/** Exit code when followed; undefined when detached (--no-follow). */
|
|
16
35
|
exitCode?: number;
|
|
17
36
|
}
|
|
37
|
+
/** Terminate a detached dispatch that its caller could not persist locally. */
|
|
38
|
+
export declare function terminateDispatchedTask(task: HostTask): void;
|
|
18
39
|
export interface DispatchOptions {
|
|
19
40
|
agent: string;
|
|
20
41
|
prompt: string;
|
|
@@ -20,6 +20,77 @@ import { followHostTask } from './progress.js';
|
|
|
20
20
|
// regardless of the run's cwd. Task ids are 8 hex chars, so these paths are
|
|
21
21
|
// injection-safe to interpolate unquoted into remote commands.
|
|
22
22
|
const REMOTE_DIR = '$HOME/.agents/.cache/hosts';
|
|
23
|
+
/**
|
|
24
|
+
* If `p` is anchored at the home dir — a leading `~` or `$HOME` — return the
|
|
25
|
+
* remainder (no leading slash), else null. Callers that want a local-home
|
|
26
|
+
* absolute (`/Users/<me>/x`, from a shell-expanded `--cwd ~/x`) re-rooted at the
|
|
27
|
+
* remote home normalize it to `~/x` first (`toRemotePortable`); explicit
|
|
28
|
+
* `--remote-cwd` is left literal and so is never re-rooted here.
|
|
29
|
+
*/
|
|
30
|
+
function homeRemainder(p) {
|
|
31
|
+
if (p === '~' || p === '$HOME')
|
|
32
|
+
return '';
|
|
33
|
+
if (p.startsWith('~/'))
|
|
34
|
+
return p.slice(2);
|
|
35
|
+
if (p.startsWith('$HOME/'))
|
|
36
|
+
return p.slice(6);
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Build a `cd <dir> && ` prefix that resolves on the REMOTE host.
|
|
41
|
+
*
|
|
42
|
+
* A `~`/`$HOME`-anchored path must resolve against the REMOTE user's home, not
|
|
43
|
+
* the local one (`/home/<me>` vs `/Users/<me>`). We emit an unquoted `"$HOME"`
|
|
44
|
+
* for that segment — the remote login shell expands it — and shell-quote the
|
|
45
|
+
* remainder. Any other path (absolute or relative) is quoted verbatim.
|
|
46
|
+
*/
|
|
47
|
+
export function remoteCdPrefix(remoteCwd) {
|
|
48
|
+
if (!remoteCwd)
|
|
49
|
+
return '';
|
|
50
|
+
const rest = homeRemainder(remoteCwd);
|
|
51
|
+
if (rest === '')
|
|
52
|
+
return 'cd "$HOME" && ';
|
|
53
|
+
if (rest !== null)
|
|
54
|
+
return `cd "$HOME"/${shellQuote(rest)} && `;
|
|
55
|
+
return `cd ${shellQuote(remoteCwd)} && `;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Launch a detached login-shell command in its own Unix session/process group.
|
|
59
|
+
*
|
|
60
|
+
* Node is already a hard requirement for a host that can run `agents`. Its
|
|
61
|
+
* `detached: true` contract calls setsid(2) on Unix, unlike `nohup ... &` under
|
|
62
|
+
* a non-interactive shell where the background wrapper can remain in the SSH
|
|
63
|
+
* shell's process group. Returning the group leader PID makes `kill(-pid)` a
|
|
64
|
+
* reliable whole-tree operation for both normal stops and rollback cleanup.
|
|
65
|
+
*/
|
|
66
|
+
export function buildDetachedLaunchCommand(inner) {
|
|
67
|
+
const nodeScript = [
|
|
68
|
+
"const { spawn } = require('node:child_process');",
|
|
69
|
+
`const child = spawn('/bin/bash', ['-lc', ${JSON.stringify(inner)}], { detached: true, stdio: 'ignore' });`,
|
|
70
|
+
"child.once('error', error => { console.error(error.message); process.exitCode = 1; });",
|
|
71
|
+
"child.once('spawn', () => { console.log(child.pid); child.unref(); });",
|
|
72
|
+
].join(' ');
|
|
73
|
+
return `bash -lc ${shellQuote(`node -e ${shellQuote(nodeScript)}`)}`;
|
|
74
|
+
}
|
|
75
|
+
function terminateRemoteLaunch(task) {
|
|
76
|
+
if (!task.pid)
|
|
77
|
+
throw new Error(`Cannot terminate remote task ${task.id}: launch returned no PID.`);
|
|
78
|
+
const pid = task.pid;
|
|
79
|
+
const command = `if kill -TERM -- -${pid} 2>/dev/null; then ` +
|
|
80
|
+
`sleep 1; kill -KILL -- -${pid} 2>/dev/null || true; ` +
|
|
81
|
+
`elif kill -0 -- -${pid} 2>/dev/null; then exit 1; fi; ` +
|
|
82
|
+
`rm -f ${task.remoteLog} ${task.remoteExit}`;
|
|
83
|
+
const result = sshExec(task.target, command, { timeoutMs: 10000, multiplex: true });
|
|
84
|
+
if (result.code !== 0) {
|
|
85
|
+
throw new Error(`Failed to terminate remote task ${task.id} on ${task.host}: ` +
|
|
86
|
+
`${(result.stderr || result.stdout).trim() || 'ssh error'}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** Terminate a detached dispatch that its caller could not persist locally. */
|
|
90
|
+
export function terminateDispatchedTask(task) {
|
|
91
|
+
terminateRemoteLaunch(task);
|
|
92
|
+
updateTask(task.id, terminalPatch(143));
|
|
93
|
+
}
|
|
23
94
|
/**
|
|
24
95
|
* The launch + task-record + optional follow core. Both `dispatchToHost` (run)
|
|
25
96
|
* and `dispatchAgentsCommand` (teams) build their `forwardedArgs` and call here,
|
|
@@ -45,10 +116,11 @@ async function launchDetached(host, target, opts) {
|
|
|
45
116
|
const remoteExit = `${REMOTE_DIR}/${id}.exit`;
|
|
46
117
|
// Inner command run under a login shell so PATH resolves `agents`.
|
|
47
118
|
const invocation = ['agents', ...opts.forwardedArgs].map(shellQuote).join(' ');
|
|
48
|
-
const cwd =
|
|
119
|
+
const cwd = remoteCdPrefix(opts.remoteCwd);
|
|
49
120
|
const inner = `${cwd}${invocation} > ${remoteLog} 2>&1; echo $? > ${remoteExit}`;
|
|
50
|
-
// Outer: ensure dir, launch
|
|
51
|
-
|
|
121
|
+
// Outer: ensure dir, launch the login-shell wrapper as a new process-group
|
|
122
|
+
// leader, and print that leader PID.
|
|
123
|
+
const launch = `mkdir -p ${REMOTE_DIR}; ${buildDetachedLaunchCommand(inner)}`;
|
|
52
124
|
const res = sshExec(target, launch, { timeoutMs: 30000, multiplex: true });
|
|
53
125
|
if (res.code !== 0) {
|
|
54
126
|
throw new Error(`Failed to launch on "${host.name}": ${(res.stderr || res.stdout).trim() || 'ssh error'}`);
|
|
@@ -68,7 +140,18 @@ async function launchDetached(host, target, opts) {
|
|
|
68
140
|
status: 'running',
|
|
69
141
|
createdAt: new Date().toISOString(),
|
|
70
142
|
};
|
|
71
|
-
|
|
143
|
+
try {
|
|
144
|
+
saveTask(task);
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
try {
|
|
148
|
+
terminateRemoteLaunch(task);
|
|
149
|
+
}
|
|
150
|
+
catch (cleanupErr) {
|
|
151
|
+
throw new Error(`Failed to persist remote task ${task.id}; cleanup also failed: ${cleanupErr.message}`, { cause: err });
|
|
152
|
+
}
|
|
153
|
+
throw err;
|
|
154
|
+
}
|
|
72
155
|
if (opts.follow === false) {
|
|
73
156
|
return { task };
|
|
74
157
|
}
|
|
@@ -146,7 +229,7 @@ export async function runInteractiveOnHost(host, opts) {
|
|
|
146
229
|
for (const w of warnings)
|
|
147
230
|
process.stderr.write(`[hosts] warning: ${w}\n`);
|
|
148
231
|
const invocation = ['agents', ...buildInteractiveRunForwardedArgs(opts)].map(shellQuote).join(' ');
|
|
149
|
-
const cwd =
|
|
232
|
+
const cwd = remoteCdPrefix(opts.remoteCwd);
|
|
150
233
|
const remoteCmd = `${cwd}${invocation}`;
|
|
151
234
|
return sshStream(target, remoteCmd, { tty: process.stdin.isTTY, multiplex: true });
|
|
152
235
|
}
|
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
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AgentId, PermissionSet, InstalledPermission, ClaudePermissions, OpenCodePermissions, CodexPermissions } from './types.js';
|
|
1
|
+
import type { AgentId, PermissionSet, InstalledPermission, ClaudePermissions, CursorPermissions, OpenCodePermissions, CodexPermissions } from './types.js';
|
|
2
2
|
/** Filename used for Codex Starlark deny-rules generated from permission groups. */
|
|
3
3
|
export declare const CODEX_RULES_FILENAME = "agents-deny.rules";
|
|
4
4
|
export type ParsedRules = PermissionSet;
|
|
@@ -99,6 +99,11 @@ export declare function removePermissionSet(name: string): {
|
|
|
99
99
|
* Claude uses: { permissions: { allow: ["Bash(*)", "Read(**)"], deny: [] } }
|
|
100
100
|
*/
|
|
101
101
|
export declare function convertToClaudeFormat(set: PermissionSet): ClaudePermissions;
|
|
102
|
+
/**
|
|
103
|
+
* Convert canonical permission set to Cursor CLI format
|
|
104
|
+
* (`~/.cursor/cli-config.json` permissions.allow/deny).
|
|
105
|
+
*/
|
|
106
|
+
export declare function convertToCursorFormat(set: PermissionSet): CursorPermissions;
|
|
102
107
|
/**
|
|
103
108
|
* Convert canonical permission set to Gemini format.
|
|
104
109
|
* Gemini reads tool allow-lists from settings.json under `tools.allowed`.
|
|
@@ -112,6 +117,11 @@ export declare function convertToGeminiFormat(set: PermissionSet): {
|
|
|
112
117
|
allowed: string[];
|
|
113
118
|
};
|
|
114
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
|
+
};
|
|
115
125
|
/**
|
|
116
126
|
* Convert canonical permission set to Antigravity format.
|
|
117
127
|
* Antigravity reads ~/.gemini/antigravity-cli/settings.json with
|
|
@@ -152,6 +162,19 @@ export type KimiRule = {
|
|
|
152
162
|
decision: 'allow' | 'deny';
|
|
153
163
|
pattern: string;
|
|
154
164
|
};
|
|
165
|
+
export type KiroRule = {
|
|
166
|
+
capability: string;
|
|
167
|
+
effect: 'allow' | 'deny';
|
|
168
|
+
match?: string[];
|
|
169
|
+
exclude?: string[];
|
|
170
|
+
};
|
|
171
|
+
/**
|
|
172
|
+
* Convert canonical permissions to Kiro CLI v3 capability rules.
|
|
173
|
+
* Kiro stores these rules in ~/.kiro/settings/permissions.yaml.
|
|
174
|
+
*/
|
|
175
|
+
export declare function convertToKiroFormat(set: PermissionSet): {
|
|
176
|
+
rules: KiroRule[];
|
|
177
|
+
};
|
|
155
178
|
/**
|
|
156
179
|
* Convert a canonical permission set to Kimi Code's `[permission].rules` format.
|
|
157
180
|
* Kimi (`~/.kimi-code/config.toml`) reads rules of the form
|
|
@@ -194,6 +217,13 @@ export declare function applyClaudePermissions(set: PermissionSet, scope?: 'user
|
|
|
194
217
|
success: boolean;
|
|
195
218
|
error?: string;
|
|
196
219
|
};
|
|
220
|
+
/**
|
|
221
|
+
* Path OpenCode actually loads for global config:
|
|
222
|
+
* ~/.config/opencode/opencode.jsonc (or .json)
|
|
223
|
+
* Project: <cwd>/opencode.jsonc (or .json) at project root — not .opencode/.
|
|
224
|
+
* See https://opencode.ai/docs/config/
|
|
225
|
+
*/
|
|
226
|
+
export declare function openCodeConfigPath(scope: 'user' | 'project', cwd?: string, home?: string): string;
|
|
197
227
|
/**
|
|
198
228
|
* Apply a permission set to a specific version's home directory.
|
|
199
229
|
* This writes to {versionHome}/.{agent}/settings.json (or equivalent).
|