@prohost/cli 0.8.3 → 0.10.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +119 -2
  3. package/dist/agent/account_runtime.d.ts +19 -3
  4. package/dist/agent/account_runtime.js +28 -6
  5. package/dist/agent/accounts.d.ts +17 -1
  6. package/dist/agent/accounts.js +43 -2
  7. package/dist/agent/agent_commands.js +6 -3
  8. package/dist/agent/api.d.ts +6 -0
  9. package/dist/agent/api.js +10 -5
  10. package/dist/agent/claude.js +9 -3
  11. package/dist/agent/command.d.ts +9 -0
  12. package/dist/agent/command.js +64 -2
  13. package/dist/agent/daemon.d.ts +4 -0
  14. package/dist/agent/daemon.js +4 -0
  15. package/dist/agent/prompt.d.ts +27 -0
  16. package/dist/agent/prompt.js +21 -1
  17. package/dist/agent/run.d.ts +28 -3
  18. package/dist/agent/run.js +90 -18
  19. package/dist/agent/scheduler.d.ts +44 -0
  20. package/dist/agent/scheduler.js +93 -0
  21. package/dist/agent/worktrees.d.ts +136 -0
  22. package/dist/agent/worktrees.js +317 -0
  23. package/dist/index.d.ts +1 -0
  24. package/dist/index.js +29 -2
  25. package/dist/usage/backoff.d.ts +42 -0
  26. package/dist/usage/backoff.js +85 -0
  27. package/dist/usage/command.d.ts +31 -0
  28. package/dist/usage/command.js +269 -0
  29. package/dist/usage/config.d.ts +38 -0
  30. package/dist/usage/config.js +75 -0
  31. package/dist/usage/discovery.d.ts +63 -0
  32. package/dist/usage/discovery.js +187 -0
  33. package/dist/usage/quota.d.ts +96 -0
  34. package/dist/usage/quota.js +145 -0
  35. package/dist/usage/scanner.d.ts +117 -0
  36. package/dist/usage/scanner.js +438 -0
  37. package/dist/usage/service.d.ts +122 -0
  38. package/dist/usage/service.js +299 -0
  39. package/dist/version.d.ts +2 -2
  40. package/dist/version.js +1 -1
  41. package/package.json +1 -1
@@ -0,0 +1,31 @@
1
+ /**
2
+ * `prohost usage …` — observer mode: report this machine's Claude Code and
3
+ * Codex capacity and token totals to ProhostAI without pairing an agent.
4
+ *
5
+ * Flag plumbing only; the work lives in `discovery.ts`, `quota.ts`,
6
+ * `scanner.ts` and `service.ts`.
7
+ */
8
+ import type { DiscoveryResult } from './discovery.js';
9
+ type Flags = Record<string, string | boolean>;
10
+ export interface UsageIO {
11
+ out: (line: string) => void;
12
+ err: (line: string) => void;
13
+ env?: NodeJS.ProcessEnv;
14
+ }
15
+ /** launchd label prefix for the usage reporter — distinct from the agent daemon's. */
16
+ export declare const USAGE_LAUNCHD_PREFIX = "ai.prohost.usage";
17
+ /** Label for the usage daemon serving one `$PROHOST_HOME`. Same digest as the agent's, other prefix. */
18
+ export declare function usageLaunchdLabel(home: string): string;
19
+ export declare function usageLogPath(env?: NodeJS.ProcessEnv): string;
20
+ /** `prohost usage start` argv for a supervisor. */
21
+ export declare function usageDaemonArguments(options: {
22
+ execPath: string;
23
+ scriptPath: string;
24
+ machine?: string;
25
+ }): string[];
26
+ /** Dispatch a `usage` subcommand. Returns the exit code. */
27
+ export declare function runUsage(subcommand: string | undefined, flags: Flags, printHelp: () => void, io?: UsageIO): Promise<number>;
28
+ export declare function renderDiscovery(result: DiscoveryResult, env: NodeJS.ProcessEnv, dryRun: boolean): string[];
29
+ /** `1234567` → `1.2M`. */
30
+ export declare function compactTokens(n: number): string;
31
+ export {};
@@ -0,0 +1,269 @@
1
+ /**
2
+ * `prohost usage …` — observer mode: report this machine's Claude Code and
3
+ * Codex capacity and token totals to ProhostAI without pairing an agent.
4
+ *
5
+ * Flag plumbing only; the work lives in `discovery.ts`, `quota.ts`,
6
+ * `scanner.ts` and `service.ts`.
7
+ */
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ import process from 'node:process';
11
+ import { existsSync } from 'node:fs';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { DEFAULT_ACCOUNT_LABEL, loadAccounts, runtimeName } from '../agent/accounts.js';
14
+ import { daemonEnvironment } from '../agent/command.js';
15
+ import { maskSecret, prohostHome } from '../agent/credentials.js';
16
+ import { buildSystemdUnit, installLaunchdDaemon, launchdLabel, launchdPlistPath, uninstallLaunchdDaemon, } from '../agent/daemon.js';
17
+ import { acquireRunLock } from '../agent/lock.js';
18
+ import { ensurePrivateDir, resolveUsageKey, saveUsageCredentials, usageStateDir } from './config.js';
19
+ import { accountConfigDir, discoverAccounts } from './discovery.js';
20
+ import { RETENTION_DAYS, dayKey, loadScanState, localTotals, pendingDays } from './scanner.js';
21
+ import { UsageService, loadStatus, runUsageLoop, scanStatePath } from './service.js';
22
+ const defaultIO = {
23
+ out: (line) => process.stdout.write(`${line}\n`),
24
+ err: (line) => process.stderr.write(`${line}\n`),
25
+ };
26
+ /** launchd label prefix for the usage reporter — distinct from the agent daemon's. */
27
+ export const USAGE_LAUNCHD_PREFIX = 'ai.prohost.usage';
28
+ /** Label for the usage daemon serving one `$PROHOST_HOME`. Same digest as the agent's, other prefix. */
29
+ export function usageLaunchdLabel(home) {
30
+ const digest = launchdLabel(home).split('.').pop();
31
+ return `${USAGE_LAUNCHD_PREFIX}.${digest}`;
32
+ }
33
+ export function usageLogPath(env = process.env) {
34
+ return path.join(prohostHome(env), 'usage.log');
35
+ }
36
+ /** `prohost usage start` argv for a supervisor. */
37
+ export function usageDaemonArguments(options) {
38
+ return [options.execPath, options.scriptPath, 'usage', 'start', ...(options.machine ? ['--machine', options.machine] : [])];
39
+ }
40
+ function flagString(flags, name) {
41
+ const value = flags[name];
42
+ return typeof value === 'string' ? value : undefined;
43
+ }
44
+ function tildify(dir, env) {
45
+ const home = env.HOME || os.homedir();
46
+ return dir === home || dir.startsWith(home + path.sep) ? `~${dir.slice(home.length)}` : dir;
47
+ }
48
+ /** Dispatch a `usage` subcommand. Returns the exit code. */
49
+ export async function runUsage(subcommand, flags, printHelp, io = defaultIO) {
50
+ if (subcommand === 'connect')
51
+ return connectCommand(flags, io);
52
+ if (subcommand === 'discover')
53
+ return discoverCommand(flags, io);
54
+ if (subcommand === 'start')
55
+ return startCommand(flags, io);
56
+ if (subcommand === 'status')
57
+ return statusCommand(io);
58
+ if (subcommand === 'install-daemon')
59
+ return installCommand(flags, io);
60
+ if (subcommand === 'uninstall-daemon')
61
+ return uninstallCommand(io);
62
+ io.err(subcommand
63
+ ? `Unknown usage subcommand: ${subcommand}\n`
64
+ : 'Missing usage subcommand — expected `connect`, `discover`, `start`, `status`, `install-daemon` or `uninstall-daemon`.\n');
65
+ printHelp();
66
+ return 1;
67
+ }
68
+ export function renderDiscovery(result, env, dryRun) {
69
+ const lines = [];
70
+ const total = result.added.length + result.known.length + result.skipped.length;
71
+ if (total === 0) {
72
+ return [
73
+ 'No Claude Code or Codex logins found (looked for ~/.claude*, ~/.codex*, $CLAUDE_CONFIG_DIR and $CODEX_HOME).',
74
+ ];
75
+ }
76
+ lines.push(`Found ${total} login${total === 1 ? '' : 's'}:`);
77
+ const row = (mark, label, runtime, dir, note) => ` ${mark} ${label.padEnd(16)} ${runtimeName(runtime).padEnd(12)} ${tildify(dir, env)}${note ? ` (${note})` : ''}`;
78
+ for (const a of result.added)
79
+ lines.push(row('+', a.label, a.runtime, a.dir, dryRun ? 'would add' : 'added'));
80
+ for (const k of result.known)
81
+ lines.push(row('=', k.label, k.runtime, k.dir, 'already registered'));
82
+ for (const s of result.skipped)
83
+ lines.push(row('!', s.label, s.runtime, s.dir, `skipped: ${s.reason}`));
84
+ return lines;
85
+ }
86
+ async function connectCommand(flags, io) {
87
+ const env = io.env ?? process.env;
88
+ if (flags.key === true) {
89
+ io.err('--key needs a value. Create a key with the usage:write scope in ProhostAI → Settings → API keys.');
90
+ return 1;
91
+ }
92
+ const key = flagString(flags, 'key') ?? env.PROHOST_API_KEY;
93
+ if (key) {
94
+ const file = saveUsageCredentials({ apiKey: key.trim(), baseUrl: flagString(flags, 'base-url') }, env);
95
+ io.out(`✓ Saved key ${maskSecret(key.trim())} to ${file} (owner-only).`);
96
+ }
97
+ else {
98
+ const existing = resolveUsageKey(env);
99
+ if (!existing) {
100
+ io.err('✗ No API key. Pass --key <key> (scope usage:write) or set $PROHOST_API_KEY. ' +
101
+ 'A machine with a paired agent can skip this — its credential is used.');
102
+ return 1;
103
+ }
104
+ io.out(existing.source === 'agent'
105
+ ? `✓ Using this machine's paired agent credential (${maskSecret(existing.apiKey)}).`
106
+ : `✓ Already connected (${maskSecret(existing.apiKey)}).`);
107
+ }
108
+ io.out('');
109
+ for (const line of renderDiscovery(discoverAccounts({}, env), env, false))
110
+ io.out(line);
111
+ io.out('');
112
+ io.out('Report now and every 5 minutes: prohost usage start (or keep it running: prohost usage install-daemon)');
113
+ return 0;
114
+ }
115
+ function discoverCommand(flags, io) {
116
+ const env = io.env ?? process.env;
117
+ const dryRun = flags['dry-run'] === true;
118
+ for (const line of renderDiscovery(discoverAccounts({ dryRun }, env), env, dryRun))
119
+ io.out(line);
120
+ return 0;
121
+ }
122
+ async function startCommand(flags, io) {
123
+ const env = io.env ?? process.env;
124
+ if (!resolveUsageKey(env)) {
125
+ io.err('✗ Not connected. Run: prohost usage connect --key <api key>');
126
+ return 1;
127
+ }
128
+ // Its own lock, under $PROHOST_HOME/usage — `agent run` holds the home's.
129
+ const stateDir = ensurePrivateDir(usageStateDir(env));
130
+ const lock = acquireRunLock({ ...env, PROHOST_HOME: stateDir });
131
+ if (!lock.ok) {
132
+ io.err(`✗ Another \`prohost usage start\` (pid ${lock.holder.pid}) is already reporting for ${prohostHome(env)}. ` +
133
+ 'Stop it first — or, if it is the daemon, use `prohost usage uninstall-daemon`.');
134
+ return 1;
135
+ }
136
+ const controller = new AbortController();
137
+ const onSig = () => controller.abort();
138
+ process.on('SIGINT', onSig);
139
+ process.on('SIGTERM', onSig);
140
+ try {
141
+ // New logins since the last start are picked up without a separate step.
142
+ const found = discoverAccounts({}, env);
143
+ for (const a of found.added)
144
+ io.out(`+ Registered ${runtimeName(a.runtime)} login "${a.label}" (${tildify(a.dir, env)})`);
145
+ const service = new UsageService({ env, machine: flagString(flags, 'machine') });
146
+ if (flags.once === true) {
147
+ const result = await service.tick();
148
+ io.out(`Report ${result.reported ? 'sent' : 'not sent'}; ${result.uploadedRows} token row(s) uploaded.`);
149
+ return 0;
150
+ }
151
+ io.out(`Reporting ${loadAccounts(env).length} login(s) every 5 minutes. Ctrl-C to stop.`);
152
+ await runUsageLoop(service, { signal: controller.signal });
153
+ return 0;
154
+ }
155
+ finally {
156
+ process.off('SIGINT', onSig);
157
+ process.off('SIGTERM', onSig);
158
+ lock.release();
159
+ }
160
+ }
161
+ function cliEntrypoint() {
162
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'index.js');
163
+ }
164
+ function installCommand(flags, io) {
165
+ const env = io.env ?? process.env;
166
+ if (!resolveUsageKey(env)) {
167
+ io.err('✗ Not connected. Run: prohost usage connect --key <api key>');
168
+ return 1;
169
+ }
170
+ const workdir = ensurePrivateDir(usageStateDir(env));
171
+ const spec = {
172
+ label: usageLaunchdLabel(prohostHome(env)),
173
+ programArguments: usageDaemonArguments({
174
+ execPath: process.execPath,
175
+ scriptPath: cliEntrypoint(),
176
+ machine: flagString(flags, 'machine'),
177
+ }),
178
+ workdir,
179
+ logPath: usageLogPath(env),
180
+ env: daemonEnvironment(),
181
+ };
182
+ if (os.platform() !== 'darwin') {
183
+ io.out(`Automatic install is macOS-only. On Linux, save this as ~/.config/systemd/user/${spec.label}.service and run:\n` +
184
+ ` systemctl --user daemon-reload && systemctl --user enable --now ${spec.label}\n` +
185
+ ' loginctl enable-linger "$USER" # so it survives logout\n\n' +
186
+ buildSystemdUnit(spec).replace('ProhostAI paired agent', 'ProhostAI usage reporter'));
187
+ return 0;
188
+ }
189
+ const result = installLaunchdDaemon(spec);
190
+ if (!result.ok) {
191
+ io.err(result.message);
192
+ return 1;
193
+ }
194
+ io.out(`✓ Installed ${spec.label}`);
195
+ io.out(` plist: ${result.plistPath}`);
196
+ io.out(` logs: ${spec.logPath}`);
197
+ io.out(' Usage is reported every 5 minutes from login on, and the reporter restarts if it exits.');
198
+ io.out(' Stop it with: prohost usage uninstall-daemon');
199
+ return 0;
200
+ }
201
+ function uninstallCommand(io) {
202
+ const env = io.env ?? process.env;
203
+ const label = usageLaunchdLabel(prohostHome(env));
204
+ if (os.platform() !== 'darwin') {
205
+ io.out(`Automatic removal is macOS-only. On Linux:\n systemctl --user disable --now ${label}`);
206
+ return 0;
207
+ }
208
+ const existed = existsSync(launchdPlistPath(label));
209
+ uninstallLaunchdDaemon(label);
210
+ io.out(existed ? `✓ Removed ${label}` : `No usage reporter was installed for ${prohostHome(env)}. Nothing to remove.`);
211
+ return 0;
212
+ }
213
+ /** `1234567` → `1.2M`. */
214
+ export function compactTokens(n) {
215
+ if (n >= 1e9)
216
+ return `${(n / 1e9).toFixed(1)}B`;
217
+ if (n >= 1e6)
218
+ return `${(n / 1e6).toFixed(1)}M`;
219
+ if (n >= 1e3)
220
+ return `${(n / 1e3).toFixed(1)}k`;
221
+ return String(n);
222
+ }
223
+ function sum(t) {
224
+ return t ? t[0] + t[1] + t[2] + t[3] : 0;
225
+ }
226
+ function describeCall(call) {
227
+ if (!call)
228
+ return 'never';
229
+ const outcome = call.ok ? '✓' : `✗ ${call.status === 0 ? 'no response' : `HTTP ${call.status}`}`;
230
+ return `${call.at} ${outcome}${call.count !== undefined ? ` (${call.count})` : ''}`;
231
+ }
232
+ function statusCommand(io) {
233
+ const env = io.env ?? process.env;
234
+ const key = resolveUsageKey(env);
235
+ io.out(key
236
+ ? `Connected ${maskSecret(key.apiKey)} → ${key.baseUrl}${key.source === 'agent' ? ' (paired agent credential)' : ''}`
237
+ : 'Connected no — run: prohost usage connect --key <api key>');
238
+ const plist = launchdPlistPath(usageLaunchdLabel(prohostHome(env)));
239
+ io.out(`Daemon ${os.platform() === 'darwin' ? (existsSync(plist) ? `installed (${tildify(plist, env)})` : 'not installed') : 'see systemd'}`);
240
+ const status = loadStatus(env);
241
+ io.out(`Last tick ${status.lastTickAt ?? 'never'}`);
242
+ io.out(`Report ${describeCall(status.lastReport)}`);
243
+ io.out(`Totals ${describeCall(status.lastTotals)}`);
244
+ const scan = loadScanState(scanStatePath(env));
245
+ if (scan.dirty.length > 0)
246
+ io.out(`Pending ${pendingDays(scan).length} day(s) not yet uploaded`);
247
+ const accounts = loadAccounts(env);
248
+ io.out('');
249
+ if (accounts.length === 0) {
250
+ io.out('No logins registered. Run: prohost usage discover');
251
+ return 0;
252
+ }
253
+ const now = Date.now();
254
+ const week = localTotals(scan, dayKey(now - 6 * 86_400_000));
255
+ const month = localTotals(scan, dayKey(now - (RETENTION_DAYS - 1) * 86_400_000));
256
+ io.out('Logins (tokens: last 7 days / 30 days, from local logs)');
257
+ for (const record of accounts) {
258
+ const seen = status.accounts[record.key];
259
+ const auth = seen?.authState === 'ok' ? 'signed in' : seen?.authState === 'expired' ? 'signed out' : 'unknown';
260
+ const windows = (seen?.windows ?? [])
261
+ .map((w) => `${w.kind === 'weekly_model' ? `weekly ${w.model_label ?? ''}`.trim() : w.kind} ${w.used_percent}%`)
262
+ .join(', ');
263
+ io.out(` ${record.label.padEnd(16)} ${runtimeName(record.runtime).padEnd(12)} ${auth.padEnd(10)} ` +
264
+ `${compactTokens(sum(week.get(record.key)))} / ${compactTokens(sum(month.get(record.key)))}` +
265
+ `${windows ? ` · ${windows}` : ''}` +
266
+ `${record.label === DEFAULT_ACCOUNT_LABEL ? '' : ` · ${tildify(accountConfigDir(record, env), env)}`}`);
267
+ }
268
+ return 0;
269
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Where `prohost usage` keeps its own state, and which API key it reports with.
3
+ *
4
+ * Observer mode needs no paired agent: `prohost usage connect --key …` stores
5
+ * a key (scope `usage:write`) in `$PROHOST_HOME/usage.json`, written `0600`
6
+ * inside a `0700` directory exactly like `agent.json`. A machine that already
7
+ * paired an agent needs nothing new — its `agent_runs:write` key is accepted
8
+ * too, so without a usage key the agent's credential is used.
9
+ *
10
+ * The key is never printed; {@link maskSecret} is the only form that reaches
11
+ * the terminal.
12
+ */
13
+ export interface UsageCredentials {
14
+ version: 1;
15
+ base_url: string;
16
+ api_key: string;
17
+ connected_at: string;
18
+ }
19
+ export interface ResolvedKey {
20
+ baseUrl: string;
21
+ apiKey: string;
22
+ /** `usage` — `usage.json`; `agent` — the paired agent's `agent.json`. */
23
+ source: 'usage' | 'agent';
24
+ }
25
+ export declare function usageCredentialsPath(env?: NodeJS.ProcessEnv): string;
26
+ /** Directory for the scanner cursor and reporter status. */
27
+ export declare function usageStateDir(env?: NodeJS.ProcessEnv): string;
28
+ /** Create a `0700` directory (tightening one that already exists). */
29
+ export declare function ensurePrivateDir(dir: string): string;
30
+ /** Write a file `0600` via write-then-rename, so a crash never leaves half a file. */
31
+ export declare function writePrivateFile(file: string, content: string): void;
32
+ export declare function saveUsageCredentials(options: {
33
+ apiKey: string;
34
+ baseUrl?: string;
35
+ }, env?: NodeJS.ProcessEnv): string;
36
+ export declare function loadUsageCredentials(env?: NodeJS.ProcessEnv): UsageCredentials | null;
37
+ /** The key reports go out with: `usage.json` first, then a paired agent's. */
38
+ export declare function resolveUsageKey(env?: NodeJS.ProcessEnv): ResolvedKey | null;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Where `prohost usage` keeps its own state, and which API key it reports with.
3
+ *
4
+ * Observer mode needs no paired agent: `prohost usage connect --key …` stores
5
+ * a key (scope `usage:write`) in `$PROHOST_HOME/usage.json`, written `0600`
6
+ * inside a `0700` directory exactly like `agent.json`. A machine that already
7
+ * paired an agent needs nothing new — its `agent_runs:write` key is accepted
8
+ * too, so without a usage key the agent's credential is used.
9
+ *
10
+ * The key is never printed; {@link maskSecret} is the only form that reaches
11
+ * the terminal.
12
+ */
13
+ import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
14
+ import path from 'node:path';
15
+ import { DEFAULT_API_BASE_URL } from '../agent/contract.js';
16
+ import { prohostHome, tryLoadCredentials } from '../agent/credentials.js';
17
+ export function usageCredentialsPath(env = process.env) {
18
+ return path.join(prohostHome(env), 'usage.json');
19
+ }
20
+ /** Directory for the scanner cursor and reporter status. */
21
+ export function usageStateDir(env = process.env) {
22
+ return path.join(prohostHome(env), 'usage');
23
+ }
24
+ /** Create a `0700` directory (tightening one that already exists). */
25
+ export function ensurePrivateDir(dir) {
26
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
27
+ chmodSync(dir, 0o700);
28
+ return dir;
29
+ }
30
+ /** Write a file `0600` via write-then-rename, so a crash never leaves half a file. */
31
+ export function writePrivateFile(file, content) {
32
+ ensurePrivateDir(path.dirname(file));
33
+ const temp = `${file}.${process.pid}.tmp`;
34
+ writeFileSync(temp, content, { mode: 0o600 });
35
+ chmodSync(temp, 0o600);
36
+ renameSync(temp, file);
37
+ }
38
+ export function saveUsageCredentials(options, env = process.env) {
39
+ ensurePrivateDir(prohostHome(env));
40
+ const file = usageCredentialsPath(env);
41
+ const record = {
42
+ version: 1,
43
+ base_url: options.baseUrl || DEFAULT_API_BASE_URL,
44
+ api_key: options.apiKey,
45
+ connected_at: new Date().toISOString(),
46
+ };
47
+ writePrivateFile(file, `${JSON.stringify(record, null, 2)}\n`);
48
+ return file;
49
+ }
50
+ export function loadUsageCredentials(env = process.env) {
51
+ try {
52
+ const parsed = JSON.parse(readFileSync(usageCredentialsPath(env), 'utf8'));
53
+ if (typeof parsed?.api_key !== 'string' || !parsed.api_key)
54
+ return null;
55
+ return {
56
+ version: 1,
57
+ base_url: typeof parsed.base_url === 'string' && parsed.base_url ? parsed.base_url : DEFAULT_API_BASE_URL,
58
+ api_key: parsed.api_key,
59
+ connected_at: typeof parsed.connected_at === 'string' ? parsed.connected_at : '',
60
+ };
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ }
66
+ /** The key reports go out with: `usage.json` first, then a paired agent's. */
67
+ export function resolveUsageKey(env = process.env) {
68
+ const usage = loadUsageCredentials(env);
69
+ if (usage)
70
+ return { baseUrl: usage.base_url, apiKey: usage.api_key, source: 'usage' };
71
+ const agent = tryLoadCredentials(env);
72
+ if (agent)
73
+ return { baseUrl: agent.base_url || DEFAULT_API_BASE_URL, apiKey: agent.api_key, source: 'agent' };
74
+ return null;
75
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Find the Claude Code and Codex logins already on this machine.
3
+ *
4
+ * People who juggle several subscriptions keep each one in its own config
5
+ * directory — `~/.claude-work`, `~/.codex-personal`, or whatever
6
+ * `$CLAUDE_CONFIG_DIR` / `$CODEX_HOME` points at. Observer mode reports all of
7
+ * them, so instead of asking for each one to be re-created under
8
+ * `~/.prohost-accounts`, it registers the directories that are already there.
9
+ *
10
+ * Only directory names and the presence of a few non-secret markers
11
+ * (`projects/`, `settings.json`, `sessions/`, `config.toml`…) are looked at —
12
+ * never a file's contents, and never anything that holds a credential.
13
+ * Whether a login is signed in is asked of the unmodified agent CLI later,
14
+ * exactly as `accounts.ts` already does.
15
+ */
16
+ import type { AccountRecord, AccountRuntime } from '../agent/accounts.js';
17
+ export interface DiscoveredDir {
18
+ runtime: AccountRuntime;
19
+ /** Absolute, symlinks resolved. */
20
+ dir: string;
21
+ /** The label it would be registered under. */
22
+ label: string;
23
+ /** `~/.claude` / `~/.codex` — the agent CLI's own default login. */
24
+ isDefault: boolean;
25
+ /** Why it was found: a home-directory entry or an environment variable. */
26
+ via: 'home' | 'CLAUDE_CONFIG_DIR' | 'CODEX_HOME';
27
+ }
28
+ export interface DiscoveryResult {
29
+ /** Newly registered (or, on a dry run, that would be). */
30
+ added: Array<DiscoveredDir & {
31
+ record?: AccountRecord;
32
+ }>;
33
+ /** Found but already in the registry. */
34
+ known: Array<DiscoveredDir & {
35
+ record: AccountRecord;
36
+ }>;
37
+ /** Found but not registered, and why. */
38
+ skipped: Array<DiscoveredDir & {
39
+ reason: string;
40
+ }>;
41
+ }
42
+ /** The directory an account's logs live in: its own, or the CLI's default under `$HOME`. */
43
+ export declare function accountConfigDir(record: AccountRecord, env?: NodeJS.ProcessEnv): string;
44
+ /**
45
+ * A registry label from a directory name: `~/.claude-baxy` → `baxy`,
46
+ * `~/.codex_work` → `work`, `~/.claude` → `default`. Anything a label can't
47
+ * hold becomes `-`; an empty result falls back to the runtime's name.
48
+ */
49
+ export declare function deriveLabel(dirName: string, runtime: AccountRuntime): string;
50
+ /**
51
+ * Every config directory on this machine, before any registry comparison:
52
+ * `~/.claude*` / `~/.codex*` that look like config directories, plus
53
+ * `$CLAUDE_CONFIG_DIR` / `$CODEX_HOME`. Deduplicated by real path.
54
+ */
55
+ export declare function findConfigDirs(env?: NodeJS.ProcessEnv): DiscoveredDir[];
56
+ /**
57
+ * Register every login found by {@link findConfigDirs} that the machine-wide
58
+ * registry doesn't already know. `dryRun` reports the same answer and writes
59
+ * nothing.
60
+ */
61
+ export declare function discoverAccounts(options?: {
62
+ dryRun?: boolean;
63
+ }, env?: NodeJS.ProcessEnv): DiscoveryResult;
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Find the Claude Code and Codex logins already on this machine.
3
+ *
4
+ * People who juggle several subscriptions keep each one in its own config
5
+ * directory — `~/.claude-work`, `~/.codex-personal`, or whatever
6
+ * `$CLAUDE_CONFIG_DIR` / `$CODEX_HOME` points at. Observer mode reports all of
7
+ * them, so instead of asking for each one to be re-created under
8
+ * `~/.prohost-accounts`, it registers the directories that are already there.
9
+ *
10
+ * Only directory names and the presence of a few non-secret markers
11
+ * (`projects/`, `settings.json`, `sessions/`, `config.toml`…) are looked at —
12
+ * never a file's contents, and never anything that holds a credential.
13
+ * Whether a login is signed in is asked of the unmodified agent CLI later,
14
+ * exactly as `accounts.ts` already does.
15
+ */
16
+ import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs';
17
+ import os from 'node:os';
18
+ import path from 'node:path';
19
+ import { AccountError, DEFAULT_ACCOUNT_LABEL, MAX_REPORTED_ACCOUNTS, ensureDefaultAccount, isValidLabel, loadAccounts, registerAccountDir, } from '../agent/accounts.js';
20
+ /** Entries whose presence says "this is a Claude Code config directory". None of them hold a login. */
21
+ const CLAUDE_MARKERS = ['projects', 'settings.json', '.claude.json', 'history.jsonl', 'todos', 'statsig', 'shell-snapshots'];
22
+ /** Same for Codex. `auth.json` is deliberately not one of them. */
23
+ const CODEX_MARKERS = ['sessions', 'archived_sessions', 'config.toml', 'history.jsonl', 'log', 'version.json'];
24
+ const CLAUDE_NAME = /^\.claude(?:[-_.](.+))?$/;
25
+ const CODEX_NAME = /^\.codex(?:[-_.](.+))?$/;
26
+ function homeDir(env) {
27
+ return env.HOME || os.homedir();
28
+ }
29
+ function resolveReal(dir) {
30
+ try {
31
+ return realpathSync(dir);
32
+ }
33
+ catch {
34
+ return path.resolve(dir);
35
+ }
36
+ }
37
+ function isDirectory(p) {
38
+ try {
39
+ return statSync(p).isDirectory();
40
+ }
41
+ catch {
42
+ return false;
43
+ }
44
+ }
45
+ function looksLikeConfigDir(dir, runtime) {
46
+ const markers = runtime === 'claude_code' ? CLAUDE_MARKERS : CODEX_MARKERS;
47
+ return isDirectory(dir) && markers.some((m) => existsSync(path.join(dir, m)));
48
+ }
49
+ /** The directory an account's logs live in: its own, or the CLI's default under `$HOME`. */
50
+ export function accountConfigDir(record, env = process.env) {
51
+ if (record.dir)
52
+ return record.dir;
53
+ return path.join(homeDir(env), record.runtime === 'claude_code' ? '.claude' : '.codex');
54
+ }
55
+ /**
56
+ * A registry label from a directory name: `~/.claude-baxy` → `baxy`,
57
+ * `~/.codex_work` → `work`, `~/.claude` → `default`. Anything a label can't
58
+ * hold becomes `-`; an empty result falls back to the runtime's name.
59
+ */
60
+ export function deriveLabel(dirName, runtime) {
61
+ const base = path.basename(dirName);
62
+ const match = (runtime === 'claude_code' ? CLAUDE_NAME : CODEX_NAME).exec(base);
63
+ if (match && match[1] === undefined)
64
+ return DEFAULT_ACCOUNT_LABEL;
65
+ const raw = match ? match[1] : base.replace(/^\.+/, '');
66
+ const cleaned = raw
67
+ .replace(/[^A-Za-z0-9_-]+/g, '-')
68
+ .replace(/^[^A-Za-z0-9]+/, '')
69
+ .replace(/-+$/, '')
70
+ .slice(0, 32);
71
+ if (cleaned && isValidLabel(cleaned))
72
+ return cleaned;
73
+ return runtime === 'claude_code' ? 'claude' : 'codex';
74
+ }
75
+ /** Make `label` unique among `taken` by appending `-2`, `-3`… (and never `default`). */
76
+ function uniqueLabel(label, taken) {
77
+ if (!taken.has(label) && label !== DEFAULT_ACCOUNT_LABEL)
78
+ return label;
79
+ for (let n = 2;; n += 1) {
80
+ const suffix = `-${n}`;
81
+ const candidate = `${label.slice(0, 32 - suffix.length)}${suffix}`;
82
+ if (!taken.has(candidate))
83
+ return candidate;
84
+ }
85
+ }
86
+ /**
87
+ * Every config directory on this machine, before any registry comparison:
88
+ * `~/.claude*` / `~/.codex*` that look like config directories, plus
89
+ * `$CLAUDE_CONFIG_DIR` / `$CODEX_HOME`. Deduplicated by real path.
90
+ */
91
+ export function findConfigDirs(env = process.env) {
92
+ const home = homeDir(env);
93
+ const found = new Map();
94
+ const defaults = {
95
+ claude_code: resolveReal(path.join(home, '.claude')),
96
+ codex: resolveReal(path.join(home, '.codex')),
97
+ };
98
+ const consider = (dir, runtime, via) => {
99
+ if (!looksLikeConfigDir(dir, runtime))
100
+ return;
101
+ const real = resolveReal(dir);
102
+ const key = `${runtime}:${real}`;
103
+ if (found.has(key))
104
+ return;
105
+ const isDefault = real === defaults[runtime];
106
+ found.set(key, {
107
+ runtime,
108
+ dir: real,
109
+ label: isDefault ? DEFAULT_ACCOUNT_LABEL : deriveLabel(dir, runtime),
110
+ isDefault,
111
+ via,
112
+ });
113
+ };
114
+ let entries = [];
115
+ try {
116
+ entries = readdirSync(home).sort();
117
+ }
118
+ catch {
119
+ /* no readable home: only the env vars below */
120
+ }
121
+ for (const name of entries) {
122
+ if (CLAUDE_NAME.test(name))
123
+ consider(path.join(home, name), 'claude_code', 'home');
124
+ else if (CODEX_NAME.test(name))
125
+ consider(path.join(home, name), 'codex', 'home');
126
+ }
127
+ if (env.CLAUDE_CONFIG_DIR)
128
+ consider(path.resolve(env.CLAUDE_CONFIG_DIR), 'claude_code', 'CLAUDE_CONFIG_DIR');
129
+ if (env.CODEX_HOME)
130
+ consider(path.resolve(env.CODEX_HOME), 'codex', 'CODEX_HOME');
131
+ return [...found.values()];
132
+ }
133
+ /**
134
+ * Register every login found by {@link findConfigDirs} that the machine-wide
135
+ * registry doesn't already know. `dryRun` reports the same answer and writes
136
+ * nothing.
137
+ */
138
+ export function discoverAccounts(options = {}, env = process.env) {
139
+ const result = { added: [], known: [], skipped: [] };
140
+ const registry = loadAccounts(env);
141
+ const byDir = new Map();
142
+ for (const record of registry) {
143
+ // The implicit default (dir null) compares on the CLI's own directory.
144
+ byDir.set(`${record.runtime}:${resolveReal(accountConfigDir(record, env))}`, record);
145
+ }
146
+ const taken = { claude_code: new Set(), codex: new Set() };
147
+ for (const record of registry)
148
+ taken[record.runtime].add(record.label);
149
+ let room = MAX_REPORTED_ACCOUNTS - 2 - registry.filter((a) => a.label !== DEFAULT_ACCOUNT_LABEL).length;
150
+ for (const found of findConfigDirs(env)) {
151
+ const existing = byDir.get(`${found.runtime}:${found.dir}`);
152
+ if (existing) {
153
+ result.known.push({ ...found, label: existing.label, record: existing });
154
+ continue;
155
+ }
156
+ if (found.isDefault) {
157
+ if (options.dryRun)
158
+ result.added.push(found);
159
+ else
160
+ result.added.push({ ...found, record: ensureDefaultAccount(found.runtime, env) });
161
+ continue;
162
+ }
163
+ if (room <= 0) {
164
+ result.skipped.push({ ...found, reason: `this machine already has ${MAX_REPORTED_ACCOUNTS - 2} accounts` });
165
+ continue;
166
+ }
167
+ const label = uniqueLabel(found.label, taken[found.runtime]);
168
+ taken[found.runtime].add(label);
169
+ const candidate = { ...found, label };
170
+ if (options.dryRun) {
171
+ result.added.push(candidate);
172
+ room -= 1;
173
+ continue;
174
+ }
175
+ try {
176
+ const record = registerAccountDir({ label, runtime: found.runtime, dir: found.dir }, env);
177
+ result.added.push({ ...candidate, label: record.label, record });
178
+ room -= 1;
179
+ }
180
+ catch (err) {
181
+ if (!(err instanceof AccountError))
182
+ throw err;
183
+ result.skipped.push({ ...candidate, reason: err.message });
184
+ }
185
+ }
186
+ return result;
187
+ }