@phnx-labs/agents-cli 1.20.87 → 1.20.88

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 (52) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +3 -3
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/doctor.d.ts +0 -19
  5. package/dist/commands/doctor.js +219 -305
  6. package/dist/commands/exec.js +7 -19
  7. package/dist/commands/inspect.js +3 -5
  8. package/dist/commands/routines.js +2 -2
  9. package/dist/commands/sessions.js +1 -0
  10. package/dist/commands/ssh.js +3 -3
  11. package/dist/commands/usage.d.ts +3 -2
  12. package/dist/commands/usage.js +2 -9
  13. package/dist/lib/agents.d.ts +31 -1
  14. package/dist/lib/agents.js +55 -0
  15. package/dist/lib/command-skills.d.ts +10 -0
  16. package/dist/lib/command-skills.js +14 -0
  17. package/dist/lib/commands.js +19 -1
  18. package/dist/lib/daemon.js +17 -2
  19. package/dist/lib/devices/doctor-findings.d.ts +167 -0
  20. package/dist/lib/devices/doctor-findings.js +893 -0
  21. package/dist/lib/devices/fleet-divergence.d.ts +22 -0
  22. package/dist/lib/devices/fleet-divergence.js +34 -10
  23. package/dist/lib/devices/fleet-inventory.d.ts +17 -6
  24. package/dist/lib/devices/fleet-inventory.js +56 -8
  25. package/dist/lib/exec.d.ts +14 -3
  26. package/dist/lib/exec.js +41 -8
  27. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  28. package/dist/lib/project-resources.js +34 -20
  29. package/dist/lib/runner.d.ts +14 -1
  30. package/dist/lib/runner.js +37 -8
  31. package/dist/lib/sandbox.d.ts +2 -0
  32. package/dist/lib/sandbox.js +38 -0
  33. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  34. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  35. package/dist/lib/secrets/rc-hygiene.d.ts +0 -6
  36. package/dist/lib/secrets/rc-hygiene.js +0 -24
  37. package/dist/lib/session/active.d.ts +6 -6
  38. package/dist/lib/session/active.js +6 -6
  39. package/dist/lib/session/discover.d.ts +5 -0
  40. package/dist/lib/session/discover.js +137 -1
  41. package/dist/lib/session/parse.d.ts +2 -0
  42. package/dist/lib/session/parse.js +76 -37
  43. package/dist/lib/session/sync/agents.js +0 -0
  44. package/dist/lib/session/types.d.ts +1 -1
  45. package/dist/lib/session/types.js +1 -1
  46. package/dist/lib/staleness/detectors/commands.js +14 -5
  47. package/dist/lib/staleness/types.d.ts +2 -0
  48. package/dist/lib/staleness/writers/commands.js +13 -7
  49. package/dist/lib/usage.d.ts +72 -1
  50. package/dist/lib/usage.js +21 -27
  51. package/dist/lib/versions.js +30 -13
  52. package/package.json +1 -1
@@ -1667,7 +1667,7 @@ export function registerRunCommand(program) {
1667
1667
  const { pickSessionInteractive } = await import('./sessions.js');
1668
1668
  const { buildContinuePrompt } = await import('../lib/loop.js');
1669
1669
  // Freshen the index for this agent before any lookup (incremental, cached).
1670
- // AgentId is wider than SessionAgentId (cursor/amp/… keep no transcripts);
1670
+ // AgentId is wider than SessionAgentId (amp/kiro/goose/copilot keep no transcripts);
1671
1671
  // those simply yield no matches and fall through to the not-found error.
1672
1672
  const sessionAgent = agent;
1673
1673
  await discoverSessions({ agent: sessionAgent, version });
@@ -1899,8 +1899,8 @@ export function registerRunCommand(program) {
1899
1899
  // safest native mode (modes[0], typically edit). That covers both the
1900
1900
  // implicit default and an explicit `--mode plan`, so multi-agent
1901
1901
  // scripts can pass a uniform plan flag without per-agent branching.
1902
- // Elevation is never silent: we warn on stderr (yellow when the user
1903
- // explicitly asked for plan; gray for the implicit default / auto).
1902
+ // Mode degradation is never silent: buildExecCommand emits one stderr
1903
+ // warning for the requested-to-resolved transition unless --quiet is set.
1904
1904
  // `skip` still hard-fails when unsupported — pretending we bypassed
1905
1905
  // permissions would be unsafe.
1906
1906
  const modeIsDefault = modeSource === 'default';
@@ -1913,20 +1913,7 @@ export function registerRunCommand(program) {
1913
1913
  console.error(chalk.red(err.message));
1914
1914
  process.exit(1);
1915
1915
  }
1916
- if (resolvedMode !== requestedMode) {
1917
- mode = resolvedMode;
1918
- if (!options.quiet) {
1919
- if (requestedMode === 'plan' && !modeIsDefault) {
1920
- process.stderr.write(chalk.yellow(`[agents] ${agent} has no read-only 'plan' mode; using '${mode}' (writable) instead. Pass --mode ${mode} to silence this.\n`));
1921
- }
1922
- else {
1923
- process.stderr.write(chalk.gray(`[agents] ${agent} has no '${requestedMode}' mode; using '${mode}'\n`));
1924
- }
1925
- }
1926
- }
1927
- else {
1928
- mode = resolvedMode;
1929
- }
1916
+ mode = resolvedMode;
1930
1917
  // Fail fast on the headless-plan stall footgun: a slash command run
1931
1918
  // headless under the implicit default 'plan' mode hangs forever at
1932
1919
  // ExitPlanMode (no TTY to approve the plan). Tell the user how to fix it
@@ -1935,7 +1922,7 @@ export function registerRunCommand(program) {
1935
1922
  const stallCmd = headlessPlanStallCommand({
1936
1923
  prompt,
1937
1924
  interactive: options.interactive,
1938
- mode,
1925
+ mode: resolvedMode,
1939
1926
  modeIsDefault,
1940
1927
  });
1941
1928
  if (stallCmd) {
@@ -2029,7 +2016,7 @@ export function registerRunCommand(program) {
2029
2016
  version,
2030
2017
  prompt,
2031
2018
  interactive: options.interactive || forceInteractive,
2032
- mode,
2019
+ mode: requestedMode,
2033
2020
  effort,
2034
2021
  cwd: options.cwd,
2035
2022
  model,
@@ -2040,6 +2027,7 @@ export function registerRunCommand(program) {
2040
2027
  name: options.name,
2041
2028
  resume: resumeNative,
2042
2029
  verbose: options.verbose,
2030
+ modeWarningState: { quiet: options.quiet },
2043
2031
  // --raw, --no-tmux (commander negation → options.tmux === false), and
2044
2032
  // --disable-tmux all bypass the interactive tmux wrapper. AGENTS_NO_TMUX=1
2045
2033
  // does the same via the env check in exec.ts.
@@ -32,6 +32,7 @@ import { listMcpServerConfigs, discoverMcpConfigsFromRepo } from '../lib/mcp.js'
32
32
  import { discoverPlugins, discoverPluginsInDir, pluginResourceGroups } from '../lib/plugins.js';
33
33
  import { PLUGIN_GROUP_COLORS } from './plugins.js';
34
34
  import { countSessionsInScope } from '../lib/session/discover.js';
35
+ import { isSessionTrackedAgent } from '../lib/session/types.js';
35
36
  import { damerauLevenshtein } from '../lib/fuzzy.js';
36
37
  import { terminalWidth, truncateToWidth, stringWidth, stripAnsi } from '../lib/session/width.js';
37
38
  /** Resource kinds the inspect command can drill into. */
@@ -1256,14 +1257,11 @@ function safeStat(p) {
1256
1257
  return null;
1257
1258
  }
1258
1259
  }
1259
- const SESSION_AGENTS = new Set([
1260
- 'claude', 'codex', 'gemini', 'opencode', 'openclaw', 'rush', 'hermes', 'grok', 'kimi', 'droid',
1261
- ]);
1262
1260
  function safeCountSessions(agent) {
1263
- if (!SESSION_AGENTS.has(agent))
1261
+ if (!isSessionTrackedAgent(agent))
1264
1262
  return 0;
1265
1263
  try {
1266
- return countSessionsInScope({ agent: agent });
1264
+ return countSessionsInScope({ agent });
1267
1265
  }
1268
1266
  catch {
1269
1267
  return 0;
@@ -19,7 +19,7 @@ import { fireWebhookJobs, matchJobsToWebhook } from '../lib/triggers/webhook.js'
19
19
  import { getRoutinesDir } from '../lib/state.js';
20
20
  import { IS_WINDOWS } from '../lib/platform/index.js';
21
21
  import { safeJoin } from '../lib/paths.js';
22
- import { executeJob, executeJobDetached, monitorRunningJobs } from '../lib/runner.js';
22
+ import { executeJob, executeJobDetached, monitorRunningJobs, ROUTINE_AGENT_IDS } from '../lib/runner.js';
23
23
  import { JobScheduler } from '../lib/scheduler.js';
24
24
  import { detectOverdueJobs } from '../lib/overdue.js';
25
25
  import { isInteractiveTerminal, requireInteractiveSelection } from './utils.js';
@@ -572,7 +572,7 @@ export function registerRoutinesCommands(program) {
572
572
  .command('add [nameOrPath]')
573
573
  .description('Create a new routine from a YAML file or inline flags. Starts the scheduler automatically if it is not already running.')
574
574
  .option('-s, --schedule <cron>', 'Cron schedule in standard format (5 fields: minute hour day month weekday)')
575
- .option('-a, --agent <agent>', 'Which agent runs this routine: claude, codex, antigravity, cursor, or opencode')
575
+ .option('-a, --agent <agent>', `Which agent runs this routine: ${ROUTINE_AGENT_IDS.join(', ')}`)
576
576
  .option('--workflow <name>', 'Run an installed workflow (~/.agents/workflows/<name>) via `agents run`. Mutually exclusive with --agent.')
577
577
  .option('--command <sh>', 'Run a plain shell command directly (no agent, no auth, no sandbox) — for deterministic housekeeping routines. Mutually exclusive with --agent and --workflow; --prompt is not used.')
578
578
  .option('-p, --prompt <prompt>', 'Task instruction for the agent')
@@ -2368,6 +2368,7 @@ export function buildResumeCommand(session) {
2368
2368
  case 'grok':
2369
2369
  case 'kimi':
2370
2370
  case 'droid':
2371
+ case 'cursor':
2371
2372
  // Grok (and some others) sessions are captured artifacts, not resumable the same way.
2372
2373
  return null;
2373
2374
  }
@@ -313,7 +313,7 @@ function printFleetResults(results) {
313
313
  if (failed > 0)
314
314
  process.exitCode = 1;
315
315
  }
316
- function localHealthRow(self, stats) {
316
+ async function localHealthRow(self, stats) {
317
317
  return {
318
318
  name: self,
319
319
  platform: process.platform === 'darwin' ? 'macos' : process.platform,
@@ -324,7 +324,7 @@ function localHealthRow(self, stats) {
324
324
  orphans: countOrphans(),
325
325
  // Local baseline inventory for cross-device divergence (RUSH-2027) — the
326
326
  // yardstick every remote box is compared against.
327
- inventory: collectLocalFleetInventory(process.cwd()),
327
+ inventory: await collectLocalFleetInventory(process.cwd()),
328
328
  };
329
329
  }
330
330
  async function probeRemoteHealth(target) {
@@ -369,7 +369,7 @@ async function runFleetStatus(opts) {
369
369
  // read from a fresh probe, not a stale tailscale snapshot (RUSH-1965).
370
370
  // Best-effort: a registry write must never break the status render.
371
371
  await writeReachability(collectReachabilityWriteBacks(reg, statsMap)).catch(() => { });
372
- const rows = [localHealthRow(self, statsMap.get(self))];
372
+ const rows = [await localHealthRow(self, statsMap.get(self))];
373
373
  const remoteTargets = remoteFleetTargets(planned, self)
374
374
  .map((t) => ({
375
375
  name: t.device.name,
@@ -6,8 +6,9 @@
6
6
  * - codex: parsed from latest session log's rate_limits event
7
7
  * - kimi: live Kimi Code /usages API call (cached for 2 minutes)
8
8
  * - droid: live Factory billing/limits API call (cached for 2 minutes)
9
- * - others: marked as "not exposed by CLI" (Gemini, OpenCode, Cursor, etc.
10
- * don't publish per-account usage today)
9
+ * - grok: parsed from the latest local usage event
10
+ * - cursor: live Cursor usage API call (cached for 2 minutes)
11
+ * - others: marked as "not exposed by CLI"
11
12
  */
12
13
  import type { Command } from 'commander';
13
14
  import type { AgentId } from '../lib/types.js';
@@ -2,14 +2,7 @@ import { addHostOption } from '../lib/hosts/option.js';
2
2
  import chalk from 'chalk';
3
3
  import { ALL_AGENT_IDS, AGENTS, getAccountInfo, agentLabel, resolveAgentName, formatAgentError, } from '../lib/agents.js';
4
4
  import { listInstalledVersions, getGlobalDefault, getVersionHomePath } from '../lib/versions.js';
5
- import { formatUsageSection, getUsageInfoForIdentity } from '../lib/usage.js';
6
- /**
7
- * Agents whose CLI surfaces usage data we can read today. Kept in sync with the
8
- * live/last-seen sources `getUsageInfo` dispatches on in `../lib/usage.js`
9
- * (claude, codex, kimi, droid) — an agent with a usage source but missing here
10
- * would wrongly print "does not publish usage data" for a signed-in account.
11
- */
12
- const USAGE_SUPPORTED = new Set(['claude', 'codex', 'kimi', 'droid']);
5
+ import { agentReportsUsage, formatUsageSection, getUsageInfoForIdentity } from '../lib/usage.js';
13
6
  export function registerUsageCommand(program) {
14
7
  addHostOption(program.command('usage [agent]'))
15
8
  .description('Show rate-limit / quota usage per agent')
@@ -55,7 +48,7 @@ async function collectAgentUsage(agentId) {
55
48
  // Plain name — color is applied only at text-render time (formatAgentUsage), so
56
49
  // `--json` never emits ANSI escapes in `label` (e.g. under FORCE_COLOR=1).
57
50
  const label = AGENTS[agentId].name;
58
- if (!USAGE_SUPPORTED.has(agentId)) {
51
+ if (!agentReportsUsage(agentId)) {
59
52
  return { agent: agentId, label, status: 'unsupported' };
60
53
  }
61
54
  const versions = listInstalledVersions(agentId);
@@ -165,7 +165,7 @@ export declare function formatClaudeOrgLabel(orgType: string | null | undefined)
165
165
  */
166
166
  export declare function accountOrgBadge(info?: Pick<AccountInfo, 'organizationType' | 'organizationName'> | null): string | null;
167
167
  /** Agents whose local credential formats expose enough state for account selection. */
168
- export declare const ACCOUNT_INSPECTION_AGENT_IDS: readonly ["claude", "codex", "gemini", "grok", "antigravity", "kimi", "droid", "opencode"];
168
+ export declare const ACCOUNT_INSPECTION_AGENT_IDS: readonly ["claude", "codex", "gemini", "cursor", "grok", "antigravity", "kimi", "droid", "opencode"];
169
169
  /** Whether agents-cli can determine this agent's per-version sign-in state. */
170
170
  export declare function supportsAccountInspection(agentId: AgentId): boolean;
171
171
  /**
@@ -176,6 +176,36 @@ export declare function supportsAccountInspection(agentId: AgentId): boolean;
176
176
  export declare function accountDisplayLabel(info?: Pick<AccountInfo, 'email' | 'accountId' | 'signedIn' | 'organizationType' | 'organizationName'> | null): string;
177
177
  /** Return the email address associated with the agent's auth config, or null. */
178
178
  export declare function getAccountEmail(agentId: AgentId, home?: string): Promise<string | null>;
179
+ /** Where an agent's credential file lives, split into the per-version copy and
180
+ * the active/global copy under the real HOME. */
181
+ export interface CredentialPresence {
182
+ /** The credential file exists inside the passed version home. */
183
+ perVersion: boolean;
184
+ /** The credential file exists under the active/global HOME (the one the login
185
+ * symlink actually targets), independent of the version home. */
186
+ active: boolean;
187
+ /** Whether agents-cli knows WHERE this agent's credential lives at all — i.e.
188
+ * the agent has an entry in {@link CREDENTIAL_FILE_SEGMENTS}. When false both
189
+ * probes are trivially false because there is nothing to look for, so absence
190
+ * is NOT evidence of a logout and no caller may treat it as provable.
191
+ *
192
+ * This is deliberately separate from `supportsAccountInspection`: the two
193
+ * registries move independently, and an agent has already been added to the
194
+ * inspection set without a credential path (cursor), which without this flag
195
+ * produced a false "logged out" critical for every installed version. */
196
+ knownLocation: boolean;
197
+ }
198
+ /**
199
+ * File-presence probe for an agent's credential, split by location: whether it
200
+ * exists in a SPECIFIC version home (`perVersion`) and whether it exists under
201
+ * the active/global HOME (`active`). A logged-out claim is only *provable* when
202
+ * BOTH are absent — a version that merely lacks its own copy but shares the
203
+ * global login is signed in, not logged out. Pure file existence; no decrypt,
204
+ * no network, no keychain prompt. Agents with no inspectable identity return
205
+ * `{ perVersion: false, active: false }` and must NEVER yield a provable-logout
206
+ * claim (the caller gates on {@link supportsAccountInspection}).
207
+ */
208
+ export declare function credentialPresence(agentId: AgentId, versionHome: string): CredentialPresence;
179
209
  /** Decrypted contents of Droid's auth.v2.file (subset we consume). */
180
210
  export interface DroidAuthPayload {
181
211
  access_token?: string;
@@ -1014,6 +1014,7 @@ export const ACCOUNT_INSPECTION_AGENT_IDS = [
1014
1014
  'claude',
1015
1015
  'codex',
1016
1016
  'gemini',
1017
+ 'cursor',
1017
1018
  'grok',
1018
1019
  'antigravity',
1019
1020
  'kimi',
@@ -1077,6 +1078,60 @@ function resolveAccountCredentialPath(base, ...segments) {
1077
1078
  }
1078
1079
  return null;
1079
1080
  }
1081
+ /**
1082
+ * The on-disk credential file(s) each account-inspectable agent authenticates
1083
+ * from, expressed as path segments under a home. Mirrors the exact files
1084
+ * {@link getAccountInfo} reads, so a presence check here matches what a real
1085
+ * launch would find. Each entry is a list of alternatives — the FIRST that
1086
+ * exists counts as present (claude writes either `.claude/.claude.json` under
1087
+ * the shimmed config dir or a home-level `.claude.json`). Agents whose login is
1088
+ * stored only in the OS keychain on some platforms (antigravity, and claude's
1089
+ * token) still expose a credential FILE — the presence of that file is the
1090
+ * signal we key off; its absence on BOTH the per-version home and the active
1091
+ * home is what makes a logged-out claim provable.
1092
+ */
1093
+ const CREDENTIAL_FILE_SEGMENTS = {
1094
+ claude: [['.claude', '.claude.json'], ['.claude.json']],
1095
+ codex: [['.codex', 'auth.json']],
1096
+ gemini: [['.gemini', 'google_accounts.json']],
1097
+ grok: [['.grok', 'auth.json']],
1098
+ kimi: [['.kimi-code', 'credentials', 'kimi-code.json']],
1099
+ droid: [['.factory', 'auth.v2.file']],
1100
+ antigravity: [['.gemini', 'antigravity-cli', 'antigravity-oauth-token']],
1101
+ opencode: [['.local', 'share', 'opencode', 'auth.json']],
1102
+ };
1103
+ /** Whether an agent's credential file exists under a given home. */
1104
+ function credentialFileExistsUnder(agentId, home) {
1105
+ const alternatives = CREDENTIAL_FILE_SEGMENTS[agentId];
1106
+ if (!alternatives)
1107
+ return false;
1108
+ for (const segments of alternatives) {
1109
+ const p = path.join(home, ...segments);
1110
+ try {
1111
+ if (fs.existsSync(p))
1112
+ return true;
1113
+ }
1114
+ catch { /* unreadable */ }
1115
+ }
1116
+ return false;
1117
+ }
1118
+ /**
1119
+ * File-presence probe for an agent's credential, split by location: whether it
1120
+ * exists in a SPECIFIC version home (`perVersion`) and whether it exists under
1121
+ * the active/global HOME (`active`). A logged-out claim is only *provable* when
1122
+ * BOTH are absent — a version that merely lacks its own copy but shares the
1123
+ * global login is signed in, not logged out. Pure file existence; no decrypt,
1124
+ * no network, no keychain prompt. Agents with no inspectable identity return
1125
+ * `{ perVersion: false, active: false }` and must NEVER yield a provable-logout
1126
+ * claim (the caller gates on {@link supportsAccountInspection}).
1127
+ */
1128
+ export function credentialPresence(agentId, versionHome) {
1129
+ const realHome = process.env.AGENTS_REAL_HOME || os.homedir();
1130
+ const perVersion = credentialFileExistsUnder(agentId, versionHome);
1131
+ const active = credentialFileExistsUnder(agentId, realHome);
1132
+ const knownLocation = (CREDENTIAL_FILE_SEGMENTS[agentId]?.length ?? 0) > 0;
1133
+ return { perVersion, active, knownLocation };
1134
+ }
1080
1135
  /**
1081
1136
  * Factory Droid stores its OAuth credential encrypted at ~/.factory/auth.v2.file
1082
1137
  * (AES-256-GCM, format `ivB64:tagB64:ctB64`) with the 32-byte key base64-stored
@@ -4,6 +4,16 @@
4
4
  */
5
5
  import type { AgentId } from './types.js';
6
6
  export declare function shouldInstallCommandAsSkill(agent: AgentId, version: string): boolean;
7
+ /**
8
+ * Agents whose native command files serve a separate surface while their CLI
9
+ * consumes the generated skill form. Keep this registry distinct from
10
+ * `shouldInstallCommandAsSkill`: these targets need both writes, not a format
11
+ * replacement.
12
+ */
13
+ export declare const COMMAND_SKILL_DUAL_WRITE_TARGETS: {
14
+ cursor: true;
15
+ };
16
+ export declare function shouldAlsoInstallCommandAsSkill(agent: AgentId, version: string): boolean;
7
17
  export declare function commandSkillName(commandName: string): string;
8
18
  export declare function buildCommandSkillContent(commandName: string, sourcePath: string): string;
9
19
  export declare function skillSourceExists(skillName: string, skillSourceDirs: Array<string | null | undefined>): boolean;
@@ -52,6 +52,20 @@ function readSkillCommandMarker(skillMdPath) {
52
52
  export function shouldInstallCommandAsSkill(agent, version) {
53
53
  return !supports(agent, 'commands', version).ok && supports(agent, 'skills', version).ok;
54
54
  }
55
+ /**
56
+ * Agents whose native command files serve a separate surface while their CLI
57
+ * consumes the generated skill form. Keep this registry distinct from
58
+ * `shouldInstallCommandAsSkill`: these targets need both writes, not a format
59
+ * replacement.
60
+ */
61
+ export const COMMAND_SKILL_DUAL_WRITE_TARGETS = {
62
+ cursor: true,
63
+ };
64
+ export function shouldAlsoInstallCommandAsSkill(agent, version) {
65
+ return COMMAND_SKILL_DUAL_WRITE_TARGETS[agent] === true
66
+ && supports(agent, 'commands', version).ok
67
+ && supports(agent, 'skills', version).ok;
68
+ }
55
69
  export function commandSkillName(commandName) {
56
70
  return commandName;
57
71
  }
@@ -15,7 +15,7 @@ import { markdownToToml } from './convert.js';
15
15
  import { getCommandsDir, getUserCommandsDir, getEnabledExtraRepos, getProjectAgentsDir, getSkillsDir, getTrashCommandsDir } from './state.js';
16
16
  import { getEffectiveHome, getVersionHomePath, listInstalledVersions, resolveVersion } from './versions.js';
17
17
  import { discoverPlugins } from './plugins.js';
18
- import { commandSkillMatches, installCommandSkillToVersion, listCommandSkillsInVersion, removeCommandSkillFromVersion, shouldInstallCommandAsSkill, } from './command-skills.js';
18
+ import { commandSkillMatches, installCommandSkillToVersion, listCommandSkillsInVersion, removeCommandSkillFromVersion, shouldAlsoInstallCommandAsSkill, shouldInstallCommandAsSkill, } from './command-skills.js';
19
19
  import { installGooseCommandToVersion, listGooseCommandsInVersion, gooseCommandMatches, removeGooseCommandFromVersion, } from './goose-commands.js';
20
20
  function compareVersions(a, b) {
21
21
  const aParts = a.split('.').map((n) => parseInt(n, 10) || 0);
@@ -211,6 +211,14 @@ export function installCommand(sourcePath, agentId, commandName, method = 'symli
211
211
  const agent = AGENTS[agentId];
212
212
  ensureCommandsDir(agentId);
213
213
  const home = getEffectiveHome(agentId);
214
+ const installVersion = pinnedVersion ?? listInstalledVersions(agentId)[0] ?? '';
215
+ const alsoInstallAsSkill = shouldAlsoInstallCommandAsSkill(agentId, installVersion);
216
+ if (alsoInstallAsSkill) {
217
+ const installed = installCommandSkillToVersion(path.join(home, agentConfigDirName(agentId)), commandName, sourcePath, [getSkillsDir(), ...getEnabledExtraRepos().map((repo) => path.join(repo.dir, 'skills'))]);
218
+ if (!installed.success) {
219
+ return { path: '', method: 'copy', error: installed.error, warnings: validation.warnings };
220
+ }
221
+ }
214
222
  // Goose: a slash command is a recipe YAML registered in config.yaml, not a
215
223
  // native command file under commandsSubdir.
216
224
  if (agentId === 'goose') {
@@ -401,6 +409,11 @@ export function installCommandToVersion(agent, version, commandName, method = 'c
401
409
  ...getEnabledExtraRepos().map((repo) => path.join(repo.dir, 'skills')),
402
410
  ]);
403
411
  }
412
+ if (shouldAlsoInstallCommandAsSkill(agent, version)) {
413
+ const installed = installCommandSkillToVersion(agentDir, commandName, sourcePath, [getSkillsDir(), ...getEnabledExtraRepos().map((repo) => path.join(repo.dir, 'skills'))]);
414
+ if (!installed.success)
415
+ return installed;
416
+ }
404
417
  // Goose: a slash command is a recipe YAML registered in config.yaml, not a
405
418
  // native command file. Write the recipe + slash_commands entry.
406
419
  if (agent === 'goose') {
@@ -441,6 +454,11 @@ export function removeCommandFromVersion(agent, version, commandName) {
441
454
  if (shouldInstallCommandAsSkill(agent, version)) {
442
455
  return removeCommandSkillFromVersion(agentDir, commandName);
443
456
  }
457
+ if (shouldAlsoInstallCommandAsSkill(agent, version)) {
458
+ const removed = removeCommandSkillFromVersion(agentDir, commandName);
459
+ if (!removed.success)
460
+ return removed;
461
+ }
444
462
  if (agent === 'goose') {
445
463
  const trashDir = path.join(getTrashCommandsDir(), agent, version, commandName);
446
464
  return removeGooseCommandFromVersion(versionHome, commandName, trashDir);
@@ -880,7 +880,7 @@ ${[launch.command, ...launch.args].map((arg) => ` <string>${xmlEscape(arg)}</
880
880
  <key>EnvironmentVariables</key>
881
881
  <dict>
882
882
  <key>PATH</key>
883
- <string>${daemonNodeBinDir()}:/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:${os.homedir()}/.bun/bin</string>
883
+ <string>${daemonPathValue(agentsBin, ['/usr/local/bin', '/usr/bin', '/bin', '/opt/homebrew/bin', `${os.homedir()}/.bun/bin`])}</string>
884
884
  </dict>
885
885
  </dict>
886
886
  </plist>`;
@@ -909,7 +909,7 @@ Type=simple
909
909
  ExecStart=${execStart}
910
910
  Restart=always
911
911
  RestartSec=10
912
- Environment=PATH=${daemonNodeBinDir()}:/usr/local/bin:/usr/bin:/bin
912
+ Environment=PATH=${daemonPathValue(agentsBin, ['/usr/local/bin', '/usr/bin', '/bin'])}
913
913
 
914
914
  [Install]
915
915
  WantedBy=default.target`;
@@ -1074,6 +1074,21 @@ export function getDaemonLaunch(agentsBin = getAgentsBinPath()) {
1074
1074
  function daemonNodeBinDir() {
1075
1075
  return path.dirname(process.execPath);
1076
1076
  }
1077
+ /**
1078
+ * The full PATH value the daemon service manifest pins, in order: the Node runtime
1079
+ * dir (so the shim's shebang and any child resolve the exact installing Node — see
1080
+ * {@link daemonNodeBinDir}), then the directory of the `agents` shim itself, then
1081
+ * the platform's system dirs. The shim's own dir matters because a scheduled
1082
+ * `command` routine shells out to the bare name `agents`
1083
+ * (`/bin/sh -c 'agents watchdog --nudge'`): when the shim lives outside the Node
1084
+ * bin dir — a `~/.local/bin` global install, a separate npm prefix — a PATH
1085
+ * carrying only the Node dir resolves `agents` to nothing and every routine dies
1086
+ * with `exit 127`. Deduped across the whole list, so a Node/shim dir that already
1087
+ * appears among the system dirs (e.g. a `/usr/local/bin` install) never doubles.
1088
+ */
1089
+ function daemonPathValue(agentsBin, systemDirs) {
1090
+ return [...new Set([daemonNodeBinDir(), path.dirname(agentsBin), ...systemDirs])].join(':');
1091
+ }
1077
1092
  /**
1078
1093
  * Build the argv to relaunch the `agents` CLI with the given subcommand args.
1079
1094
  *
@@ -0,0 +1,167 @@
1
+ import type { AgentId } from '../types.js';
2
+ import type { DuplicateVersionHook } from '../hooks.js';
3
+ import type { RcSecretFinding } from '../secrets/rc-hygiene.js';
4
+ import type { SyncStatusRow, OrphanRow } from '../drift.js';
5
+ import type { FetchStatusMarker } from '../auto-pull.js';
6
+ import type { VersionResourceReport } from '../doctor-diff.js';
7
+ import type { FleetDivergence, FleetVersionSignIn } from './fleet-divergence.js';
8
+ export type FindingSeverity = 'critical' | 'warning';
9
+ /** A machine-stable class for a finding — drives {@link remediationFor} and lets
10
+ * the JSON consumer group by kind. */
11
+ export declare const ALL_FINDING_KINDS: readonly ["logged-out", "logout-unprovable", "missing-hook", "missing-plugin", "unwired-hook", "cli-missing", "missing-resource", "content-drift", "never-synced", "stale", "repo-behind", "repo-drift", "fleet-resource-gap", "host-cli-missing", "host-cli-invalid", "version-skew", "orphan", "duplicate-hook", "duplicate-hook-drift", "rc-secret-export", "exec-policy", "stale-cli"];
12
+ /** A machine-stable class for a finding. Derived from the runtime list above so
13
+ * the rubric test can enumerate every kind. */
14
+ export type FindingKind = typeof ALL_FINDING_KINDS[number];
15
+ /** One prioritized finding, attributed to a device (and, when relevant, an agent
16
+ * version + account). `remediation` is the exact command/hint to fix it. */
17
+ export interface DoctorFinding {
18
+ severity: FindingSeverity;
19
+ kind: FindingKind;
20
+ /** The device this finding is about. */
21
+ device: string;
22
+ /** Agent id, when the finding is about a specific agent (else undefined). */
23
+ agent?: AgentId;
24
+ /** Version id, when about a specific installed version. Absent on a finding
25
+ * collapsed across versions — read {@link DoctorFinding.versions} instead. */
26
+ version?: string;
27
+ /** Set only on a finding collapsed across several versions of one agent (the
28
+ * same problem on each). The row renders `<agent> (N versions)` and the
29
+ * remediation widens to the agent-wide sweep. */
30
+ versions?: string[];
31
+ /** Human account label (email/org/opaque id), when known. */
32
+ account?: string | null;
33
+ /** One-line plain-English description of the problem. */
34
+ message: string;
35
+ /** Exact remediation command / hint. */
36
+ remediation: string;
37
+ }
38
+ /**
39
+ * The exact remediation for a finding. Login fixes are harness-native
40
+ * (`loginHint`); a per-version login is offered ONLY for agents that isolate the
41
+ * credential per home (`agents run <agent>@<version>` then log in) — for
42
+ * gemini/antigravity/droid/cursor the login is shared, so we say so instead of
43
+ * faking a per-version fix. Every other kind maps to its canonical command.
44
+ */
45
+ export declare function remediationFor(finding: DoctorFinding): string;
46
+ export interface LocalFindingInputs {
47
+ device: string;
48
+ syncRows: SyncStatusRow[];
49
+ orphanRows: OrphanRow[];
50
+ repoBehind: FetchStatusMarker[];
51
+ /** Per-version resource reports (one per installed version) — the source of the
52
+ * missing-hook / missing-plugin / missing-resource / content-drift / unwired
53
+ * criticals+warnings. */
54
+ reports: VersionResourceReport[];
55
+ /** Per-version sign-in per agent id. */
56
+ signIn: Record<string, FleetVersionSignIn[]>;
57
+ /** Managed agents (installed versions) whose binary won't resolve. */
58
+ cliMissing?: AgentId[];
59
+ /** Host CLIs declared in a DotAgents repo's `cli/`: their install state on this
60
+ * box, plus any manifest the loader could not parse. Host-global (installed to
61
+ * PATH, never synced into a version home), so they are a machine-level
62
+ * finding, not a per-version one. */
63
+ hostClis?: {
64
+ statuses: Array<{
65
+ name: string;
66
+ installed: boolean;
67
+ }>;
68
+ errors: Array<{
69
+ file: string;
70
+ reason: string;
71
+ }>;
72
+ };
73
+ /** Hooks materialized into several version homes at once — identical copies are
74
+ * installation noise, differing ones are drift a stale gate can act on. */
75
+ duplicateHooks?: DuplicateVersionHook[];
76
+ /** Credential-shaped exports found in the user's shell rc files (RUSH-1968). */
77
+ rcSecrets?: RcSecretFinding[];
78
+ /** The effective PowerShell execution policy and the platform it was read on.
79
+ * Only `win32` yields a finding — the `agents.ps1` launcher is Windows-only. */
80
+ execPolicy?: {
81
+ platform: NodeJS.Platform;
82
+ policy: string | null;
83
+ };
84
+ /** `<agent>@<version>` keys whose home is an isolated copy. Their findings are
85
+ * never collapsed across versions: the agent-wide `agents doctor <agent> --fix`
86
+ * sweep deliberately skips isolated copies, so a collapsed row would print a
87
+ * remediation that does not fix them. */
88
+ isolatedVersions?: string[];
89
+ }
90
+ /**
91
+ * Fold this machine's signals into findings. Missing hooks/plugins and unwired
92
+ * hooks are CRITICAL; provable logouts are CRITICAL and unprovable ones WARNING;
93
+ * everything else (other missing kinds, drift, stale/never-synced, repo-behind,
94
+ * orphans) is a WARNING. Pure.
95
+ */
96
+ export declare function buildLocalFindings(input: LocalFindingInputs): DoctorFinding[];
97
+ /**
98
+ * Fold findings that say the SAME thing about several versions of one agent into
99
+ * a single row carrying `versions`, and widen its remediation to the agent-wide
100
+ * sweep (`agents doctor claude --fix` heals every non-isolated version in one
101
+ * go). Five identical `plugin 'code' — mirror missing` rows, one per installed
102
+ * claude, is the same fact five times.
103
+ *
104
+ * Three things never merge, because for each of them the widened remediation
105
+ * would be wrong:
106
+ * - **Isolated copies** — the agent-wide sweep deliberately skips them
107
+ * (`runFix`), so a folded row would print a command that leaves one broken.
108
+ * - **Findings with no agent** (repo-behind, rc-secret-export, …) — their
109
+ * `version` field is an alias, not a version.
110
+ * - **Logouts** ({@link NEVER_COLLAPSED}) — a login is inherently per-version:
111
+ * the fix is `agents run <agent>@<version> -- login`, and there is no `@all`
112
+ * equivalent. Dropping `version` would fall back to the bare native hint,
113
+ * which the shim resolves to the DEFAULT version — logging into the wrong one
114
+ * and leaving the finding to reappear.
115
+ *
116
+ * Pure; input order is kept.
117
+ */
118
+ export declare function collapseAcrossVersions(findings: DoctorFinding[], isolated: Set<string>): DoctorFinding[];
119
+ /**
120
+ * Map a device's per-version sign-in into logout findings: a PROVABLE logout is
121
+ * CRITICAL, an unprovable one is a hedged WARNING ("could not verify sign-in"),
122
+ * and a signed-in version yields nothing.
123
+ *
124
+ * An agent with no inspectable identity never appears at all — not even as the
125
+ * hedged warning: agents-cli knows no credential file for it, so "logged out" is
126
+ * unknowable and silence beats a false claim. Membership is
127
+ * `supportsAccountInspection` (`lib/agents.ts`) and is deliberately NOT listed
128
+ * here — agents move between the sets, and a copy of the list in prose becomes a
129
+ * lie the next time one does. Note the caller also requires
130
+ * `CredentialPresence.knownLocation`: the inspection set and the credential-path
131
+ * map move independently, so being inspectable is not on its own enough to call a
132
+ * logout provable. Pure.
133
+ */
134
+ export declare function signInToFindings(device: string, signIn: Record<string, FleetVersionSignIn[]>): DoctorFinding[];
135
+ /**
136
+ * Map cross-device divergence (from {@link compareFleetInventories}) into
137
+ * warnings: an agent version present elsewhere but absent on a device is a
138
+ * version-skew warning; a diverged config repo is a repo-drift warning; a
139
+ * missing resource is a missing-resource warning. Baseline = the local machine.
140
+ * Only the *lagging* box is attributed (a `*-missing-local` finding is the
141
+ * baseline's gap). Pure.
142
+ */
143
+ export declare function fleetDivergenceToFindings(divergences: FleetDivergence[], baseline: string): DoctorFinding[];
144
+ export interface RenderOptions {
145
+ /** Fleet mode (`--devices`): render the `─── by computer ───` header + one
146
+ * block per device. Single-machine mode collapses to one `▸ <machine>` block
147
+ * with no fleet header. */
148
+ fleet: boolean;
149
+ /** The baseline (local) machine name — tagged `· this machine`. */
150
+ baseline?: string;
151
+ /** Header line context: device count (fleet) or the local version string. */
152
+ header?: string;
153
+ }
154
+ /**
155
+ * Render the two-part hybrid layout from a flat findings list. Pure — returns the
156
+ * lines so the exact output is snapshot-tested. Criticals across ALL devices go
157
+ * to the top section, worst-first; the per-computer section lists each device's
158
+ * warnings + a `✗ N critical (above)` marker, worst device first.
159
+ */
160
+ export declare function renderFindings(findings: DoctorFinding[], accounts: Record<string, Record<string, FleetVersionSignIn[]>>, opts: RenderOptions): string[];
161
+ /**
162
+ * The compact accounts/versions line for one device: every installed version and
163
+ * its account, grouped by agent — green ✓ signed in, red ✗ provably logged out,
164
+ * gray ? unknown (see {@link badge}). e.g.
165
+ * `claude 2.1.170 ✓muqsit@gmail(Max) 2.1.999 ✓team(Team) · codex ✗ · grok ✓`
166
+ */
167
+ export declare function renderAccountsLine(signIn: Record<string, FleetVersionSignIn[]>): string;