@phnx-labs/agents-cli 1.14.6 → 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.
- package/README.md +148 -1
- package/dist/commands/beta.js +6 -1
- package/dist/commands/exec.js +9 -2
- package/dist/commands/init.js +10 -0
- package/dist/commands/mcp.js +4 -4
- package/dist/commands/prune.d.ts +0 -20
- package/dist/commands/prune.js +268 -15
- package/dist/commands/secrets.js +83 -0
- package/dist/commands/teams.js +2 -3
- package/dist/commands/usage.js +6 -0
- package/dist/commands/versions.js +8 -6
- package/dist/lib/browser/chrome.js +1 -1
- package/dist/lib/browser/drivers/ssh.d.ts +1 -0
- package/dist/lib/browser/drivers/ssh.js +23 -2
- package/dist/lib/browser/ipc.js +1 -0
- package/dist/lib/browser/service.d.ts +3 -0
- package/dist/lib/browser/service.js +114 -6
- package/dist/lib/daemon.js +4 -4
- package/dist/lib/events.d.ts +159 -0
- package/dist/lib/events.js +441 -0
- package/dist/lib/exec.js +29 -6
- package/dist/lib/permissions.d.ts +6 -3
- package/dist/lib/permissions.js +38 -34
- package/dist/lib/routines.d.ts +15 -0
- package/dist/lib/routines.js +68 -0
- package/dist/lib/runner.js +15 -0
- package/dist/lib/secrets/bundles.js +7 -1
- package/dist/lib/secrets/index.d.ts +14 -11
- package/dist/lib/secrets/index.js +49 -21
- package/dist/lib/secrets/linux.d.ts +27 -0
- package/dist/lib/secrets/linux.js +161 -0
- package/dist/lib/session/db.d.ts +4 -0
- package/dist/lib/session/db.js +26 -0
- package/dist/lib/skills.js +4 -0
- package/dist/lib/usage.d.ts +1 -1
- package/dist/lib/usage.js +13 -46
- package/dist/lib/versions.js +16 -0
- package/package.json +1 -1
- package/scripts/postinstall.js +37 -9
package/dist/lib/routines.js
CHANGED
|
@@ -350,3 +350,71 @@ export function installJobFromSource(sourcePath, name) {
|
|
|
350
350
|
return { success: false, error: err.message };
|
|
351
351
|
}
|
|
352
352
|
}
|
|
353
|
+
/** List all job names that have run directories. */
|
|
354
|
+
export function listJobsWithRuns() {
|
|
355
|
+
const runsDir = getRunsDir();
|
|
356
|
+
if (!fs.existsSync(runsDir))
|
|
357
|
+
return [];
|
|
358
|
+
return fs.readdirSync(runsDir, { withFileTypes: true })
|
|
359
|
+
.filter((e) => e.isDirectory())
|
|
360
|
+
.map((e) => e.name);
|
|
361
|
+
}
|
|
362
|
+
/** Count total runs across all jobs. */
|
|
363
|
+
export function countAllRuns() {
|
|
364
|
+
let total = 0;
|
|
365
|
+
for (const jobName of listJobsWithRuns()) {
|
|
366
|
+
total += listRuns(jobName).length;
|
|
367
|
+
}
|
|
368
|
+
return total;
|
|
369
|
+
}
|
|
370
|
+
/** Preview runs that would be pruned (keeping only the most recent `keep` per job). */
|
|
371
|
+
export function previewRunsPrune(keep) {
|
|
372
|
+
const toPrune = [];
|
|
373
|
+
for (const jobName of listJobsWithRuns()) {
|
|
374
|
+
const runs = listRuns(jobName);
|
|
375
|
+
if (runs.length > keep) {
|
|
376
|
+
const toRemove = runs.slice(0, runs.length - keep);
|
|
377
|
+
for (const run of toRemove) {
|
|
378
|
+
toPrune.push({ jobName, runId: run.runId, startedAt: run.startedAt });
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return toPrune;
|
|
383
|
+
}
|
|
384
|
+
/** Delete old runs, keeping only the most recent `keep` per job. Returns bytes freed and run count. */
|
|
385
|
+
export function pruneRuns(keep) {
|
|
386
|
+
let deleted = 0;
|
|
387
|
+
let bytesFreed = 0;
|
|
388
|
+
for (const jobName of listJobsWithRuns()) {
|
|
389
|
+
const runs = listRuns(jobName);
|
|
390
|
+
if (runs.length <= keep)
|
|
391
|
+
continue;
|
|
392
|
+
const toRemove = runs.slice(0, runs.length - keep);
|
|
393
|
+
for (const run of toRemove) {
|
|
394
|
+
const runDir = getRunDir(jobName, run.runId);
|
|
395
|
+
bytesFreed += getDirSize(runDir);
|
|
396
|
+
fs.rmSync(runDir, { recursive: true, force: true });
|
|
397
|
+
deleted++;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return { deleted, bytesFreed };
|
|
401
|
+
}
|
|
402
|
+
function getDirSize(dirPath) {
|
|
403
|
+
if (!fs.existsSync(dirPath))
|
|
404
|
+
return 0;
|
|
405
|
+
let size = 0;
|
|
406
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
407
|
+
for (const entry of entries) {
|
|
408
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
409
|
+
if (entry.isDirectory()) {
|
|
410
|
+
size += getDirSize(fullPath);
|
|
411
|
+
}
|
|
412
|
+
else {
|
|
413
|
+
try {
|
|
414
|
+
size += fs.statSync(fullPath).size;
|
|
415
|
+
}
|
|
416
|
+
catch { /* ignore */ }
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return size;
|
|
420
|
+
}
|
package/dist/lib/runner.js
CHANGED
|
@@ -14,6 +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 { createTimer, maybeRotate, truncate } from './events.js';
|
|
17
18
|
/** CLI command templates per agent, with {prompt} as a placeholder. */
|
|
18
19
|
const AGENT_COMMANDS = {
|
|
19
20
|
claude: ['claude', '-p', '--verbose', '{prompt}', '--output-format', 'stream-json', '--permission-mode', 'plan'],
|
|
@@ -107,6 +108,15 @@ function generateRunId() {
|
|
|
107
108
|
}
|
|
108
109
|
/** Execute a job synchronously (waits for completion or timeout before resolving). */
|
|
109
110
|
export async function executeJob(config) {
|
|
111
|
+
maybeRotate();
|
|
112
|
+
const timer = createTimer('agent.run', {
|
|
113
|
+
agent: config.agent,
|
|
114
|
+
version: config.version,
|
|
115
|
+
jobName: config.name,
|
|
116
|
+
mode: config.mode,
|
|
117
|
+
prompt: truncate(config.prompt, 200),
|
|
118
|
+
schedule: config.schedule,
|
|
119
|
+
});
|
|
110
120
|
const resolvedPrompt = resolveJobPrompt(config);
|
|
111
121
|
const cmd = buildJobCommand(config, resolvedPrompt);
|
|
112
122
|
const useSandbox = config.sandbox !== false;
|
|
@@ -138,6 +148,8 @@ export async function executeJob(config) {
|
|
|
138
148
|
detached: true,
|
|
139
149
|
env: spawnEnv,
|
|
140
150
|
});
|
|
151
|
+
// Mark startup time (time from function call to process spawn)
|
|
152
|
+
timer.mark('startup');
|
|
141
153
|
meta.pid = child.pid || null;
|
|
142
154
|
writeRunMeta(meta);
|
|
143
155
|
let settled = false;
|
|
@@ -160,6 +172,7 @@ export async function executeJob(config) {
|
|
|
160
172
|
meta.status = 'timeout';
|
|
161
173
|
meta.completedAt = new Date().toISOString();
|
|
162
174
|
writeRunMeta(meta);
|
|
175
|
+
timer.end({ status: 'timeout', runId });
|
|
163
176
|
const reportPath = extractAndSaveReport(stdoutPath, config.agent, runDir);
|
|
164
177
|
resolve({ meta, reportPath });
|
|
165
178
|
}, timeoutMs);
|
|
@@ -176,6 +189,7 @@ export async function executeJob(config) {
|
|
|
176
189
|
meta.status = code === 0 ? 'completed' : 'failed';
|
|
177
190
|
meta.completedAt = new Date().toISOString();
|
|
178
191
|
writeRunMeta(meta);
|
|
192
|
+
timer.end({ status: meta.status, exitCode: code ?? undefined, runId });
|
|
179
193
|
const reportPath = extractAndSaveReport(stdoutPath, config.agent, runDir);
|
|
180
194
|
resolve({ meta, reportPath });
|
|
181
195
|
});
|
|
@@ -191,6 +205,7 @@ export async function executeJob(config) {
|
|
|
191
205
|
meta.status = 'failed';
|
|
192
206
|
meta.completedAt = new Date().toISOString();
|
|
193
207
|
writeRunMeta(meta);
|
|
208
|
+
timer.end({ status: 'failed', error: err.message, runId });
|
|
194
209
|
resolve({ meta, reportPath: null });
|
|
195
210
|
});
|
|
196
211
|
child.unref();
|
|
@@ -16,6 +16,7 @@ import * as os from 'os';
|
|
|
16
16
|
import * as path from 'path';
|
|
17
17
|
import * as yaml from 'yaml';
|
|
18
18
|
import { deleteKeychainToken, getKeychainToken, hasKeychainToken, listKeychainItems, parseBundleValue, resolveRef, secretsKeychainItem, setKeychainToken, } from './index.js';
|
|
19
|
+
import { emit } from '../events.js';
|
|
19
20
|
/** Allowed values for a secret's `type` metadata field. */
|
|
20
21
|
export const SECRET_TYPES = [
|
|
21
22
|
'api-key',
|
|
@@ -148,10 +149,15 @@ export function writeBundle(bundle) {
|
|
|
148
149
|
};
|
|
149
150
|
const json = JSON.stringify(payload);
|
|
150
151
|
setKeychainToken(bundleMetaItem(bundle.name), json, Boolean(bundle.icloud_sync));
|
|
152
|
+
emit('secrets.set', { bundle: bundle.name });
|
|
151
153
|
}
|
|
152
154
|
export function deleteBundle(name) {
|
|
153
155
|
validateBundleName(name);
|
|
154
|
-
|
|
156
|
+
const deleted = deleteKeychainToken(bundleMetaItem(name));
|
|
157
|
+
if (deleted) {
|
|
158
|
+
emit('secrets.delete', { bundle: name });
|
|
159
|
+
}
|
|
160
|
+
return deleted;
|
|
155
161
|
}
|
|
156
162
|
export function listBundles() {
|
|
157
163
|
let services;
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Cross-platform secure credential storage.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
2
|
+
* Cross-platform secure credential storage.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
35
|
-
if (process.platform !== 'darwin') {
|
|
36
|
-
throw new Error('
|
|
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
|
|
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
|
-
|
|
81
|
-
|
|
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
|
|
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
|
-
|
|
97
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
};
|
package/dist/lib/session/db.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/session/db.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/lib/skills.js
CHANGED
|
@@ -13,6 +13,7 @@ import * as yaml from 'yaml';
|
|
|
13
13
|
import { SKILLS_CAPABLE_AGENTS, ensureSkillsDir } from './agents.js';
|
|
14
14
|
import { getUserSkillsDir, getSkillsDir as getSystemSkillsDir, getProjectAgentsDir, getEnabledExtraRepos } from './state.js';
|
|
15
15
|
import { getEffectiveHome, getVersionHomePath, listInstalledVersions } from './versions.js';
|
|
16
|
+
import { emit } from './events.js';
|
|
16
17
|
const HOME = os.homedir();
|
|
17
18
|
/** User-scoped skills dir (~/.agents/skills/). Used for installs. */
|
|
18
19
|
export function getSkillsDir() {
|
|
@@ -255,6 +256,7 @@ export function installSkill(sourcePath, skillName, agents, method = 'symlink')
|
|
|
255
256
|
};
|
|
256
257
|
}
|
|
257
258
|
}
|
|
259
|
+
emit('skill.install', { skill: skillName, agents });
|
|
258
260
|
return { success: true };
|
|
259
261
|
}
|
|
260
262
|
/**
|
|
@@ -555,6 +557,7 @@ export function installSkillToVersion(agent, version, skillName, method = 'copy'
|
|
|
555
557
|
// Best-effort; failure here shouldn't unwind the install
|
|
556
558
|
}
|
|
557
559
|
}
|
|
560
|
+
emit('skill.install', { skill: skillName, agent, version });
|
|
558
561
|
return { success: true };
|
|
559
562
|
}
|
|
560
563
|
/**
|
|
@@ -616,6 +619,7 @@ export function uninstallSkill(skillName) {
|
|
|
616
619
|
catch {
|
|
617
620
|
// Ignore removal errors
|
|
618
621
|
}
|
|
622
|
+
emit('skill.remove', { skill: skillName });
|
|
619
623
|
return { success: true };
|
|
620
624
|
}
|
|
621
625
|
export function listInstalledSkills() {
|
package/dist/lib/usage.d.ts
CHANGED
|
@@ -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
|
|
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.
|