@phnx-labs/agents-cli 1.20.33 → 1.20.34

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 (56) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +28 -2
  3. package/dist/commands/computer.d.ts +23 -0
  4. package/dist/commands/computer.js +45 -3
  5. package/dist/commands/doctor.d.ts +10 -0
  6. package/dist/commands/doctor.js +49 -0
  7. package/dist/commands/import.js +1 -1
  8. package/dist/commands/rules.js +1 -1
  9. package/dist/commands/secrets-migrate.js +23 -11
  10. package/dist/commands/secrets.d.ts +20 -0
  11. package/dist/commands/secrets.js +53 -1
  12. package/dist/commands/status.d.ts +12 -0
  13. package/dist/commands/status.js +81 -0
  14. package/dist/commands/teams.js +70 -6
  15. package/dist/commands/versions.js +2 -1
  16. package/dist/commands/view.d.ts +39 -0
  17. package/dist/commands/view.js +194 -75
  18. package/dist/index.js +4 -2
  19. package/dist/lib/acp/harnesses.d.ts +1 -1
  20. package/dist/lib/acp/harnesses.js +2 -2
  21. package/dist/lib/agents.d.ts +12 -0
  22. package/dist/lib/agents.js +115 -32
  23. package/dist/lib/browser/chrome.js +20 -0
  24. package/dist/lib/browser/drivers/ssh.d.ts +19 -0
  25. package/dist/lib/browser/drivers/ssh.js +18 -3
  26. package/dist/lib/doctor-diff.js +29 -2
  27. package/dist/lib/drift-sync.d.ts +43 -0
  28. package/dist/lib/drift-sync.js +179 -0
  29. package/dist/lib/exec.d.ts +15 -0
  30. package/dist/lib/exec.js +21 -11
  31. package/dist/lib/platform/winpath.d.ts +31 -2
  32. package/dist/lib/platform/winpath.js +133 -24
  33. package/dist/lib/pwsh.d.ts +11 -0
  34. package/dist/lib/pwsh.js +13 -0
  35. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  36. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  37. package/dist/lib/secrets/agent.d.ts +42 -1
  38. package/dist/lib/secrets/agent.js +89 -11
  39. package/dist/lib/secrets/bundles.js +40 -9
  40. package/dist/lib/secrets/filestore.js +31 -1
  41. package/dist/lib/secrets/index.d.ts +33 -1
  42. package/dist/lib/secrets/index.js +90 -9
  43. package/dist/lib/secrets/windows.d.ts +74 -0
  44. package/dist/lib/secrets/windows.js +440 -0
  45. package/dist/lib/shims.d.ts +20 -0
  46. package/dist/lib/shims.js +53 -20
  47. package/dist/lib/startup/command-registry.d.ts +1 -0
  48. package/dist/lib/startup/command-registry.js +2 -0
  49. package/dist/lib/sync-status.d.ts +102 -0
  50. package/dist/lib/sync-status.js +135 -0
  51. package/dist/lib/teams/agents.d.ts +24 -0
  52. package/dist/lib/teams/agents.js +30 -1
  53. package/dist/lib/types.d.ts +20 -1
  54. package/dist/lib/usage.d.ts +30 -0
  55. package/dist/lib/usage.js +159 -2
  56. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -51,7 +51,7 @@ if (IS_DEV_BUILD) {
51
51
  // module on each invocation (which loaded the whole ~50-module tree before the
52
52
  // first byte of output), the registry maps a command name to a thunk that
53
53
  // imports only what that command needs. See src/lib/startup/command-registry.ts.
54
- import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadPackages, loadDaemon, loadRoutines, loadRun, loadDefaults, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadProfiles, loadSecrets, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadRefreshRules, loadDrive, loadFactory, loadUsage, loadCost, loadBudget, loadAlias, loadPty, loadTmux, loadBrowser, loadComputer, loadHosts, loadSsh, loadPull, loadPush, loadRepo, loadSetup, } from './lib/startup/command-registry.js';
54
+ import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadPackages, loadDaemon, loadRoutines, loadRun, loadDefaults, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadStatus, loadProfiles, loadSecrets, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadRefreshRules, loadDrive, loadFactory, loadUsage, loadCost, loadBudget, loadAlias, loadPty, loadTmux, loadBrowser, loadComputer, loadHosts, loadSsh, loadPull, loadPush, loadRepo, loadSetup, } from './lib/startup/command-registry.js';
55
55
  import { applyGlobalHelpConventions } from './lib/help.js';
56
56
  import { IS_WINDOWS } from './lib/platform/index.js';
57
57
  // Transparent shim delegate: the generated Windows `.cmd` shims invoke
@@ -764,6 +764,7 @@ async function registerAllEagerCommands() {
764
764
  await reg(loadTrash);
765
765
  await reg(loadRestore);
766
766
  await reg(loadDoctor);
767
+ await reg(loadStatus);
767
768
  registerExecAliasCommand(program);
768
769
  await reg(loadProfiles);
769
770
  await reg(loadSecrets);
@@ -894,7 +895,8 @@ if (!helpOrVersionRequested) {
894
895
  // fire-and-forget the next background sync. System repo gets a real fast-forward
895
896
  // pull (read-only locally, safe). User repo and extras get fetch-only + a
896
897
  // status marker that we'll print on the *next* invocation.
897
- const { spawnDetachedSync } = await import('./lib/auto-pull.js');
898
+ const { spawnDetachedSync, printPendingUpdateNotices } = await import('./lib/auto-pull.js');
899
+ printPendingUpdateNotices();
898
900
  spawnDetachedSync();
899
901
  }
900
902
  // First-run experience: no args + no config yet + TTY -> launch interactive setup.
@@ -2,7 +2,7 @@
2
2
  * Per-harness spawn configuration for Agent Client Protocol (ACP) mode.
3
3
  *
4
4
  * Each entry describes how to launch a coding agent CLI as an ACP server
5
- * (stdio JSON-RPC). Unsupported harnesses (amp, roo, goose-stdio) are omitted;
5
+ * (stdio JSON-RPC). Unsupported harnesses (amp, goose-stdio) are omitted;
6
6
  * callers should fall back to the legacy direct-exec path for those.
7
7
  *
8
8
  * Sources: https://agentclientprotocol.com/get-started/agents + vendor docs.
@@ -2,7 +2,7 @@
2
2
  * Per-harness spawn configuration for Agent Client Protocol (ACP) mode.
3
3
  *
4
4
  * Each entry describes how to launch a coding agent CLI as an ACP server
5
- * (stdio JSON-RPC). Unsupported harnesses (amp, roo, goose-stdio) are omitted;
5
+ * (stdio JSON-RPC). Unsupported harnesses (amp, goose-stdio) are omitted;
6
6
  * callers should fall back to the legacy direct-exec path for those.
7
7
  *
8
8
  * Sources: https://agentclientprotocol.com/get-started/agents + vendor docs.
@@ -61,7 +61,7 @@ export const ACP_HARNESSES = {
61
61
  // goose: ACP over HTTP via `goosed`, not a clean stdio subcommand.
62
62
  // copilot, kiro: excluded for now (not installed in the reference environment,
63
63
  // no local verification possible).
64
- // amp, roo: not on the ACP agents list.
64
+ // amp: not on the ACP agents list.
65
65
  };
66
66
  /** Returns the ACP spawn spec for an agent, or undefined if the harness does not speak ACP. */
67
67
  export function getAcpSpec(agent) {
@@ -219,6 +219,18 @@ export declare const AGENT_NAME_ALIASES: Record<string, AgentId>;
219
219
  export declare function resolveAgentName(input: string): AgentId | null;
220
220
  /** Check whether the input string matches any known agent name or alias. */
221
221
  export declare function isAgentName(input: string): boolean;
222
+ /**
223
+ * Build the deprecation notice lines for an agent, or null if it isn't
224
+ * deprecated. Split from the printer so tests can assert the content without
225
+ * capturing stdout. Lines are plain (uncolored) text.
226
+ */
227
+ export declare function deprecationNotice(agent: AgentId): string[] | null;
228
+ /**
229
+ * Print a deprecation warning (yellow) if the agent's registry entry carries a
230
+ * `deprecated` marker; no-op otherwise. Call from any user entry point that
231
+ * acts on a chosen agent — install (`agents add`) and `agents teams add`.
232
+ */
233
+ export declare function warnAgentDeprecated(agent: AgentId): void;
222
234
  /** Format an error message for an unrecognized agent name, listing valid options. */
223
235
  export declare function formatAgentError(agentName: string, validAgents?: AgentId[]): string;
224
236
  export {};
@@ -2,7 +2,7 @@
2
2
  * Core agent configuration and detection module.
3
3
  *
4
4
  * Defines the canonical registry of all supported AI coding agents (Claude, Codex,
5
- * Gemini, Cursor, OpenCode, OpenClaw, Copilot, Amp, Kiro, Goose, Roo, Grok) with their
5
+ * Gemini, Cursor, OpenCode, OpenClaw, Copilot, Amp, Kiro, Goose, Grok) with their
6
6
  * CLI commands, config paths, capability flags, and MCP integration points.
7
7
  *
8
8
  * Provides functions for detecting installed CLIs, resolving version-managed binaries,
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import { execFile } from 'child_process';
12
12
  import { promisify } from 'util';
13
+ import * as crypto from 'crypto';
13
14
  import * as fs from 'fs';
14
15
  import * as path from 'path';
15
16
  import * as os from 'os';
@@ -249,6 +250,16 @@ export const AGENTS = {
249
250
  variableSyntax: '{{args}}',
250
251
  supportsHooks: true,
251
252
  nativeAgentsSkillsDir: true,
253
+ // Google retired the Gemini CLI (announced at Google I/O 2026, May 19); the `gemini`
254
+ // command stopped serving free/Pro/Ultra requests on June 18, 2026. Antigravity CLI
255
+ // (`agy`) is the official successor. See warnAgentDeprecated() for the surfaced warning.
256
+ deprecated: {
257
+ by: 'Google',
258
+ date: 'June 18, 2026',
259
+ reason: 'The Gemini CLI was retired for free, Pro, and Ultra tiers and no longer serves requests (announced at Google I/O 2026 on May 19).',
260
+ replacement: 'antigravity',
261
+ url: 'https://developers.googleblog.com/an-important-update-transitioning-gemini-cli-to-antigravity-cli/',
262
+ },
252
263
  // gemini hooks: shipped in v0.26.0 (Jan 2026); older binaries silently ignore the `hooks` key.
253
264
  capabilities: { hooks: { since: '0.26.0' }, mcp: true, allowlist: false, skills: true, commands: true, plugins: false, subagents: false, rules: { file: 'GEMINI.md' }, workflows: false, modes: ['plan', 'edit', 'skip'], rulesImports: true },
254
265
  },
@@ -375,24 +386,6 @@ export const AGENTS = {
375
386
  supportsHooks: false,
376
387
  capabilities: { hooks: false, mcp: true, allowlist: false, skills: false, commands: false, plugins: false, subagents: false, rules: { file: 'AGENTS.md' }, workflows: false, modes: ['edit'] },
377
388
  },
378
- roo: {
379
- id: 'roo',
380
- name: 'Roo Code',
381
- color: 'cyanBright',
382
- cliCommand: 'roo',
383
- npmPackage: '',
384
- installScript: 'curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh',
385
- configDir: path.join(HOME, '.roo'),
386
- commandsDir: path.join(HOME, '.roo', 'commands'),
387
- commandsSubdir: 'commands',
388
- skillsDir: path.join(HOME, '.roo', 'skills'),
389
- hooksDir: 'hooks',
390
- instructionsFile: 'AGENTS.md',
391
- format: 'markdown',
392
- variableSyntax: '$ARGUMENTS',
393
- supportsHooks: false,
394
- capabilities: { hooks: false, mcp: true, allowlist: false, skills: true, commands: true, plugins: false, subagents: false, rules: { file: 'AGENTS.md' }, workflows: false, modes: ['plan', 'edit'] },
395
- },
396
389
  // Google Antigravity CLI (`agy`) — official replacement for Gemini CLI as of IO 2026.
397
390
  // configDir nests inside `~/.gemini/` since agy shares the parent dir with the Gemini
398
391
  // CLI but isolates its own state in the `antigravity-cli/` subdir. Per-version HOME
@@ -802,6 +795,52 @@ function resolveAccountCredentialPath(base, ...segments) {
802
795
  }
803
796
  return null;
804
797
  }
798
+ /**
799
+ * Factory Droid stores its OAuth credential encrypted at ~/.factory/auth.v2.file
800
+ * (AES-256-GCM, format `ivB64:tagB64:ctB64`) with the 32-byte key base64-stored
801
+ * in ~/.factory/auth.v2.key. On the keyfile-v2 source there is no OS-keychain /
802
+ * device binding — the key is on disk — so we can decrypt locally with no
803
+ * network call. The decrypted JSON credential's `access_token` is a WorkOS JWT
804
+ * carrying an `email` claim (plus org_id / role). We decode the claim WITHOUT
805
+ * verifying `exp`: the email is stable identity for display, not an
806
+ * authorization decision, so an expired token still yields the right address.
807
+ * Every failure (missing key file — e.g. a keyring-v2/legacy login with no
808
+ * on-disk key, a bad GCM tag, malformed JSON, or no email claim) returns null so
809
+ * the caller falls back to the file-presence signed-in signal. Never throws.
810
+ */
811
+ function decryptDroidCredential(base) {
812
+ const filePath = resolveAccountCredentialPath(base, '.factory', 'auth.v2.file');
813
+ const keyPath = resolveAccountCredentialPath(base, '.factory', 'auth.v2.key');
814
+ if (!filePath || !keyPath)
815
+ return null;
816
+ try {
817
+ const blob = fs.readFileSync(filePath, 'utf-8').trim();
818
+ const key = Buffer.from(fs.readFileSync(keyPath, 'utf-8').trim(), 'base64');
819
+ if (key.length !== 32)
820
+ return null;
821
+ const [ivB64, tagB64, ctB64] = blob.split(':');
822
+ if (!ivB64 || !tagB64 || !ctB64)
823
+ return null;
824
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(ivB64, 'base64'));
825
+ decipher.setAuthTag(Buffer.from(tagB64, 'base64'));
826
+ const plaintext = Buffer.concat([
827
+ decipher.update(Buffer.from(ctB64, 'base64')),
828
+ decipher.final(),
829
+ ]).toString('utf-8');
830
+ const cred = JSON.parse(plaintext);
831
+ const claims = typeof cred?.access_token === 'string' ? decodeJwtPayload(cred.access_token) : null;
832
+ if (!claims)
833
+ return null;
834
+ return {
835
+ email: typeof claims.email === 'string' ? claims.email : null,
836
+ orgId: normalizeIdentityPart(claims.org_id ?? cred.active_organization_id),
837
+ role: typeof claims.role === 'string' ? claims.role : null,
838
+ };
839
+ }
840
+ catch {
841
+ return null;
842
+ }
843
+ }
805
844
  let cachedAgyKeychainSignedIn;
806
845
  /**
807
846
  * Antigravity (`agy`, a Codeium/Windsurf-based CLI) stores its OAuth token in
@@ -1034,12 +1073,29 @@ export async function getAccountInfo(agentId, home) {
1034
1073
  return { ...empty, signedIn: true, accountId: userId, accountKey, lastActive };
1035
1074
  }
1036
1075
  case 'droid': {
1037
- // Factory Droid stores auth at ~/.factory/auth.v2.file (+ auth.v2.key,
1038
- // an encrypted blob). No email/JWT is readable locally, so presence of
1039
- // the auth file is the only signed-in signal we can derive without a
1040
- // network call same pattern as antigravity/kimi. `.factory` is the
1041
- // config dir on every platform (macOS/Linux ~/.factory, Windows
1042
- // %USERPROFILE%\.factory), so path.join keeps this cross-platform.
1076
+ // Factory Droid stores auth at ~/.factory/auth.v2.file (AES-256-GCM,
1077
+ // decrypted with the on-disk ~/.factory/auth.v2.key). We decrypt locally
1078
+ // no network and surface the email/org/role from the WorkOS
1079
+ // access-token JWT, same as claude/codex/grok. If the credential can't be
1080
+ // decrypted (a keyring-v2/legacy login with no on-disk key, or a decrypt
1081
+ // failure) we fall back to the file-presence signed-in signal so the row
1082
+ // still reads as logged in — the conservative floor antigravity/kimi use.
1083
+ // `.factory` is the config dir on every platform (macOS/Linux
1084
+ // ~/.factory, Windows %USERPROFILE%\.factory).
1085
+ const decoded = decryptDroidCredential(base);
1086
+ if (decoded?.email) {
1087
+ const organizationId = decoded.orgId;
1088
+ const accountKey = buildIdentityKey(agentId, [['org', organizationId]]);
1089
+ return {
1090
+ ...empty,
1091
+ email: decoded.email,
1092
+ organizationId,
1093
+ accountId: organizationId,
1094
+ accountKey,
1095
+ signedIn: true,
1096
+ lastActive,
1097
+ };
1098
+ }
1043
1099
  const authPath = resolveAccountCredentialPath(base, '.factory', 'auth.v2.file');
1044
1100
  if (!authPath)
1045
1101
  return { ...empty, lastActive };
@@ -1574,8 +1630,6 @@ export function getMcpConfigPathForHome(agentId, home) {
1574
1630
  return path.join(home, '.kiro', 'settings', 'mcp.json');
1575
1631
  case 'goose':
1576
1632
  return path.join(home, '.config', 'goose', 'config.yaml');
1577
- case 'roo':
1578
- return path.join(home, '.roo', 'mcp.json');
1579
1633
  case 'antigravity':
1580
1634
  return path.join(home, '.gemini', 'antigravity-cli', 'mcp_config.json');
1581
1635
  case 'grok':
@@ -1612,8 +1666,6 @@ function getProjectMcpConfigPath(agentId, cwd = process.cwd()) {
1612
1666
  return path.join(cwd, '.kiro', 'settings', 'mcp.json');
1613
1667
  case 'goose':
1614
1668
  return path.join(cwd, '.goose', 'config.yaml');
1615
- case 'roo':
1616
- return path.join(cwd, '.roo', 'mcp.json');
1617
1669
  case 'antigravity':
1618
1670
  return path.join(cwd, '.gemini', 'antigravity-cli', 'mcp_config.json');
1619
1671
  case 'grok':
@@ -1746,9 +1798,6 @@ export const AGENT_NAME_ALIASES = {
1746
1798
  'kiro-cli': 'kiro',
1747
1799
  goose: 'goose',
1748
1800
  'block-goose': 'goose',
1749
- roo: 'roo',
1750
- 'roo-code': 'roo',
1751
- roocode: 'roo',
1752
1801
  antigravity: 'antigravity',
1753
1802
  'google-antigravity': 'antigravity',
1754
1803
  agy: 'antigravity',
@@ -1789,6 +1838,40 @@ export function resolveAgentName(input) {
1789
1838
  export function isAgentName(input) {
1790
1839
  return resolveAgentName(input) !== null;
1791
1840
  }
1841
+ /**
1842
+ * Build the deprecation notice lines for an agent, or null if it isn't
1843
+ * deprecated. Split from the printer so tests can assert the content without
1844
+ * capturing stdout. Lines are plain (uncolored) text.
1845
+ */
1846
+ export function deprecationNotice(agent) {
1847
+ const dep = AGENTS[agent].deprecated;
1848
+ if (!dep)
1849
+ return null;
1850
+ const name = AGENTS[agent].name;
1851
+ const lines = [
1852
+ `Warning: ${name} was deprecated by ${dep.by} (${dep.date}).`,
1853
+ ` ${dep.reason}`,
1854
+ ];
1855
+ if (dep.replacement) {
1856
+ const rep = AGENTS[dep.replacement];
1857
+ lines.push(` Consider using ${rep.name} instead: agents add ${rep.id}`);
1858
+ }
1859
+ if (dep.url)
1860
+ lines.push(` ${dep.url}`);
1861
+ return lines;
1862
+ }
1863
+ /**
1864
+ * Print a deprecation warning (yellow) if the agent's registry entry carries a
1865
+ * `deprecated` marker; no-op otherwise. Call from any user entry point that
1866
+ * acts on a chosen agent — install (`agents add`) and `agents teams add`.
1867
+ */
1868
+ export function warnAgentDeprecated(agent) {
1869
+ const lines = deprecationNotice(agent);
1870
+ if (!lines)
1871
+ return;
1872
+ for (const line of lines)
1873
+ console.log(chalk.yellow(line));
1874
+ }
1792
1875
  /** Format an error message for an unrecognized agent name, listing valid options. */
1793
1876
  export function formatAgentError(agentName, validAgents = ALL_AGENT_IDS) {
1794
1877
  return `Unknown agent '${agentName}'. Valid agents: ${validAgents.join(', ')}`;
@@ -51,6 +51,7 @@ const BROWSER_PATHS = {
51
51
  edge: [
52
52
  `${WIN_PROGRAMFILES}\\Microsoft\\Edge\\Application\\msedge.exe`,
53
53
  `${WIN_PROGRAMFILES_X86}\\Microsoft\\Edge\\Application\\msedge.exe`,
54
+ `${WIN_LOCALAPPDATA}\\Microsoft\\Edge\\Application\\msedge.exe`,
54
55
  ],
55
56
  custom: [],
56
57
  },
@@ -282,6 +283,25 @@ export async function attachToChrome(port) {
282
283
  return wsUrl;
283
284
  }
284
285
  export function killChrome(pid) {
286
+ if (process.platform === 'win32') {
287
+ // On Windows `process.kill(pid, 'SIGINT')` maps to TerminateProcess — a
288
+ // hard kill that skips Chromium's on-exit profile flush (leaving a dirty
289
+ // "Chrome didn't shut down correctly" state). Ask taskkill for a graceful
290
+ // stop first (no /F → posts WM_CLOSE), and only force-kill the tree if that
291
+ // fails or the process is still around.
292
+ try {
293
+ execFileSync('taskkill', ['/pid', String(pid)], { stdio: 'ignore' });
294
+ }
295
+ catch {
296
+ try {
297
+ execFileSync('taskkill', ['/pid', String(pid), '/t', '/f'], { stdio: 'ignore' });
298
+ }
299
+ catch {
300
+ // Process already dead
301
+ }
302
+ }
303
+ return;
304
+ }
285
305
  try {
286
306
  process.kill(pid, 'SIGINT');
287
307
  }
@@ -54,3 +54,22 @@ export declare function buildLaunchCmd(remoteOs: RemoteOs, browserType: string,
54
54
  */
55
55
  export declare function buildKillCmd(remoteOs: RemoteOs, port: number): string;
56
56
  export declare function restartRemoteBrowser(user: string, host: string, browserType: string, port: number, remoteOs: RemoteOs, customBinary?: string): Promise<void>;
57
+ /**
58
+ * Identify whether a pid listening on our target local port is an SSH
59
+ * tunnel WE would have spawned for `host:remotePort`. Used so that two
60
+ * agents-browser invocations of the same SSH profile share a tunnel
61
+ * rather than failing the second one with "port in use".
62
+ *
63
+ * Best-effort match against the ssh -L command line via `ps`. If we
64
+ * can't read the cmd or the args don't look like ours, treat as not-ours.
65
+ *
66
+ * Windows has no `ps`, so the POSIX branch below always throws there and every
67
+ * reuse check returned false — the second invocation to the same host:port then
68
+ * failed "port in use". On win32 we instead reuse the netstat -ano + tasklist
69
+ * occupant lookup (getPortOccupant): the tunnel binds `remotePort` locally
70
+ * (localPort === remotePort in the caller), so the pid holding that port is our
71
+ * ssh.exe. tasklist only exposes the image name, not the full command line, so
72
+ * we can't match host/remotePort as tightly as the POSIX branch — an ssh
73
+ * process on our exact local port is our tunnel in practice.
74
+ */
75
+ export declare function isOwnTunnel(pid: number, host: string, remotePort: number): boolean;
@@ -12,6 +12,7 @@ export { shellQuote };
12
12
  // the single ssh-tunnel helper. Calling it with no options preserves this
13
13
  // driver's original foreground, stderr-captured behavior exactly.
14
14
  import { startSSHTunnel } from '../../ssh-tunnel.js';
15
+ import { encodePwshBase64 } from '../../pwsh.js';
15
16
  export async function connectSSH(endpoint, profile) {
16
17
  const url = new URL(endpoint);
17
18
  if (url.protocol !== 'ssh:') {
@@ -159,8 +160,7 @@ function psSingleQuote(s) {
159
160
  * `powershell -Command "…"` is fragile the moment a path or URL is involved).
160
161
  */
161
162
  export function encodePowerShell(script) {
162
- const b64 = Buffer.from(script, 'utf16le').toString('base64');
163
- return `powershell -NoProfile -EncodedCommand ${b64}`;
163
+ return `powershell -NoProfile -EncodedCommand ${encodePwshBase64(script)}`;
164
164
  }
165
165
  /**
166
166
  * The PowerShell that launches the browser on a Windows remote. Two hard
@@ -290,8 +290,23 @@ function sleep(ms) {
290
290
  *
291
291
  * Best-effort match against the ssh -L command line via `ps`. If we
292
292
  * can't read the cmd or the args don't look like ours, treat as not-ours.
293
+ *
294
+ * Windows has no `ps`, so the POSIX branch below always throws there and every
295
+ * reuse check returned false — the second invocation to the same host:port then
296
+ * failed "port in use". On win32 we instead reuse the netstat -ano + tasklist
297
+ * occupant lookup (getPortOccupant): the tunnel binds `remotePort` locally
298
+ * (localPort === remotePort in the caller), so the pid holding that port is our
299
+ * ssh.exe. tasklist only exposes the image name, not the full command line, so
300
+ * we can't match host/remotePort as tightly as the POSIX branch — an ssh
301
+ * process on our exact local port is our tunnel in practice.
293
302
  */
294
- function isOwnTunnel(pid, host, remotePort) {
303
+ export function isOwnTunnel(pid, host, remotePort) {
304
+ if (process.platform === 'win32') {
305
+ const occupant = getPortOccupant(remotePort);
306
+ if (!occupant || occupant.pid !== pid)
307
+ return false;
308
+ return occupant.command.toLowerCase().startsWith('ssh');
309
+ }
295
310
  try {
296
311
  const out = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
297
312
  encoding: 'utf-8',
@@ -29,6 +29,8 @@ import { pluginInstallDir, repairableManifestFields } from './plugin-marketplace
29
29
  import { markdownToToml } from './convert.js';
30
30
  import { resolveImports, supportsRulesImports } from './rules/compile.js';
31
31
  import { listCommandsInVersionHome, getVersionCommandsDir } from './commands.js';
32
+ import { shouldInstallCommandAsSkill, commandSkillMatches, commandSkillName } from './command-skills.js';
33
+ import { supports } from './capabilities.js';
32
34
  import { listSkillsInVersionHome, getVersionSkillsDir } from './skills.js';
33
35
  import { listHooksInVersionHome, listHookEntriesFromDir } from './hooks.js';
34
36
  const RULES_DOC_FILENAME = 'README.md';
@@ -87,6 +89,15 @@ function diffCommands(agent, version, cwd, excludeProject = false) {
87
89
  const isToml = agentConfig.format === 'toml';
88
90
  const ext = isToml ? '.toml' : '.md';
89
91
  const homeDir = getVersionCommandsDir(agent, version);
92
+ // Command-as-skill agents (kimi, codex>=0.117, grok) install every command as a
93
+ // SKILL wrapper at <agentDir>/skills/<cmd>/SKILL.md, not a native command file.
94
+ // The compare must follow that or every command false-reports as drifted.
95
+ const asSkill = shouldInstallCommandAsSkill(agent, version);
96
+ // Agents that hold commands neither natively nor as skills (e.g. goose) must not
97
+ // report source commands as "missing" — they structurally can't take them.
98
+ if (!asSkill && !supports(agent, 'commands', version).ok)
99
+ return [];
100
+ const agentDir = path.join(getVersionHomePath(agent, version), agentConfigDirName(agent));
90
101
  const installed = new Set(listCommandsInVersionHome(agent, version));
91
102
  const layerBases = buildLayerBases(cwd, 'commands', { excludeProject });
92
103
  const sourceByName = new Map();
@@ -113,11 +124,24 @@ function diffCommands(agent, version, cwd, excludeProject = false) {
113
124
  const seen = new Set();
114
125
  for (const [name, src] of sourceByName) {
115
126
  seen.add(name);
116
- const homePath = path.join(homeDir, `${name}${ext}`);
117
127
  if (!installed.has(name)) {
118
128
  rows.push({ kind: 'commands', name, status: 'missing', source: src.layer, sourcePath: src.path });
119
129
  continue;
120
130
  }
131
+ if (asSkill) {
132
+ // Compare against the installed command-skill wrapper, not a native path.
133
+ const matches = commandSkillMatches(agentDir, name, src.path);
134
+ rows.push({
135
+ kind: 'commands',
136
+ name,
137
+ status: matches ? 'ok' : 'diff',
138
+ source: src.layer,
139
+ sourcePath: src.path,
140
+ homePath: path.join(agentDir, 'skills', commandSkillName(name), 'SKILL.md'),
141
+ });
142
+ continue;
143
+ }
144
+ const homePath = path.join(homeDir, `${name}${ext}`);
121
145
  const installedContent = readSafe(homePath);
122
146
  const sourceContent = readSafe(src.path);
123
147
  if (installedContent == null || sourceContent == null) {
@@ -138,7 +162,10 @@ function diffCommands(agent, version, cwd, excludeProject = false) {
138
162
  for (const name of installed) {
139
163
  if (seen.has(name))
140
164
  continue;
141
- rows.push({ kind: 'commands', name, status: 'extra', homePath: path.join(homeDir, `${name}${ext}`) });
165
+ const extraHome = asSkill
166
+ ? path.join(agentDir, 'skills', commandSkillName(name), 'SKILL.md')
167
+ : path.join(homeDir, `${name}${ext}`);
168
+ rows.push({ kind: 'commands', name, status: 'extra', homePath: extraHome });
142
169
  }
143
170
  return rows.sort((a, b) => a.name.localeCompare(b.name));
144
171
  }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Interactive drift-sync flow — the single "we detected drift, want to fix it?"
3
+ * action, shared by `agents status`, `agents doctor`, and the menu-bar "NEEDS
4
+ * SYNC" row.
5
+ *
6
+ * It composes existing pieces, re-implementing nothing:
7
+ * - computeSyncStatus() — the unified detection engine (sync-status.ts)
8
+ * - pullRepo() — fast-forward the `.system` repo (git.ts)
9
+ * - promptAgentVersionSelection() — the "which agent types / versions?" picker
10
+ * - heal({ mode: 'full' }) — the reconcile engine `doctor --fix` uses
11
+ *
12
+ * Combined flow (one confirmation): if `.system` is behind AND resources drifted,
13
+ * a single "Sync all detected" both pulls `.system` and reconciles the chosen
14
+ * version homes. The security posture is preserved — the `.system` pull only ever
15
+ * happens on an explicit user choice here, never silently (see auto-pull-worker.ts
16
+ * for why system auto-pull is off by default).
17
+ */
18
+ import { type VersionHealResult } from './heal.js';
19
+ import { type UnifiedSyncStatus } from './sync-status.js';
20
+ export interface DriftSyncOptions {
21
+ cwd?: string;
22
+ /** Reconcile everything detected with no prompts — the "kick it" path, also the
23
+ * non-TTY / menu-bar-launched-with-flag behavior. Pulls `.system` if behind. */
24
+ yes?: boolean;
25
+ /** Pre-computed status, to avoid a second scan when the caller already has one. */
26
+ status?: UnifiedSyncStatus;
27
+ /** Skip the drift summary — set by callers that already printed their own. */
28
+ quiet?: boolean;
29
+ }
30
+ export interface DriftSyncResult {
31
+ systemBehindBefore: number;
32
+ systemPulled: boolean;
33
+ healed: VersionHealResult[];
34
+ /** True when the user declined at the prompt (nothing was changed). */
35
+ cancelled: boolean;
36
+ /** True when there was no drift to act on in the first place. */
37
+ nothingToDo: boolean;
38
+ }
39
+ /**
40
+ * The unified "drift detected — sync now?" flow. Returns a structured result so
41
+ * callers (menu-bar, doctor) can report without re-scanning.
42
+ */
43
+ export declare function promptDriftSync(opts?: DriftSyncOptions): Promise<DriftSyncResult>;