@phnx-labs/agents-cli 1.14.7 → 1.15.0

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.
@@ -14,7 +14,7 @@ import { resolveJobPrompt, parseTimeout, writeRunMeta, getRunDir, } from './rout
14
14
  import { getRunsDir } from './state.js';
15
15
  import { prepareJobHome, buildSpawnEnv } from './sandbox.js';
16
16
  import { resolveModel, buildReasoningFlags } from './models.js';
17
- import { emitStart, maybeRotate } from './events.js';
17
+ import { createTimer, maybeRotate, truncate } from './events.js';
18
18
  /** CLI command templates per agent, with {prompt} as a placeholder. */
19
19
  const AGENT_COMMANDS = {
20
20
  claude: ['claude', '-p', '--verbose', '{prompt}', '--output-format', 'stream-json', '--permission-mode', 'plan'],
@@ -109,11 +109,13 @@ function generateRunId() {
109
109
  /** Execute a job synchronously (waits for completion or timeout before resolving). */
110
110
  export async function executeJob(config) {
111
111
  maybeRotate();
112
- const done = emitStart('agent.run.start', {
112
+ const timer = createTimer('agent.run', {
113
113
  agent: config.agent,
114
114
  version: config.version,
115
115
  jobName: config.name,
116
116
  mode: config.mode,
117
+ prompt: truncate(config.prompt, 200),
118
+ schedule: config.schedule,
117
119
  });
118
120
  const resolvedPrompt = resolveJobPrompt(config);
119
121
  const cmd = buildJobCommand(config, resolvedPrompt);
@@ -146,6 +148,8 @@ export async function executeJob(config) {
146
148
  detached: true,
147
149
  env: spawnEnv,
148
150
  });
151
+ // Mark startup time (time from function call to process spawn)
152
+ timer.mark('startup');
149
153
  meta.pid = child.pid || null;
150
154
  writeRunMeta(meta);
151
155
  let settled = false;
@@ -168,7 +172,7 @@ export async function executeJob(config) {
168
172
  meta.status = 'timeout';
169
173
  meta.completedAt = new Date().toISOString();
170
174
  writeRunMeta(meta);
171
- done({ status: 'timeout', runId });
175
+ timer.end({ status: 'timeout', runId });
172
176
  const reportPath = extractAndSaveReport(stdoutPath, config.agent, runDir);
173
177
  resolve({ meta, reportPath });
174
178
  }, timeoutMs);
@@ -185,7 +189,7 @@ export async function executeJob(config) {
185
189
  meta.status = code === 0 ? 'completed' : 'failed';
186
190
  meta.completedAt = new Date().toISOString();
187
191
  writeRunMeta(meta);
188
- done({ status: meta.status, exitCode: code, runId });
192
+ timer.end({ status: meta.status, exitCode: code ?? undefined, runId });
189
193
  const reportPath = extractAndSaveReport(stdoutPath, config.agent, runDir);
190
194
  resolve({ meta, reportPath });
191
195
  });
@@ -201,7 +205,7 @@ export async function executeJob(config) {
201
205
  meta.status = 'failed';
202
206
  meta.completedAt = new Date().toISOString();
203
207
  writeRunMeta(meta);
204
- done({ status: 'failed', error: err.message, runId });
208
+ timer.end({ status: 'failed', error: err.message, runId });
205
209
  resolve({ meta, reportPath: null });
206
210
  });
207
211
  child.unref();
@@ -1,11 +1,14 @@
1
1
  /**
2
- * macOS Keychain integration for secure credential storage.
2
+ * Cross-platform secure credential storage.
3
3
  *
4
- * All reads/writes go through a signed Swift helper (keychain-helper.swift)
5
- * compiled into AgentsKeychain.app. The .app embeds a provisioning profile
6
- * that grants the application-identifier + keychain-access-groups entitlement
7
- * macOS requires for kSecAttrSynchronizable writes (iCloud Keychain).
8
- * For device-local writes the helper is invoked with the `nosync` arg.
4
+ * macOS: Uses Keychain via signed Swift helper (AgentsKeychain.app) or `security` CLI.
5
+ * Linux: Uses libsecret (GNOME Keyring) via `secret-tool` CLI.
6
+ * Windows: Not yet supported.
7
+ *
8
+ * The .app embeds a provisioning profile that grants the application-identifier
9
+ * + keychain-access-groups entitlement macOS requires for kSecAttrSynchronizable
10
+ * writes (iCloud Keychain). For device-local writes the helper is invoked with
11
+ * the `nosync` arg.
9
12
  */
10
13
  /** Supported secret resolution backends. */
11
14
  export type SecretProvider = 'keychain' | 'env' | 'file' | 'exec';
@@ -49,15 +52,15 @@ export interface KeychainBackend {
49
52
  }
50
53
  /** Install a custom keychain backend (test only). Returns the previous backend so callers can restore. */
51
54
  export declare function setKeychainBackendForTest(b: KeychainBackend | null): KeychainBackend | null;
52
- /** Check if a keychain item exists (macOS only). */
55
+ /** Check if a keychain/keyring item exists. */
53
56
  export declare function hasKeychainToken(item: string, sync?: boolean): boolean;
54
- /** Retrieve a secret value from the macOS Keychain. Throws if not found. */
57
+ /** Retrieve a secret value from the keychain/keyring. Throws if not found. */
55
58
  export declare function getKeychainToken(item: string, sync?: boolean): string;
56
- /** Store or update a secret value in the macOS Keychain. iCloud-synced when sync=true. */
59
+ /** Store or update a secret value in the keychain/keyring. iCloud-synced when sync=true (macOS only). */
57
60
  export declare function setKeychainToken(item: string, value: string, sync?: boolean): void;
58
- /** Delete a keychain item. Returns true if it existed. */
61
+ /** Delete a keychain/keyring item. Returns true if it existed. */
59
62
  export declare function deleteKeychainToken(item: string, sync?: boolean): boolean;
60
- /** Enumerate keychain item service names whose name starts with the given prefix. */
63
+ /** Enumerate keychain/keyring item names starting with the given prefix. */
61
64
  export declare function listKeychainItems(prefix: string): string[];
62
65
  /** Options controlling how secret refs are resolved. */
63
66
  export interface ResolveOptions {
@@ -1,17 +1,21 @@
1
1
  /**
2
- * macOS Keychain integration for secure credential storage.
2
+ * Cross-platform secure credential storage.
3
3
  *
4
- * All reads/writes go through a signed Swift helper (keychain-helper.swift)
5
- * compiled into AgentsKeychain.app. The .app embeds a provisioning profile
6
- * that grants the application-identifier + keychain-access-groups entitlement
7
- * macOS requires for kSecAttrSynchronizable writes (iCloud Keychain).
8
- * For device-local writes the helper is invoked with the `nosync` arg.
4
+ * macOS: Uses Keychain via signed Swift helper (AgentsKeychain.app) or `security` CLI.
5
+ * Linux: Uses libsecret (GNOME Keyring) via `secret-tool` CLI.
6
+ * Windows: Not yet supported.
7
+ *
8
+ * The .app embeds a provisioning profile that grants the application-identifier
9
+ * + keychain-access-groups entitlement macOS requires for kSecAttrSynchronizable
10
+ * writes (iCloud Keychain). For device-local writes the helper is invoked with
11
+ * the `nosync` arg.
9
12
  */
10
13
  import { fileURLToPath } from 'url';
11
14
  import { execFileSync, spawnSync } from 'child_process';
12
15
  import * as fs from 'fs';
13
16
  import * as os from 'os';
14
17
  import * as path from 'path';
18
+ import { linuxBackend } from './linux.js';
15
19
  const SERVICE_PREFIX = 'agents-cli';
16
20
  const REF_PATTERN = /^(keychain|env|file|exec):(.+)$/s;
17
21
  /** Parse a bundle value into either a literal string or a typed secret ref. */
@@ -31,11 +35,18 @@ export function parseBundleValue(raw) {
31
35
  export function serializeRef(ref) {
32
36
  return `${ref.provider}:${ref.value}`;
33
37
  }
34
- function assertMacOS() {
35
- if (process.platform !== 'darwin') {
36
- throw new Error('Keychain-based secrets require macOS. On Linux, use environment variables or .env files instead. Native Linux credential store support is planned.');
38
+ function assertSupportedPlatform() {
39
+ if (process.platform !== 'darwin' && process.platform !== 'linux') {
40
+ throw new Error('Secure credential storage requires macOS or Linux. ' +
41
+ 'On Windows, use environment variables or .env files instead.');
37
42
  }
38
43
  }
44
+ function isLinux() {
45
+ return process.platform === 'linux';
46
+ }
47
+ function isMacOS() {
48
+ return process.platform === 'darwin';
49
+ }
39
50
  /** Build the keychain item name for a profile provider token. */
40
51
  export function profileKeychainItem(provider) {
41
52
  return `${SERVICE_PREFIX}.${provider}.token`;
@@ -73,12 +84,14 @@ export function setKeychainBackendForTest(b) {
73
84
  // holds the keychain-access-groups entitlement macOS requires for
74
85
  // kSecAttrSynchronizable. Enumeration also goes through the .app because the
75
86
  // security CLI doesn't expose listing by service prefix.
76
- /** Check if a keychain item exists (macOS only). */
87
+ /** Check if a keychain/keyring item exists. */
77
88
  export function hasKeychainToken(item, sync = false) {
78
89
  if (backend)
79
90
  return backend.has(item, sync);
80
- assertMacOS();
81
- // Try security first (no prompts for local items), fall back to binary for synced items.
91
+ assertSupportedPlatform();
92
+ if (isLinux())
93
+ return linuxBackend.has(item, sync);
94
+ // macOS: Try security first (no prompts for local items), fall back to binary for synced items.
82
95
  if (spawnSync('security', ['find-generic-password', '-a', os.userInfo().username, '-s', item, '-w'], {
83
96
  stdio: ['ignore', 'pipe', 'pipe'],
84
97
  }).status === 0)
@@ -89,12 +102,14 @@ export function hasKeychainToken(item, sync = false) {
89
102
  stdio: ['ignore', 'pipe', 'pipe'],
90
103
  }).status === 0;
91
104
  }
92
- /** Retrieve a secret value from the macOS Keychain. Throws if not found. */
105
+ /** Retrieve a secret value from the keychain/keyring. Throws if not found. */
93
106
  export function getKeychainToken(item, sync = false) {
94
107
  if (backend)
95
108
  return backend.get(item, sync);
96
- assertMacOS();
97
- // Try security first (no prompts for local items)
109
+ assertSupportedPlatform();
110
+ if (isLinux())
111
+ return linuxBackend.get(item, sync);
112
+ // macOS: Try security first (no prompts for local items)
98
113
  const secResult = spawnSync('security', ['find-generic-password', '-a', os.userInfo().username, '-s', item, '-w'], {
99
114
  stdio: ['ignore', 'pipe', 'pipe'],
100
115
  });
@@ -119,17 +134,24 @@ export function getKeychainToken(item, sync = false) {
119
134
  throw new Error(`Keychain item '${item}' exists but is empty.`);
120
135
  return token;
121
136
  }
122
- /** Store or update a secret value in the macOS Keychain. iCloud-synced when sync=true. */
137
+ /** Store or update a secret value in the keychain/keyring. iCloud-synced when sync=true (macOS only). */
123
138
  export function setKeychainToken(item, value, sync = false) {
124
139
  if (backend) {
125
140
  backend.set(item, value, sync);
126
141
  return;
127
142
  }
128
- assertMacOS();
143
+ assertSupportedPlatform();
129
144
  if (!value || !value.trim())
130
145
  throw new Error('Secret value is empty.');
131
146
  if (/[\r\n]/.test(value))
132
147
  throw new Error('Secret value contains newlines, which are not supported.');
148
+ if (/[\x00=\r\n]/.test(item))
149
+ throw new Error('Secret item name contains invalid characters.');
150
+ if (isLinux()) {
151
+ linuxBackend.set(item, value, sync);
152
+ return;
153
+ }
154
+ // macOS path
133
155
  if (sync) {
134
156
  const bin = ensureKeychainHelper();
135
157
  const result = spawnSync(bin, ['set', item, os.userInfo().username], {
@@ -153,11 +175,14 @@ export function setKeychainToken(item, value, sync = false) {
153
175
  throw new Error(`Failed to write keychain item '${item}' (exit ${result.status}).`);
154
176
  }
155
177
  }
156
- /** Delete a keychain item. Returns true if it existed. */
178
+ /** Delete a keychain/keyring item. Returns true if it existed. */
157
179
  export function deleteKeychainToken(item, sync = false) {
158
180
  if (backend)
159
181
  return backend.delete(item, sync);
160
- assertMacOS();
182
+ assertSupportedPlatform();
183
+ if (isLinux())
184
+ return linuxBackend.delete(item, sync);
185
+ // macOS path
161
186
  if (sync) {
162
187
  const bin = ensureKeychainHelper();
163
188
  return spawnSync(bin, ['delete', item, os.userInfo().username], {
@@ -171,11 +196,14 @@ export function deleteKeychainToken(item, sync = false) {
171
196
  function quoteForSecurityCli(s) {
172
197
  return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
173
198
  }
174
- /** Enumerate keychain item service names whose name starts with the given prefix. */
199
+ /** Enumerate keychain/keyring item names starting with the given prefix. */
175
200
  export function listKeychainItems(prefix) {
176
201
  if (backend)
177
202
  return backend.list(prefix);
178
- assertMacOS();
203
+ assertSupportedPlatform();
204
+ if (isLinux())
205
+ return linuxBackend.list(prefix);
206
+ // macOS path
179
207
  const bin = ensureKeychainHelper();
180
208
  const result = spawnSync(bin, ['list', prefix], {
181
209
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Linux secret storage via libsecret (GNOME Keyring / Secret Service API).
3
+ *
4
+ * Uses `secret-tool` CLI which is part of libsecret-tools package.
5
+ * On Ubuntu: apt install libsecret-tools
6
+ *
7
+ * Secrets are stored with:
8
+ * service = "agents-cli"
9
+ * account = username
10
+ * item = the secret identifier
11
+ */
12
+ import type { KeychainBackend } from './index.js';
13
+ /**
14
+ * secret-tool lookup attributes:
15
+ * service=agents-cli account=<user> item=<itemName>
16
+ */
17
+ export declare function hasSecretToolToken(item: string): boolean;
18
+ export declare function getSecretToolToken(item: string): string;
19
+ export declare function setSecretToolToken(item: string, value: string): void;
20
+ export declare function deleteSecretToolToken(item: string): boolean;
21
+ /**
22
+ * List secrets by prefix. secret-tool doesn't have a list command,
23
+ * so we use secret-tool search which outputs in a specific format.
24
+ */
25
+ export declare function listSecretToolItems(prefix: string): string[];
26
+ /** KeychainBackend implementation for Linux using secret-tool */
27
+ export declare const linuxBackend: KeychainBackend;
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Linux secret storage via libsecret (GNOME Keyring / Secret Service API).
3
+ *
4
+ * Uses `secret-tool` CLI which is part of libsecret-tools package.
5
+ * On Ubuntu: apt install libsecret-tools
6
+ *
7
+ * Secrets are stored with:
8
+ * service = "agents-cli"
9
+ * account = username
10
+ * item = the secret identifier
11
+ */
12
+ import { spawnSync } from 'child_process';
13
+ import * as os from 'os';
14
+ const SERVICE = 'agents-cli';
15
+ function secretToolAvailable() {
16
+ const result = spawnSync('which', ['secret-tool'], {
17
+ stdio: ['ignore', 'pipe', 'pipe'],
18
+ });
19
+ return result.status === 0;
20
+ }
21
+ let checkedAvailability = false;
22
+ let isAvailable = false;
23
+ function ensureSecretTool() {
24
+ if (!checkedAvailability) {
25
+ isAvailable = secretToolAvailable();
26
+ checkedAvailability = true;
27
+ }
28
+ if (!isAvailable) {
29
+ throw new Error('secret-tool not found. Install libsecret-tools:\n' +
30
+ ' Ubuntu/Debian: sudo apt install libsecret-tools\n' +
31
+ ' Fedora: sudo dnf install libsecret\n' +
32
+ ' Arch: sudo pacman -S libsecret');
33
+ }
34
+ }
35
+ /**
36
+ * secret-tool lookup attributes:
37
+ * service=agents-cli account=<user> item=<itemName>
38
+ */
39
+ export function hasSecretToolToken(item) {
40
+ ensureSecretTool();
41
+ const user = os.userInfo().username;
42
+ const result = spawnSync('secret-tool', [
43
+ 'lookup',
44
+ 'service', SERVICE,
45
+ 'account', user,
46
+ 'item', item,
47
+ ], {
48
+ stdio: ['ignore', 'pipe', 'pipe'],
49
+ });
50
+ return result.status === 0 && result.stdout?.toString().trim().length > 0;
51
+ }
52
+ export function getSecretToolToken(item) {
53
+ ensureSecretTool();
54
+ const user = os.userInfo().username;
55
+ const result = spawnSync('secret-tool', [
56
+ 'lookup',
57
+ 'service', SERVICE,
58
+ 'account', user,
59
+ 'item', item,
60
+ ], {
61
+ stdio: ['ignore', 'pipe', 'pipe'],
62
+ });
63
+ if (result.status !== 0) {
64
+ throw new Error(`Secret '${item}' not found in keyring.`);
65
+ }
66
+ const token = result.stdout?.toString().trim();
67
+ if (!token) {
68
+ throw new Error(`Secret '${item}' exists but is empty.`);
69
+ }
70
+ return token;
71
+ }
72
+ export function setSecretToolToken(item, value) {
73
+ ensureSecretTool();
74
+ if (!value || !value.trim())
75
+ throw new Error('Secret value is empty.');
76
+ const user = os.userInfo().username;
77
+ const label = `agents-cli: ${item}`;
78
+ // secret-tool store reads value from stdin
79
+ const result = spawnSync('secret-tool', [
80
+ 'store',
81
+ '--label', label,
82
+ 'service', SERVICE,
83
+ 'account', user,
84
+ 'item', item,
85
+ ], {
86
+ input: value,
87
+ stdio: ['pipe', 'pipe', 'pipe'],
88
+ });
89
+ if (result.status !== 0) {
90
+ const stderr = result.stderr?.toString().trim();
91
+ throw new Error(`Failed to store secret '${item}': ${stderr || 'unknown error'}\n` +
92
+ 'Make sure GNOME Keyring or another Secret Service provider is running.');
93
+ }
94
+ }
95
+ export function deleteSecretToolToken(item) {
96
+ ensureSecretTool();
97
+ const user = os.userInfo().username;
98
+ const result = spawnSync('secret-tool', [
99
+ 'clear',
100
+ 'service', SERVICE,
101
+ 'account', user,
102
+ 'item', item,
103
+ ], {
104
+ stdio: ['ignore', 'pipe', 'pipe'],
105
+ });
106
+ // secret-tool clear returns 0 whether the item existed or not.
107
+ // This matches the macOS behavior where delete is idempotent.
108
+ return result.status === 0;
109
+ }
110
+ /**
111
+ * List secrets by prefix. secret-tool doesn't have a list command,
112
+ * so we use secret-tool search which outputs in a specific format.
113
+ */
114
+ export function listSecretToolItems(prefix) {
115
+ ensureSecretTool();
116
+ // secret-tool search outputs attributes, one item per block
117
+ const result = spawnSync('secret-tool', [
118
+ 'search',
119
+ '--all',
120
+ 'service', SERVICE,
121
+ ], {
122
+ stdio: ['ignore', 'pipe', 'pipe'],
123
+ });
124
+ if (result.status !== 0) {
125
+ return [];
126
+ }
127
+ const output = result.stdout?.toString() || '';
128
+ const items = [];
129
+ // Parse output format:
130
+ // [/org/freedesktop/secrets/collection/login/1]
131
+ // label = agents-cli: myitem
132
+ // ...
133
+ // attribute.item = myitem
134
+ const itemRegex = /attribute\.item\s*=\s*(.+)/g;
135
+ let match;
136
+ while ((match = itemRegex.exec(output)) !== null) {
137
+ const itemName = match[1].trim();
138
+ if (itemName.startsWith(prefix)) {
139
+ items.push(itemName);
140
+ }
141
+ }
142
+ return [...new Set(items)]; // dedupe
143
+ }
144
+ /** KeychainBackend implementation for Linux using secret-tool */
145
+ export const linuxBackend = {
146
+ has(item, _sync) {
147
+ return hasSecretToolToken(item);
148
+ },
149
+ get(item, _sync) {
150
+ return getSecretToolToken(item);
151
+ },
152
+ set(item, value, _sync) {
153
+ setSecretToolToken(item, value);
154
+ },
155
+ delete(item, _sync) {
156
+ return deleteSecretToolToken(item);
157
+ },
158
+ list(prefix) {
159
+ return listSecretToolItems(prefix);
160
+ },
161
+ };
@@ -137,3 +137,7 @@ export declare function getRowCount(): {
137
137
  sessions: number;
138
138
  textRows: number;
139
139
  };
140
+ /** Count sessions older than the given timestamp (for dry-run previews). */
141
+ export declare function countSessionsOlderThan(cutoffMs: number): number;
142
+ /** Delete sessions older than the given timestamp. Returns the number of rows deleted. */
143
+ export declare function deleteSessionsOlderThan(cutoffMs: number): number;
@@ -638,3 +638,29 @@ export function getRowCount() {
638
638
  const textRows = db.prepare(`SELECT COUNT(*) AS c FROM session_text`).get().c;
639
639
  return { sessions, textRows };
640
640
  }
641
+ /** Count sessions older than the given timestamp (for dry-run previews). */
642
+ export function countSessionsOlderThan(cutoffMs) {
643
+ const db = getDB();
644
+ const cutoffIso = new Date(cutoffMs).toISOString();
645
+ const row = db.prepare(`SELECT COUNT(*) AS n FROM sessions WHERE timestamp < ?`).get(cutoffIso);
646
+ return row.n;
647
+ }
648
+ /** Delete sessions older than the given timestamp. Returns the number of rows deleted. */
649
+ export function deleteSessionsOlderThan(cutoffMs) {
650
+ const db = getDB();
651
+ const cutoffIso = new Date(cutoffMs).toISOString();
652
+ const rows = db.prepare(`SELECT id, file_path FROM sessions WHERE timestamp < ?`).all(cutoffIso);
653
+ if (rows.length === 0)
654
+ return 0;
655
+ const txn = db.transaction(() => {
656
+ for (const { id, file_path } of rows) {
657
+ db.prepare(`DELETE FROM session_text WHERE session_id = ?`).run(id);
658
+ db.prepare(`DELETE FROM sessions WHERE id = ?`).run(id);
659
+ if (file_path) {
660
+ db.prepare(`DELETE FROM scan_ledger WHERE file_path = ?`).run(canonicalLedgerKey(file_path));
661
+ }
662
+ }
663
+ });
664
+ txn();
665
+ return rows.length;
666
+ }
@@ -79,7 +79,7 @@ export declare function getUsageInfoForIdentity(input: UsageIdentityInput): Prom
79
79
  export declare function formatUsageSummary(plan: string | null, snapshot: UsageSnapshot | null, planWidth?: number): string;
80
80
  /** Format a multi-line usage section for detailed agent views. */
81
81
  export declare function formatUsageSection(usage: UsageInfo): string[];
82
- /** Load Claude OAuth credentials from the macOS Keychain. */
82
+ /** Load Claude OAuth credentials from the system keychain/keyring. */
83
83
  export declare function loadClaudeOauth(home?: string): Promise<ClaudeOauthCredentials | null>;
84
84
  /**
85
85
  * Derive the Keychain service name for a Claude home directory.
package/dist/lib/usage.js CHANGED
@@ -15,6 +15,7 @@ import * as readline from 'readline';
15
15
  import { promisify } from 'util';
16
16
  import chalk from 'chalk';
17
17
  import { walkForFiles } from './fs-walk.js';
18
+ import { getKeychainToken, setKeychainToken, deleteKeychainToken, } from './secrets/index.js';
18
19
  import { getAgentsDir } from './state.js';
19
20
  const execFileAsync = promisify(execFile);
20
21
  const CLAUDE_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
@@ -343,27 +344,15 @@ function normalizeClaudeWindow(window, key, label, shortLabel) {
343
344
  windowMinutes: inferWindowMinutes(key),
344
345
  };
345
346
  }
346
- let warnedNonDarwin = false;
347
- /** Load Claude OAuth credentials from the macOS Keychain. */
347
+ /** Load Claude OAuth credentials from the system keychain/keyring. */
348
348
  export async function loadClaudeOauth(home) {
349
- if (process.platform !== 'darwin') {
350
- if (!warnedNonDarwin && process.stderr.isTTY) {
351
- process.stderr.write('[agents] Usage tracking requires macOS Keychain. Skipped on this platform.\n');
352
- warnedNonDarwin = true;
353
- }
349
+ // Windows not yet supported
350
+ if (process.platform !== 'darwin' && process.platform !== 'linux') {
354
351
  return null;
355
352
  }
356
353
  try {
357
- const account = os.userInfo().username;
358
- const { stdout } = await execFileAsync('security', [
359
- 'find-generic-password',
360
- '-a',
361
- account,
362
- '-s',
363
- // Managed Claude homes must stay pinned to their own service name.
364
- getClaudeKeychainService(home),
365
- '-w',
366
- ]);
354
+ const service = getClaudeKeychainService(home);
355
+ const stdout = getKeychainToken(service);
367
356
  const payload = JSON.parse(stdout.trim());
368
357
  if (typeof payload?.claudeAiOauth?.accessToken !== 'string')
369
358
  return null;
@@ -380,27 +369,20 @@ export async function loadClaudeOauth(home) {
380
369
  }
381
370
  }
382
371
  /**
383
- * Save Claude OAuth credentials to the macOS Keychain.
372
+ * Save Claude OAuth credentials to the system keychain/keyring.
384
373
  * Reads the existing payload, merges the new OAuth fields, and writes back.
385
374
  */
386
375
  async function saveClaudeOauth(home, credentials) {
387
- if (process.platform !== 'darwin') {
376
+ // Windows not yet supported
377
+ if (process.platform !== 'darwin' && process.platform !== 'linux') {
388
378
  return false;
389
379
  }
390
380
  try {
391
- const account = os.userInfo().username;
392
381
  const service = getClaudeKeychainService(home);
393
382
  // Read existing payload to preserve other fields
394
383
  let existingPayload = {};
395
384
  try {
396
- const { stdout } = await execFileAsync('security', [
397
- 'find-generic-password',
398
- '-a',
399
- account,
400
- '-s',
401
- service,
402
- '-w',
403
- ]);
385
+ const stdout = getKeychainToken(service);
404
386
  existingPayload = JSON.parse(stdout.trim());
405
387
  }
406
388
  catch {
@@ -418,29 +400,14 @@ async function saveClaudeOauth(home, credentials) {
418
400
  },
419
401
  };
420
402
  const payloadJson = JSON.stringify(newPayload);
421
- // Delete existing entry first (security add-generic-password -U can fail)
403
+ // Delete existing entry first, then add updated entry
422
404
  try {
423
- await execFileAsync('security', [
424
- 'delete-generic-password',
425
- '-a',
426
- account,
427
- '-s',
428
- service,
429
- ]);
405
+ deleteKeychainToken(service);
430
406
  }
431
407
  catch {
432
408
  // Entry might not exist, ignore
433
409
  }
434
- // Add updated entry
435
- await execFileAsync('security', [
436
- 'add-generic-password',
437
- '-a',
438
- account,
439
- '-s',
440
- service,
441
- '-w',
442
- payloadJson,
443
- ]);
410
+ setKeychainToken(service, payloadJson);
444
411
  return true;
445
412
  }
446
413
  catch {
@@ -945,6 +945,17 @@ export async function installVersion(agent, version, onProgress) {
945
945
  throw new Error(`Invalid version: ${JSON.stringify(version)}`);
946
946
  }
947
947
  try {
948
+ // Check npm is available
949
+ try {
950
+ await execFileAsync('which', ['npm']);
951
+ }
952
+ catch {
953
+ return {
954
+ success: false,
955
+ installedVersion: version,
956
+ error: 'npm is not installed. Install Node.js and npm first: https://nodejs.org/',
957
+ };
958
+ }
948
959
  onProgress?.(`Installing ${packageSpec}...`);
949
960
  const { stdout } = await execFileAsync('npm', ['install', packageSpec], { cwd: versionDir });
950
961
  // Determine the actual installed version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.14.7",
3
+ "version": "1.15.0",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",