@phnx-labs/agents-cli 1.20.57 → 1.20.58

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 (43) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +34 -3
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/defaults.js +24 -0
  5. package/dist/commands/exec.js +28 -4
  6. package/dist/commands/secrets.js +28 -19
  7. package/dist/commands/versions.js +11 -3
  8. package/dist/commands/view.js +19 -4
  9. package/dist/lib/agents.d.ts +21 -0
  10. package/dist/lib/agents.js +28 -4
  11. package/dist/lib/daemon.d.ts +5 -5
  12. package/dist/lib/daemon.js +65 -17
  13. package/dist/lib/git.d.ts +9 -0
  14. package/dist/lib/git.js +12 -0
  15. package/dist/lib/hosts/dispatch.d.ts +21 -0
  16. package/dist/lib/hosts/dispatch.js +88 -5
  17. package/dist/lib/permissions.d.ts +19 -1
  18. package/dist/lib/permissions.js +137 -0
  19. package/dist/lib/project-root.d.ts +65 -0
  20. package/dist/lib/project-root.js +133 -0
  21. package/dist/lib/resources/permissions.js +2 -0
  22. package/dist/lib/resources/types.d.ts +1 -1
  23. package/dist/lib/secrets/agent.d.ts +26 -23
  24. package/dist/lib/secrets/agent.js +196 -216
  25. package/dist/lib/secrets/remote.js +1 -0
  26. package/dist/lib/session/active.d.ts +3 -0
  27. package/dist/lib/session/active.js +1 -0
  28. package/dist/lib/session/parse.js +38 -15
  29. package/dist/lib/session/state.d.ts +4 -1
  30. package/dist/lib/session/state.js +18 -1
  31. package/dist/lib/session/types.d.ts +8 -0
  32. package/dist/lib/staleness/detectors/permissions.js +42 -0
  33. package/dist/lib/staleness/detectors/subagents.js +30 -0
  34. package/dist/lib/staleness/writers/subagents.js +13 -1
  35. package/dist/lib/subagents.d.ts +22 -0
  36. package/dist/lib/subagents.js +146 -0
  37. package/dist/lib/teams/agents.d.ts +14 -0
  38. package/dist/lib/teams/agents.js +158 -22
  39. package/dist/lib/types.d.ts +13 -0
  40. package/dist/lib/versions.d.ts +39 -0
  41. package/dist/lib/versions.js +199 -12
  42. package/package.json +1 -1
  43. package/scripts/postinstall.js +26 -11
@@ -674,8 +674,7 @@ export function writeOwnerOnlyServiceManifest(filePath, content) {
674
674
  fs.writeFileSync(filePath, content, { encoding: 'utf-8', mode: 0o600 });
675
675
  }
676
676
  /** Generate a macOS launchd plist for auto-starting the daemon. */
677
- export function generateLaunchdPlist(oauthToken = readDaemonClaudeOAuthToken()) {
678
- const agentsBin = getAgentsBinPath();
677
+ export function generateLaunchdPlist(oauthToken = readDaemonClaudeOAuthToken(), agentsBin = getAgentsBinPath()) {
679
678
  const launch = getDaemonLaunch(agentsBin);
680
679
  const logPath = getLogPath();
681
680
  const oauthEntry = oauthToken
@@ -704,7 +703,7 @@ ${[launch.command, ...launch.args].map((arg) => ` <string>${xmlEscape(arg)}</
704
703
  <key>EnvironmentVariables</key>
705
704
  <dict>
706
705
  <key>PATH</key>
707
- <string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:${os.homedir()}/.bun/bin:${os.homedir()}/.nvm/versions/node/v24.0.0/bin</string>${oauthEntry}
706
+ <string>${daemonNodeBinDir()}:/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:${os.homedir()}/.bun/bin</string>${oauthEntry}
708
707
  </dict>
709
708
  </dict>
710
709
  </plist>`;
@@ -714,8 +713,7 @@ function systemdExecArg(value) {
714
713
  return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
715
714
  }
716
715
  /** Generate a Linux systemd user unit for auto-starting the daemon. */
717
- export function generateSystemdUnit(oauthToken = readDaemonClaudeOAuthToken()) {
718
- const agentsBin = getAgentsBinPath();
716
+ export function generateSystemdUnit(oauthToken = readDaemonClaudeOAuthToken(), agentsBin = getAgentsBinPath()) {
719
717
  const launch = getDaemonLaunch(agentsBin);
720
718
  const execStart = [launch.command, ...launch.args].map(systemdExecArg).join(' ');
721
719
  const oauthLine = oauthToken
@@ -730,7 +728,7 @@ Type=simple
730
728
  ExecStart=${execStart}
731
729
  Restart=always
732
730
  RestartSec=10
733
- Environment=PATH=/usr/local/bin:/usr/bin:/bin:${os.homedir()}/.nvm/versions/node/v24.0.0/bin${oauthLine}
731
+ Environment=PATH=${daemonNodeBinDir()}:/usr/local/bin:/usr/bin:/bin${oauthLine}
734
732
 
735
733
  [Install]
736
734
  WantedBy=default.target`;
@@ -802,7 +800,7 @@ function readServiceManagerPid(platform = os.platform()) {
802
800
  return null;
803
801
  }
804
802
  /** Start the daemon via launchd, systemd, or as a detached process. */
805
- export function startDaemon() {
803
+ export function startDaemon(agentsBin) {
806
804
  if (isDaemonRunning()) {
807
805
  const pid = readDaemonPid();
808
806
  return { pid, method: 'already-running' };
@@ -814,7 +812,7 @@ export function startDaemon() {
814
812
  return { pid, method: 'already-starting' };
815
813
  }
816
814
  try {
817
- return startDaemonLocked();
815
+ return startDaemonLocked(agentsBin ?? getAgentsBinPath());
818
816
  }
819
817
  finally {
820
818
  releaseLock();
@@ -838,7 +836,7 @@ export function ensureDaemonStarted() {
838
836
  return null;
839
837
  }
840
838
  }
841
- function startDaemonLocked() {
839
+ function startDaemonLocked(agentsBin) {
842
840
  const platform = os.platform();
843
841
  if (platform === 'darwin') {
844
842
  try {
@@ -849,7 +847,7 @@ function startDaemonLocked() {
849
847
  }
850
848
  // The plist may embed a long-lived OAuth token in EnvironmentVariables;
851
849
  // create owner-only atomically (no world-readable window before chmod).
852
- writeOwnerOnlyServiceManifest(plistPath, generateLaunchdPlist());
850
+ writeOwnerOnlyServiceManifest(plistPath, generateLaunchdPlist(undefined, agentsBin));
853
851
  try {
854
852
  execFileSync('launchctl', ['unload', plistPath], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
855
853
  }
@@ -867,7 +865,7 @@ function startDaemonLocked() {
867
865
  catch {
868
866
  // load threw — fall through to detached spawn
869
867
  }
870
- return startDetached();
868
+ return startDetached({ agentsBin });
871
869
  }
872
870
  if (platform === 'linux') {
873
871
  try {
@@ -877,7 +875,7 @@ function startDaemonLocked() {
877
875
  fs.mkdirSync(unitDir, { recursive: true });
878
876
  }
879
877
  // May embed a long-lived OAuth token in an Environment= line; owner-only.
880
- writeOwnerOnlyServiceManifest(unitPath, generateSystemdUnit());
878
+ writeOwnerOnlyServiceManifest(unitPath, generateSystemdUnit(undefined, agentsBin));
881
879
  execFileSync('systemctl', ['--user', 'daemon-reload'], { encoding: 'utf-8' });
882
880
  execFileSync('systemctl', ['--user', 'enable', SYSTEMD_UNIT], { encoding: 'utf-8' });
883
881
  execFileSync('systemctl', ['--user', 'start', SYSTEMD_UNIT], { encoding: 'utf-8' });
@@ -890,9 +888,9 @@ function startDaemonLocked() {
890
888
  catch {
891
889
  // start threw — fall through to detached spawn
892
890
  }
893
- return startDetached();
891
+ return startDetached({ agentsBin });
894
892
  }
895
- return startDetached();
893
+ return startDetached({ agentsBin });
896
894
  }
897
895
  /**
898
896
  * Environment for the detached daemon fallback. The launchd/systemd paths
@@ -925,18 +923,68 @@ export function buildDetachedDaemonEnv(baseEnv = process.env, oauthToken = readD
925
923
  * Going through `process.execPath` means a real PE/binary is spawned with
926
924
  * `detached: true` and no console, so nothing signals the daemon after launch.
927
925
  *
928
- * When the entry isn't a JS file (e.g. a native launcher resolved via
929
- * `which agents`), run it directly — it owns its own runtime resolution.
926
+ * When the entry isn't a Node script (e.g. a native compiled launcher), run it
927
+ * directly — it owns its own runtime resolution.
930
928
  */
931
929
  export function getDaemonLaunch(agentsBin = getAgentsBinPath()) {
932
930
  const { warnings } = validateDaemonBinary(agentsBin);
933
931
  for (const w of warnings)
934
932
  process.stderr.write(`[agents] ${w}\n`);
935
- if (/\.(c|m)?js$/.test(agentsBin)) {
933
+ if (isNodeScriptEntry(agentsBin)) {
936
934
  return { command: process.execPath, args: [agentsBin, 'daemon', '_run'] };
937
935
  }
938
936
  return { command: agentsBin, args: ['daemon', '_run'] };
939
937
  }
938
+ /**
939
+ * A daemon entry must be launched through the Node runtime when it is a Node
940
+ * script — a `.js`/`.cjs`/`.mjs` file, OR a symlink/extension-less shim whose
941
+ * shebang names `node`. Package installs link `bin/agents` to a `dist/index.js`
942
+ * (a symlink) or drop an extension-less `#!/usr/bin/env node` shim, so an
943
+ * extension check alone misses them and they get run directly. Executing such an
944
+ * entry then relies on the shebang resolving `node` off the daemon's PATH — and
945
+ * when that PATH points at a pruned nvm version (or an ancient system node), the
946
+ * daemon crash-loops at import (`node:util` has no `styleText` on Node 18). A
947
+ * real compiled binary (Mach-O/ELF/PE) has no `#!node` shebang, so it takes the
948
+ * direct branch and owns its own runtime resolution.
949
+ */
950
+ function isNodeScriptEntry(agentsBin) {
951
+ let resolved = agentsBin;
952
+ try {
953
+ resolved = fs.realpathSync(agentsBin);
954
+ }
955
+ catch {
956
+ // Unresolvable (e.g. a template path that does not exist on this box): fall
957
+ // back to the extension check on the path as given.
958
+ }
959
+ if (/\.(c|m)?js$/.test(resolved))
960
+ return true;
961
+ try {
962
+ const fd = fs.openSync(resolved, 'r');
963
+ try {
964
+ const buf = Buffer.alloc(128);
965
+ const n = fs.readSync(fd, buf, 0, 128, 0);
966
+ const firstLine = buf.toString('utf-8', 0, n).split('\n', 1)[0];
967
+ return firstLine.startsWith('#!') && /\bnode\b/.test(firstLine);
968
+ }
969
+ finally {
970
+ fs.closeSync(fd);
971
+ }
972
+ }
973
+ catch {
974
+ return false;
975
+ }
976
+ }
977
+ /**
978
+ * The directory of the Node runtime that generated this service manifest, kept
979
+ * first on the daemon's PATH. Both the shim's shebang and any child routine
980
+ * process then resolve the exact Node that installed the service — never an
981
+ * ancient system node or a pruned nvm version. Replaces the old hardcoded
982
+ * `~/.nvm/versions/node/v24.0.0/bin`, which went stale the moment that patch
983
+ * release was upgraded away and bricked the daemon fleet-wide.
984
+ */
985
+ function daemonNodeBinDir() {
986
+ return path.dirname(process.execPath);
987
+ }
940
988
  /**
941
989
  * Build the argv to relaunch the `agents` CLI with the given subcommand args.
942
990
  *
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
  }
@@ -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`.
@@ -152,6 +157,19 @@ export type KimiRule = {
152
157
  decision: 'allow' | 'deny';
153
158
  pattern: string;
154
159
  };
160
+ export type KiroRule = {
161
+ capability: string;
162
+ effect: 'allow' | 'deny';
163
+ match?: string[];
164
+ exclude?: string[];
165
+ };
166
+ /**
167
+ * Convert canonical permissions to Kiro CLI v3 capability rules.
168
+ * Kiro stores these rules in ~/.kiro/settings/permissions.yaml.
169
+ */
170
+ export declare function convertToKiroFormat(set: PermissionSet): {
171
+ rules: KiroRule[];
172
+ };
155
173
  /**
156
174
  * Convert a canonical permission set to Kimi Code's `[permission].rules` format.
157
175
  * Kimi (`~/.kimi-code/config.toml`) reads rules of the form
@@ -440,6 +440,37 @@ export function convertToClaudeFormat(set) {
440
440
  }
441
441
  return { permissions };
442
442
  }
443
+ /**
444
+ * Map a canonical rule to Cursor CLI syntax.
445
+ * Cursor uses Shell(...) instead of Bash(...); other tools keep TitleCase names.
446
+ * https://cursor.com/docs/cli/reference/permissions
447
+ */
448
+ function canonicalToCursorRule(perm) {
449
+ if (perm === 'Bash' || perm.startsWith('Bash(')) {
450
+ return perm.replace(/^Bash/, 'Shell');
451
+ }
452
+ // Canonical WebSearch maps to WebFetch family for network allow.
453
+ if (perm.startsWith('WebSearch(')) {
454
+ return perm.replace(/^WebSearch/, 'WebFetch');
455
+ }
456
+ // Cursor has no Edit prefix — file writes use Write(...).
457
+ if (perm === 'Edit' || perm.startsWith('Edit(')) {
458
+ return perm.replace(/^Edit/, 'Write');
459
+ }
460
+ return perm;
461
+ }
462
+ /**
463
+ * Convert canonical permission set to Cursor CLI format
464
+ * (`~/.cursor/cli-config.json` permissions.allow/deny).
465
+ */
466
+ export function convertToCursorFormat(set) {
467
+ return {
468
+ permissions: {
469
+ allow: set.allow.map(canonicalToCursorRule),
470
+ deny: (set.deny ?? []).map(canonicalToCursorRule),
471
+ },
472
+ };
473
+ }
443
474
  /**
444
475
  * Parse canonical permission pattern to extract tool and pattern.
445
476
  * "Bash(git *)" -> { tool: "bash", pattern: "git *" }
@@ -592,6 +623,57 @@ function canonicalToGrokRule(perm, action) {
592
623
  }
593
624
  return { action, tool, pattern };
594
625
  }
626
+ /**
627
+ * Convert canonical permissions to Kiro CLI v3 capability rules.
628
+ * Kiro stores these rules in ~/.kiro/settings/permissions.yaml.
629
+ */
630
+ export function convertToKiroFormat(set) {
631
+ const rules = [];
632
+ for (const perm of set.allow) {
633
+ const rule = canonicalToKiroRule(perm, 'allow');
634
+ if (rule)
635
+ rules.push(rule);
636
+ }
637
+ for (const perm of set.deny ?? []) {
638
+ const rule = canonicalToKiroRule(perm, 'deny');
639
+ if (rule)
640
+ rules.push(rule);
641
+ }
642
+ return { rules };
643
+ }
644
+ function canonicalToKiroRule(perm, effect) {
645
+ if (BLANKET_BASH_FORMS.has(perm)) {
646
+ return { capability: 'shell', effect };
647
+ }
648
+ const parsed = parseCanonicalPreserveCase(perm);
649
+ const capability = KIRO_CAPABILITY_BY_TOOL[parsed.tool.toLowerCase()];
650
+ if (!capability)
651
+ return null;
652
+ if (parsed.pattern === null || parsed.pattern === '*' || parsed.pattern === '**') {
653
+ return { capability, effect };
654
+ }
655
+ const lowerTool = parsed.tool.toLowerCase();
656
+ const pattern = lowerTool === 'bash'
657
+ ? normalizeBashPattern(parsed.pattern)
658
+ : (lowerTool === 'webfetch' || lowerTool === 'websearch') && parsed.pattern.startsWith('domain:')
659
+ ? parsed.pattern.slice('domain:'.length)
660
+ : parsed.pattern;
661
+ return { capability, effect, match: [pattern] };
662
+ }
663
+ const KIRO_CAPABILITY_BY_TOOL = {
664
+ bash: 'shell',
665
+ read: 'fs_read',
666
+ grep: 'fs_read',
667
+ glob: 'fs_read',
668
+ write: 'fs_write',
669
+ edit: 'fs_write',
670
+ notebookedit: 'fs_write',
671
+ webfetch: 'web_fetch',
672
+ websearch: 'web_search',
673
+ mcp: 'mcp',
674
+ subagent: 'subagent',
675
+ skill: 'skill',
676
+ };
595
677
  /**
596
678
  * Parse a canonical permission string preserving the tool's original casing.
597
679
  * `parseCanonicalPattern` lowercases the tool name, which is fine for Grok
@@ -1258,6 +1340,61 @@ export function applyPermissionsToVersion(agentId, set, versionHome, merge = tru
1258
1340
  fs.writeFileSync(configPath, TOML.stringify(config), 'utf-8');
1259
1341
  return { success: true };
1260
1342
  }
1343
+ if (agentId === 'cursor') {
1344
+ // Cursor CLI permissions live in ~/.cursor/cli-config.json
1345
+ const configPath = path.join(configDir, 'cli-config.json');
1346
+ let config = {};
1347
+ if (fs.existsSync(configPath)) {
1348
+ try {
1349
+ config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
1350
+ }
1351
+ catch { /* start fresh */ }
1352
+ }
1353
+ const converted = convertToCursorFormat(set);
1354
+ if (merge && config.permissions && typeof config.permissions === 'object') {
1355
+ const existing = config.permissions;
1356
+ const allow = new Set([...(existing.allow || []), ...converted.permissions.allow]);
1357
+ const deny = new Set([...(existing.deny || []), ...converted.permissions.deny]);
1358
+ config.permissions = { allow: [...allow], deny: [...deny] };
1359
+ }
1360
+ else {
1361
+ config.permissions = converted.permissions;
1362
+ }
1363
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
1364
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
1365
+ return { success: true };
1366
+ }
1367
+ if (agentId === 'kiro') {
1368
+ const permissionsPath = path.join(versionHome, '.kiro', 'settings', 'permissions.yaml');
1369
+ let config = {};
1370
+ if (fs.existsSync(permissionsPath)) {
1371
+ const parsed = yaml.parse(fs.readFileSync(permissionsPath, 'utf-8'));
1372
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
1373
+ config = parsed;
1374
+ }
1375
+ }
1376
+ const newRules = convertToKiroFormat(set).rules;
1377
+ if (merge) {
1378
+ const existingRules = Array.isArray(config.rules) ? config.rules : [];
1379
+ const seen = new Set();
1380
+ config.rules = [...existingRules, ...newRules].filter((rule) => {
1381
+ if (!rule || typeof rule !== 'object' || Array.isArray(rule))
1382
+ return false;
1383
+ const record = rule;
1384
+ const key = `${String(record.effect)}|${String(record.capability)}|${JSON.stringify(record.match ?? [])}|${JSON.stringify(record.exclude ?? [])}`;
1385
+ if (seen.has(key))
1386
+ return false;
1387
+ seen.add(key);
1388
+ return true;
1389
+ });
1390
+ }
1391
+ else {
1392
+ config.rules = newRules;
1393
+ }
1394
+ fs.mkdirSync(path.dirname(permissionsPath), { recursive: true });
1395
+ fs.writeFileSync(permissionsPath, yaml.stringify(config), 'utf-8');
1396
+ return { success: true };
1397
+ }
1261
1398
  return { success: false, error: `Agent '${agentId}' does not support permissions` };
1262
1399
  }
1263
1400
  catch (err) {
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Projects-root resolution for the `agents run --project <slug>` shorthand.
3
+ *
4
+ * Projects follow a predictable layout — `<root>/<repo>` (e.g.
5
+ * `~/src/github.com/<user>/<repo>`), with git worktrees under
6
+ * `<repo>/.agents/worktrees/<slug>`. The root is auto-inferred from the repo you
7
+ * launch inside (the directory ABOVE the git root) and cached in `agents.yaml`
8
+ * so later runs resolve a bare slug from anywhere. It is stored home-relative
9
+ * (`~/…`) when it sits under `$HOME`, so the SAME value resolves on a remote
10
+ * host whose home differs (`/home/<user>` vs `/Users/<user>`): a `--host` run
11
+ * keeps the `~` and lets the remote login shell expand it (see `remoteCdPrefix`
12
+ * in `hosts/dispatch.ts`), while a local run expands `~` against the local home.
13
+ */
14
+ /** Rewrite an absolute path under the local home to a `~/`-relative string; pass others through. */
15
+ export declare function toHomeRelative(abs: string): string;
16
+ /** Expand a leading `~`/`$HOME` against the LOCAL home. Other paths pass through unchanged. */
17
+ export declare function expandLocalHome(p: string): string;
18
+ /**
19
+ * Make a `--cwd`/`--project` value portable to a remote host: an absolute path
20
+ * under the LOCAL home (which the local shell already expanded from `~`) becomes
21
+ * `~/…` so the *remote* shell re-roots it at its own home. Paths already anchored
22
+ * at `~`/`$HOME` pass through; other absolute or relative paths are left as-is
23
+ * (used verbatim on the host). Explicit `--remote-cwd` is NOT run through this —
24
+ * it is a literal remote path by contract.
25
+ */
26
+ export declare function toRemotePortable(p: string): string;
27
+ /** The configured projects root (home-relative or absolute), or undefined when unset. */
28
+ export declare function getProjectRoot(): string | undefined;
29
+ /** Set (override) the cached projects root. Stored home-relative when under `$HOME`. */
30
+ export declare function setProjectRoot(rootPath: string): string;
31
+ /**
32
+ * Infer the projects root from `cwd`: the directory ABOVE the git repo root
33
+ * (cwd inside `~/src/github.com/user/repo` → `~/src/github.com/user`). Returns a
34
+ * home-relative string when under `$HOME`; undefined when `cwd` is not in a repo.
35
+ */
36
+ export declare function inferProjectRoot(cwd: string): Promise<string | undefined>;
37
+ /**
38
+ * Resolve the projects root, auto-inferring and caching on first use. Throws an
39
+ * actionable error when it is neither configured nor inferrable from `cwd`.
40
+ */
41
+ export declare function ensureProjectRoot(cwd: string): Promise<string>;
42
+ export interface ProjectRef {
43
+ slug: string;
44
+ worktree?: string;
45
+ }
46
+ /** Parse a `--project` value of the form `<slug>[@<worktree>]`. */
47
+ export declare function parseProjectRef(ref: string): ProjectRef;
48
+ /**
49
+ * Join a root + `--project` ref into a working directory. Pure (no I/O) so the
50
+ * slug/worktree layout is unit-testable. `forRemote` keeps the path
51
+ * home-relative (`~/…`) for the remote shell to expand; otherwise it is expanded
52
+ * against the local home into an absolute path.
53
+ */
54
+ export declare function buildProjectPath(root: string, ref: string, forRemote: boolean): string;
55
+ /**
56
+ * Resolve a `--project` ref to a working directory, inferring/caching the root.
57
+ *
58
+ * `forRemote: true` returns a home-relative path (`~/…`) so the REMOTE login
59
+ * shell expands `~`/`$HOME` to its own home. `forRemote: false` returns an
60
+ * absolute local path and verifies it exists (so a mistyped slug fails loudly).
61
+ */
62
+ export declare function resolveProjectRef(ref: string, opts: {
63
+ forRemote: boolean;
64
+ cwd?: string;
65
+ }): Promise<string>;