@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.
Files changed (60) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +40 -4
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/defaults.js +24 -0
  5. package/dist/commands/exec.js +29 -5
  6. package/dist/commands/output.d.ts +19 -0
  7. package/dist/commands/output.js +333 -0
  8. package/dist/commands/secrets.js +34 -25
  9. package/dist/commands/versions.js +11 -3
  10. package/dist/commands/view.js +19 -4
  11. package/dist/index.js +2 -1
  12. package/dist/lib/agents.d.ts +21 -0
  13. package/dist/lib/agents.js +42 -14
  14. package/dist/lib/daemon.d.ts +5 -5
  15. package/dist/lib/daemon.js +65 -17
  16. package/dist/lib/git.d.ts +9 -0
  17. package/dist/lib/git.js +12 -0
  18. package/dist/lib/hosts/dispatch.d.ts +21 -0
  19. package/dist/lib/hosts/dispatch.js +88 -5
  20. package/dist/lib/hosts/passthrough.js +1 -0
  21. package/dist/lib/mcp.js +1 -1
  22. package/dist/lib/output/git-output.d.ts +74 -0
  23. package/dist/lib/output/git-output.js +213 -0
  24. package/dist/lib/permissions.d.ts +31 -1
  25. package/dist/lib/permissions.js +210 -9
  26. package/dist/lib/project-root.d.ts +65 -0
  27. package/dist/lib/project-root.js +134 -0
  28. package/dist/lib/resources/mcp.js +1 -1
  29. package/dist/lib/resources/permissions.js +5 -1
  30. package/dist/lib/resources/skills.js +6 -1
  31. package/dist/lib/resources/types.d.ts +1 -1
  32. package/dist/lib/secrets/agent.d.ts +26 -23
  33. package/dist/lib/secrets/agent.js +196 -216
  34. package/dist/lib/secrets/remote.d.ts +7 -2
  35. package/dist/lib/secrets/remote.js +12 -10
  36. package/dist/lib/session/active.d.ts +3 -0
  37. package/dist/lib/session/active.js +1 -0
  38. package/dist/lib/session/db.d.ts +3 -0
  39. package/dist/lib/session/db.js +20 -4
  40. package/dist/lib/session/discover.d.ts +2 -0
  41. package/dist/lib/session/discover.js +40 -4
  42. package/dist/lib/session/parse.js +38 -15
  43. package/dist/lib/session/state.d.ts +4 -1
  44. package/dist/lib/session/state.js +18 -1
  45. package/dist/lib/session/types.d.ts +10 -0
  46. package/dist/lib/staleness/detectors/permissions.js +64 -1
  47. package/dist/lib/staleness/detectors/subagents.js +30 -0
  48. package/dist/lib/staleness/writers/commands.js +3 -3
  49. package/dist/lib/staleness/writers/subagents.js +13 -1
  50. package/dist/lib/startup/command-registry.d.ts +1 -0
  51. package/dist/lib/startup/command-registry.js +2 -0
  52. package/dist/lib/subagents.d.ts +23 -0
  53. package/dist/lib/subagents.js +161 -12
  54. package/dist/lib/teams/agents.d.ts +14 -0
  55. package/dist/lib/teams/agents.js +158 -22
  56. package/dist/lib/types.d.ts +13 -0
  57. package/dist/lib/versions.d.ts +39 -0
  58. package/dist/lib/versions.js +199 -12
  59. package/package.json +1 -1
  60. 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 = opts.remoteCwd ? `cd ${shellQuote(opts.remoteCwd)} && ` : '';
119
+ const cwd = remoteCdPrefix(opts.remoteCwd);
49
120
  const inner = `${cwd}${invocation} > ${remoteLog} 2>&1; echo $? > ${remoteExit}`;
50
- // Outer: ensure dir, launch detached under bash -lc, print the PID.
51
- const launch = `mkdir -p ${REMOTE_DIR}; nohup bash -lc ${shellQuote(inner)} >/dev/null 2>&1 & echo $!`;
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
- saveTask(task);
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 = opts.remoteCwd ? `cd ${shellQuote(opts.remoteCwd)} && ` : '';
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
  }
@@ -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
+ }
@@ -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).