@shardflux/cli 0.18.0-next.5 → 0.18.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 (42) hide show
  1. package/CHANGELOG.md +61 -8
  2. package/README.md +92 -33
  3. package/dist/account-args.js +5 -3
  4. package/dist/account-commands.d.ts +4 -1
  5. package/dist/account-commands.js +104 -14
  6. package/dist/account-pool.d.ts +31 -0
  7. package/dist/account-pool.js +313 -0
  8. package/dist/agent-setup.d.ts +38 -0
  9. package/dist/agent-setup.js +116 -0
  10. package/dist/agent-target-commands.js +10 -1
  11. package/dist/agent-targets.d.ts +11 -0
  12. package/dist/agent-targets.js +22 -4
  13. package/dist/args.d.ts +3 -1
  14. package/dist/args.js +50 -5
  15. package/dist/claude-cloud-login.d.ts +11 -0
  16. package/dist/claude-cloud-login.js +49 -39
  17. package/dist/cli-proxy-hub.d.ts +26 -1
  18. package/dist/cli-proxy-hub.js +60 -4
  19. package/dist/commands.d.ts +3 -0
  20. package/dist/commands.js +7 -3
  21. package/dist/conversation-commands.d.ts +5 -0
  22. package/dist/conversation-commands.js +8 -2
  23. package/dist/conversations.d.ts +9 -0
  24. package/dist/desktop-linux.js +6 -5
  25. package/dist/filesystem-commands.d.ts +12 -2
  26. package/dist/filesystem-commands.js +46 -18
  27. package/dist/git-remote-bin.js +0 -0
  28. package/dist/handlers.js +2 -1
  29. package/dist/input.d.ts +4 -2
  30. package/dist/input.js +6 -3
  31. package/dist/main.js +19 -8
  32. package/dist/menubar.js +1 -1
  33. package/dist/t3-asar.d.ts +12 -2
  34. package/dist/t3-asar.js +14 -5
  35. package/dist/t3-launch.js +4 -1
  36. package/dist/t3-linux.d.ts +12 -13
  37. package/dist/t3-linux.js +81 -87
  38. package/dist/t3-mode.d.ts +4 -3
  39. package/dist/t3-mode.js +15 -14
  40. package/dist/t3-version.d.ts +8 -0
  41. package/dist/t3-version.js +42 -0
  42. package/package.json +21 -7
@@ -0,0 +1,313 @@
1
+ import { ShardfluxApiError } from '@shardflux/sdk';
2
+ import { out, readInput, resolveWorkspace } from "./commands.js";
3
+ import { CodedError, UsageError, describeError } from "./errors.js";
4
+ import { claudeLoginUrls, requireClaudeTerminal, runWorkspaceLogin } from "./claude-cloud-login.js";
5
+ import { openMachine } from "./filesystem-commands.js";
6
+ import { t3Install } from "./t3-mode.js";
7
+ import { ensureCliProxyHub, sdkHubCloud } from "./cli-proxy-hub.js";
8
+ import { homeProject } from "./cli-proxy.js";
9
+ import { progress } from "./progress.js";
10
+ const PROVIDERS = { claude: 'Claude', codex: 'ChatGPT / Codex' };
11
+ const WINDOW_LABELS = { five_hour: '5-hour', seven_day: 'Weekly', seven_day_opus: 'Weekly Opus', seven_day_sonnet: 'Weekly Sonnet', primary: 'Primary', secondary: 'Secondary' };
12
+ /** Codex names its windows primary and secondary; their length says which is the 5-hour and which the weekly one. */
13
+ const windowLabel = (w) => (w.window === 'primary' || w.window === 'secondary') && w.window_minutes === 300 ? '5-hour'
14
+ : (w.window === 'primary' || w.window === 'secondary') && w.window_minutes === 10080 ? 'Weekly' : WINDOW_LABELS[w.window] ?? w.window;
15
+ const STATUS_LABELS = { signed_in: 'Signed in', low: 'Low', limited: 'Limited', expired: 'Expired' };
16
+ const CLAUDE_VERSION = '2.1.289';
17
+ function provider(value, what = 'provider') {
18
+ if (value === 'claude' || value === 'codex')
19
+ return value;
20
+ throw new UsageError(`${what} is claude or codex`);
21
+ }
22
+ const optionalProvider = (value) => value === undefined ? undefined : provider(value, '--provider');
23
+ /** "in 2h 10m", "in 4d 8h", "in 41m"; "now" once passed. */
24
+ export function resetIn(resetsAt, now) {
25
+ if (!resetsAt)
26
+ return null;
27
+ const minutes = Math.round((Date.parse(resetsAt) - now) / 60_000);
28
+ if (!Number.isFinite(minutes))
29
+ return null;
30
+ if (minutes <= 0)
31
+ return 'now';
32
+ const d = Math.floor(minutes / 1440), h = Math.floor((minutes % 1440) / 60), m = minutes % 60;
33
+ return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m`;
34
+ }
35
+ const pct = (w) => `${Math.round(w.used_percent)}%`;
36
+ const plan = (a) => a.kind === 'api_key' ? 'API key' : a.plan_type ? a.plan_type.charAt(0).toUpperCase() + a.plan_type.slice(1) : '';
37
+ const threads = (n) => `${n} ${n === 1 ? 'thread' : 'threads'}`;
38
+ const name = (a) => a.email && a.label !== a.email ? `${a.label} (${a.email})` : a.label;
39
+ function bar(used, width = 10) {
40
+ const filled = Math.max(0, Math.min(width, Math.round(used / 100 * width)));
41
+ return '█'.repeat(filled) + '░'.repeat(width - filled);
42
+ }
43
+ function asOf(a, now) {
44
+ if (!a.as_of || !a.stale)
45
+ return '';
46
+ const ago = resetIn(new Date(2 * now - Date.parse(a.as_of)).toISOString(), now);
47
+ return ago ? ` · as of ${ago} ago` : '';
48
+ }
49
+ /** Account references: the id, `<provider>:<n>` (its place in the list, from 1), the email or the label. */
50
+ export function findAccount(list, ref) {
51
+ const byPlace = /^(claude|codex):(\d+)$/.exec(ref);
52
+ const matches = list.accounts.filter(a => a.id === ref || (byPlace ? a.provider === byPlace[1] && a.position === Number(byPlace[2]) - 1 : a.email === ref || a.label === ref));
53
+ if (matches.length === 1)
54
+ return matches[0];
55
+ if (matches.length > 1)
56
+ throw new UsageError(`${ref} names ${matches.length} accounts; use its id or ${matches[0].provider}:<n> from shard agents accounts`);
57
+ throw new UsageError(`No account ${ref}; shard agents accounts lists them (id, claude:<n>, codex:<n>, email or label)`);
58
+ }
59
+ /** The compact list: one row per account, grouped by provider. */
60
+ export function formatAccounts(list, now) {
61
+ if (!list.accounts.length)
62
+ return 'No Claude or ChatGPT/Codex accounts yet. Add one with shard agents accounts add claude|codex.\n';
63
+ const lines = [];
64
+ for (const p of ['claude', 'codex']) {
65
+ const rows = list.accounts.filter(a => a.provider === p);
66
+ if (!rows.length)
67
+ continue;
68
+ lines.push(PROVIDERS[p]);
69
+ for (const a of rows) {
70
+ const windows = a.kind === 'api_key' ? 'Pay as you go' : a.windows.filter(w => ['five_hour', 'seven_day', 'primary', 'secondary'].includes(w.window))
71
+ .map(w => `${windowLabel(w)} ${pct(w)}${resetIn(w.resets_at, now) ? ` (resets ${resetIn(w.resets_at, now) === 'now' ? 'now' : `in ${resetIn(w.resets_at, now)}`})` : ''}`).join(' · ') || 'No usage read yet';
72
+ const tags = [plan(a), a.preferred ? 'in use' : '', list.pool.pooled ? threads(a.threads) : ''].filter(Boolean).join(', ');
73
+ lines.push(` ${`${p}:${a.position + 1}`.padEnd(9)} ${name(a)}${tags ? ` (${tags})` : ''} · ${STATUS_LABELS[a.status]}${a.status === 'expired' ? ': sign in again with shard agents accounts add ' + p : ''}`);
74
+ lines.push(` ${''.padEnd(9)} ${windows}${asOf(a, now)}`);
75
+ }
76
+ }
77
+ if (list.pool.pooled) {
78
+ const open = list.accounts.reduce((n, a) => n + a.threads, 0), used = list.accounts.filter(a => a.threads > 0).length;
79
+ lines.push(`Pooled: ${threads(open)} on ${used} ${used === 1 ? 'account' : 'accounts'}; open threads remotely in T3 Code.`);
80
+ }
81
+ return `${lines.join('\n')}\n`;
82
+ }
83
+ /** The usage view: every window of every account as a bar with its reset time. */
84
+ export function formatUsage(list, now) {
85
+ if (!list.accounts.length)
86
+ return formatAccounts(list, now);
87
+ const lines = [];
88
+ for (const a of list.accounts) {
89
+ lines.push(`${PROVIDERS[a.provider]} · ${name(a)}${plan(a) ? ` · ${plan(a)}` : ''} · ${STATUS_LABELS[a.status]} · ${threads(a.threads)}${a.preferred ? ' · in use' : ''}${asOf(a, now)}`);
90
+ if (a.kind === 'api_key')
91
+ lines.push(' Pay as you go');
92
+ else if (!a.windows.length)
93
+ lines.push(' No usage read yet');
94
+ for (const w of a.windows) {
95
+ const reset = resetIn(w.resets_at, now);
96
+ lines.push(` ${windowLabel(w).padEnd(13)} ${bar(w.used_percent)} ${pct(w).padStart(4)}${reset ? ` ${reset === 'now' ? 'resets now' : `resets in ${reset}`}` : ''}`);
97
+ }
98
+ }
99
+ return `${lines.join('\n')}\n`;
100
+ }
101
+ /**
102
+ * The workspace a sign-in runs in: --workspace, else the account's machine VM, so a sign-in is the same from any
103
+ * folder (the menu bar starts it in Terminal in the home folder). The provider login runs in a throwaway profile there.
104
+ */
105
+ async function signInWorkspace(ctx, values) {
106
+ if (typeof values.workspace === 'string')
107
+ return resolveWorkspace(ctx, values.workspace);
108
+ return (await openMachine(ctx, undefined, { sync: false })).ws;
109
+ }
110
+ function loginFile(ctx, path, value) {
111
+ const raw = JSON.stringify(value), content_b64 = Buffer.from(raw).toString('base64');
112
+ const hide = (v) => {
113
+ if (typeof v === 'string')
114
+ ctx.addSecret(v);
115
+ else if (Array.isArray(v))
116
+ v.forEach(hide);
117
+ else if (v && typeof v === 'object')
118
+ Object.values(v).forEach(hide);
119
+ };
120
+ hide(value);
121
+ ctx.addSecret(raw);
122
+ ctx.addSecret(content_b64);
123
+ return { path, mode: '0600', content_b64 };
124
+ }
125
+ /** Codex's device sign-in page, as Codex 0.160.0 prints it (test/fixtures/cloud-agents/codex-login-device-auth-0.160.0.json). */
126
+ export function codexLoginUrls(output) {
127
+ const urls = new Set();
128
+ // eslint-disable-next-line no-control-regex
129
+ for (const match of output.matchAll(/https:\/\/[^\s\x00-\x20]+(?=[\s\x00-\x20])/g)) {
130
+ try {
131
+ const url = new URL(match[0]);
132
+ if (url.hostname === 'auth.openai.com' && url.pathname === '/codex/device' && !url.username && !url.password)
133
+ urls.add(url.toString());
134
+ }
135
+ catch { /* a fragment waits for the next frame */ }
136
+ }
137
+ return [...urls];
138
+ }
139
+ /**
140
+ * Adds a subscription account: the provider's own sign-in runs in a fresh profile in the workspace (Claude:
141
+ * `claude auth login --claudeai`; Codex: `codex login --device-auth`), its login files go to the API, and the profile
142
+ * is deleted. The workspace's own logins are untouched; the API delivers the account threads use.
143
+ */
144
+ async function signIn(ctx, values, app) {
145
+ if (ctx.json || !ctx.io.stdin.isTTY || !ctx.io.stdin.setRawMode || !ctx.io.stdin.on) {
146
+ throw new UsageError(`Signing in to ${PROVIDERS[app]} runs in a terminal: shard agents accounts add ${app} (or --api-key-stdin for an API key).`);
147
+ }
148
+ if (app === 'claude')
149
+ requireClaudeTerminal(ctx);
150
+ const ws = await signInWorkspace(ctx, values);
151
+ const api = ctx.cloud().agentAccounts;
152
+ const claim = await api.signIn(app, ws.id, { ...(typeof values.label === 'string' ? { label: values.label } : {}), ...(values.scope === 'team' || values.scope === 'personal' ? { scope: values.scope } : {}) });
153
+ const root = `/tmp/shard-account-${claim.sign_in_id}`;
154
+ const session = `account-sign-in-${claim.sign_in_id}`;
155
+ const cell = ws.cell({ agentLabel: ctx.agentLabel, transitionTimeoutMs: ctx.wakeTimeoutMs });
156
+ let complete = false;
157
+ try {
158
+ await cell.files.mkdir(root, { parents: true, mode: '0700' });
159
+ let files;
160
+ if (app === 'claude') {
161
+ const env = { HOME: root, CLAUDE_CONFIG_DIR: `${root}/.claude`, BROWSER: '/bin/false', DISABLE_AUTOUPDATER: '1' };
162
+ const version = await cell.exec.run(['claude', '--version'], { env, cwd: root, signal: ctx.signal, timeoutMs: 10_000 });
163
+ if (version.exitCode !== 0 || version.stdout.trim() !== `${CLAUDE_VERSION} (Claude Code)`)
164
+ throw new UsageError(`Claude sign-in requires Claude Code ${CLAUDE_VERSION} in the workspace image.`);
165
+ if (!await runWorkspaceLogin(ctx, ws, cell, { session, argv: ['claude', 'auth', 'login', '--claudeai'], env, cwd: root, urls: claudeLoginUrls }))
166
+ throw new UsageError('The Claude sign-in was interrupted. Run shard agents accounts add claude again.');
167
+ const credential = JSON.parse(await cell.files.readText(`${root}/.claude/.credentials.json`));
168
+ const config = JSON.parse(await cell.files.readText(`${root}/.claude/.claude.json`));
169
+ files = [loginFile(ctx, '~/.claude/.credentials.json', credential), loginFile(ctx, '~/.claude.json', { oauthAccount: config.oauthAccount })];
170
+ }
171
+ else {
172
+ const env = { HOME: root, CODEX_HOME: `${root}/.codex`, BROWSER: '/bin/false' };
173
+ await cell.files.mkdir(`${root}/.codex`, { parents: true, mode: '0700' });
174
+ if (!await runWorkspaceLogin(ctx, ws, cell, { session, argv: ['codex', 'login', '--device-auth', '-c', 'cli_auth_credentials_store="file"'], env, cwd: root, urls: codexLoginUrls }))
175
+ throw new UsageError('The ChatGPT sign-in was interrupted. Run shard agents accounts add codex again.');
176
+ files = [loginFile(ctx, '~/.codex/auth.json', JSON.parse(await cell.files.readText(`${root}/.codex/auth.json`)))];
177
+ }
178
+ // The fresh profile's tokens leave the workspace before the upload; a failed upload needs a new sign-in.
179
+ await cell.files.remove(root, { recursive: true });
180
+ const account = await api.completeSignIn(claim.sign_in_id, files);
181
+ complete = true;
182
+ return account;
183
+ }
184
+ finally {
185
+ await cell.pty.close(session).catch(() => undefined);
186
+ await cell.files.remove(root, { recursive: true }).catch(() => undefined);
187
+ if (!complete)
188
+ await api.cancelSignIn(claim.sign_in_id).catch(() => undefined);
189
+ }
190
+ }
191
+ /**
192
+ * Pooled threads are used remotely in T3 Code (desktop, web, phone): they have no provider login for claude.ai or the
193
+ * ChatGPT app. Where new conversations open stays the person's choice (Settings, or agents targets --open-in).
194
+ */
195
+ function remoteHint(ctx) {
196
+ return t3Install(ctx.io.env)
197
+ ? 'Use pooled threads remotely in T3 Code: shard agents targets --open-in claude=t3,codex=t3 opens new conversations there.\n'
198
+ : 'Use pooled threads remotely in T3 Code (desktop, web and phone): install it from https://t3.codes.\n';
199
+ }
200
+ const notAvailable = () => new CodedError('pool_not_available', 'Pooling accounts is not available for this organization.', 'Your accounts keep serving threads directly; shard agents accounts shows their usage.');
201
+ export const ACCOUNT_POOL_HANDLERS = {
202
+ async 'agents accounts'(ctx, values) {
203
+ const list = await ctx.cloud().agentAccounts.list({ provider: optionalProvider(values.provider) });
204
+ out(ctx, formatAccounts(list, Date.now()), list);
205
+ return 0;
206
+ },
207
+ async 'agents accounts list'(ctx, values) {
208
+ const list = await ctx.cloud().agentAccounts.list({ provider: optionalProvider(values.provider) });
209
+ out(ctx, formatAccounts(list, Date.now()), list);
210
+ return 0;
211
+ },
212
+ async 'agents accounts usage'(ctx, values) {
213
+ const list = await ctx.cloud().agentAccounts.list({ provider: optionalProvider(values.provider) });
214
+ out(ctx, formatUsage(list, Date.now()), list);
215
+ return 0;
216
+ },
217
+ async 'agents accounts add'(ctx, values, [app]) {
218
+ const p = provider(app);
219
+ if (values.scope !== undefined && values.scope !== 'personal' && values.scope !== 'team')
220
+ throw new UsageError('--scope is personal or team');
221
+ let account;
222
+ if (values['api-key-stdin'] === true) {
223
+ const key = Buffer.from(await readInput(ctx, '-', 64 * 1024, '--api-key-stdin')).toString('utf8').trim();
224
+ if (!key || /\s/.test(key))
225
+ throw new UsageError('--api-key-stdin reads one API key from standard input, e.g. pbpaste | shard agents accounts add claude --api-key-stdin');
226
+ ctx.addSecret(key);
227
+ account = await ctx.cloud().agentAccounts.addApiKey(p, key, { ...(typeof values.label === 'string' ? { label: values.label } : {}), ...(values.scope === 'team' || values.scope === 'personal' ? { scope: values.scope } : {}) });
228
+ }
229
+ else
230
+ account = await signIn(ctx, values, p);
231
+ out(ctx, `Added ${name(account)} (${PROVIDERS[account.provider]}${plan(account) ? ` ${plan(account)}` : ''}) as ${account.provider}:${account.position + 1}.\n`, ctx.json ? { ...await ctx.cloud().agentAccounts.list(), account } : account);
232
+ return 0;
233
+ },
234
+ async 'agents accounts remove'(ctx, _values, [ref]) {
235
+ const api = ctx.cloud().agentAccounts;
236
+ const account = findAccount(await api.list(), ref);
237
+ await api.remove(account.id);
238
+ out(ctx, `Removed ${name(account)} (${PROVIDERS[account.provider]}).\n`, ctx.json ? { ...await api.list(), removed: account.id, provider: account.provider } : null);
239
+ return 0;
240
+ },
241
+ async 'agents accounts move'(ctx, _values, [ref, direction]) {
242
+ if (direction !== 'up' && direction !== 'down')
243
+ throw new UsageError('move takes an account and up or down, e.g. shard agents accounts move claude:2 up');
244
+ const api = ctx.cloud().agentAccounts;
245
+ const account = findAccount(await api.list(), ref);
246
+ const list = await api.move(account.id, direction);
247
+ const moved = list.accounts.find(a => a.id === account.id) ?? account;
248
+ out(ctx, moved.position === account.position ? `${name(account)} is already ${direction === 'up' ? 'first' : 'last'}.\n` : `Moved ${name(moved)} to ${moved.provider}:${moved.position + 1}.\n`, { ...list, account: moved });
249
+ return 0;
250
+ },
251
+ async 'agents accounts pool'(ctx, _values, [state]) {
252
+ if (state !== 'on' && state !== 'off')
253
+ throw new UsageError('pool is on or off');
254
+ let pool;
255
+ try {
256
+ pool = await ctx.cloud().agentPool.set({ pooled: state === 'on' });
257
+ }
258
+ catch (error) {
259
+ if (error instanceof ShardfluxApiError && (error.status === 404 || error.details?.reason === 'pool_not_on_plan'))
260
+ throw notAvailable();
261
+ throw error;
262
+ }
263
+ // Pooled threads run through the account hub: it needs the router (codex-connector 5) and its hub token.
264
+ const hub = pool.pooled ? await ensurePoolHub(ctx) : null;
265
+ out(ctx, pool.pooled
266
+ ? `Pooled: each new thread runs on the account with the most room. Running threads keep their account.\n${hub ? `The account hub is not ready (${hub}); new threads start directly until it is. Run shard agents accounts pool on again.\n` : ''}${remoteHint(ctx)}`
267
+ : 'Direct: new threads use your account as before. Pooled threads keep their account until they end.\n', ctx.json ? { ...await ctx.cloud().agentAccounts.list(), pool, ...(pool.pooled ? { hub: hub ? { ready: false, error: hub } : { ready: true } } : {}) } : pool);
268
+ return 0;
269
+ },
270
+ };
271
+ /** The `accounts` block of `shard agents targets --json` (0.18.0+): the account list for the menu bar; null when it cannot be read. */
272
+ export async function targetsAccounts(ctx) {
273
+ let timer;
274
+ try {
275
+ return await Promise.race([ctx.cloud().agentAccounts.list(), new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('the account list took longer than 3 s')), 3000); })]);
276
+ }
277
+ catch {
278
+ return null;
279
+ }
280
+ finally {
281
+ clearTimeout(timer);
282
+ }
283
+ }
284
+ /** The conversation's thread runs on the account pool (its `account.mode`, API with the pool). */
285
+ export function conversationPooled(conversation) {
286
+ const account = conversation?.account;
287
+ return account?.mode === 'pooled';
288
+ }
289
+ /** A pooled thread has no provider login on its session, so claude.ai, Claude Desktop and the Codex app cannot reach it. */
290
+ export const pooledNeedsT3 = (ref) => new CodedError('t3_not_installed', `${ref} runs on pooled accounts; open it in T3 Code.`, `Install T3 Code from https://t3.codes, open it once, then run shard agents open t3 ${ref}.`);
291
+ // `shard accounts …` runs the same handlers (hidden aliases in args.ts).
292
+ for (const [path, handler] of Object.entries(ACCOUNT_POOL_HANDLERS))
293
+ ACCOUNT_POOL_HANDLERS[path.replace(/^agents /, '')] = handler;
294
+ /**
295
+ * The account hub in pool mode (W1): created or upgraded on codex-connector 5 with its hub token (POST
296
+ * /v1/agent-pool/hub-token, minted for the hub workspace). Null when ready, else what stopped it.
297
+ */
298
+ async function ensurePoolHub(ctx) {
299
+ try {
300
+ const cloud = ctx.cloud();
301
+ const hub = (accountPoolSeams.hubCloud ?? ((c) => sdkHubCloud(c.cloud(), { signal: c.signal, wakeTimeoutMs: c.wakeTimeoutMs, agentLabel: c.agentLabel, homeProject: (org) => homeProject(c, org) })))(ctx);
302
+ const ensured = await ensureCliProxyHub(hub, { dir: ctx.store.dir, apiUrl: ctx.apiUrl, progress: (m) => progress(ctx, m),
303
+ pool: { hubToken: async (workspaceId) => (await cloud.request('POST', '/v1/agent-pool/hub-token', { json: { workspace_id: workspaceId } })).token } });
304
+ for (const value of [ensured.info.callback_url, ensured.info.management_key])
305
+ ctx.addSecret(value);
306
+ return null;
307
+ }
308
+ catch (error) {
309
+ return describeError(error).message;
310
+ }
311
+ }
312
+ /** Test seam: the hub cloud the pool toggle ensures (the SDK's by default). */
313
+ export const accountPoolSeams = {};
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Set in a coding agent's shell: Claude Code (CLAUDECODE), Codex (CODEX_SANDBOX, CODEX_THREAD_ID), Cursor's agent
3
+ * (CURSOR_AGENT) and Gemini CLI (GEMINI_CLI). Such a shell may have a terminal (Cursor and Codex can run commands in a
4
+ * PTY), but nobody types into it: a full-screen view or a prompt there waits forever.
5
+ */
6
+ export declare const AGENT_SHELL_VARIABLES: readonly ["CLAUDECODE", "CODEX_SANDBOX", "CODEX_THREAD_ID", "CURSOR_AGENT", "GEMINI_CLI"];
7
+ export declare function codingAgentShell(env: Record<string, string | undefined>): boolean;
8
+ export declare const SETUP_QUESTION = "Is Shardflux for a SaaS platform (your product runs VMs for its own customers through the API, SDK or MCP), or for your personal setup (your own cloud coding agents: each Claude or Codex conversation gets its own VM)?";
9
+ export declare const AGENT_SETUP_DOCS = "https://docs.shardflux.dev/guides/agent-setup.md";
10
+ /** One step of a path: what it does, what to ask the user first (if anything), and the commands in order. */
11
+ export interface GuideStep {
12
+ step: string;
13
+ ask?: string;
14
+ commands: string[];
15
+ note?: string;
16
+ }
17
+ /** What `shard setup --guide --json` prints. */
18
+ export interface SetupGuide {
19
+ question: string;
20
+ signed_in: {
21
+ email: string | null;
22
+ } | null;
23
+ api_key_saved: boolean;
24
+ account: GuideStep;
25
+ saas: GuideStep[];
26
+ personal: GuideStep[];
27
+ terms_url: string;
28
+ privacy_url: string;
29
+ docs: string;
30
+ }
31
+ /** The local state the guide adapts to: a saved (or SHARDFLUX_SESSION_TOKEN) session and a saved (or exported) key. */
32
+ export interface GuideState {
33
+ signedIn: boolean;
34
+ email: string | null;
35
+ apiKey: boolean;
36
+ }
37
+ export declare function setupGuide(state: GuideState): SetupGuide;
38
+ export declare function setupGuideText(g: SetupGuide): string;
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Setting Shardflux up from a coding agent (0.17.1+): the one question the agent asks its user first, and the ordered
3
+ * steps of both answers. `shard setup --guide` prints it; so does a bare `shard` without a terminal or in a coding
4
+ * agent's shell, where the command center would wait for keys nobody presses. Local only: no network call and nothing
5
+ * to answer, so it returns at once.
6
+ *
7
+ * - SaaS platform: the user's product runs VMs for its own customers (API, SDK, MCP). An account, an API key, the
8
+ * integration and templates for the customers' workspaces; no agent sign-ins, menu bar or machine.
9
+ * - Personal setup: the user's own cloud coding agents. Sign in, connect Claude and Codex, build the machine from what
10
+ * the user asks for, set up the project and hand over `shard ./`.
11
+ */
12
+ import { legalFields } from "./legal.js";
13
+ /**
14
+ * Set in a coding agent's shell: Claude Code (CLAUDECODE), Codex (CODEX_SANDBOX, CODEX_THREAD_ID), Cursor's agent
15
+ * (CURSOR_AGENT) and Gemini CLI (GEMINI_CLI). Such a shell may have a terminal (Cursor and Codex can run commands in a
16
+ * PTY), but nobody types into it: a full-screen view or a prompt there waits forever.
17
+ */
18
+ export const AGENT_SHELL_VARIABLES = ['CLAUDECODE', 'CODEX_SANDBOX', 'CODEX_THREAD_ID', 'CURSOR_AGENT', 'GEMINI_CLI'];
19
+ export function codingAgentShell(env) {
20
+ return AGENT_SHELL_VARIABLES.some((name) => (env[name]?.trim() ?? '') !== '');
21
+ }
22
+ export const SETUP_QUESTION = 'Is Shardflux for a SaaS platform (your product runs VMs for its own customers through the API, SDK or MCP), or for your personal setup (your own cloud coding agents: each Claude or Codex conversation gets its own VM)?';
23
+ export const AGENT_SETUP_DOCS = 'https://docs.shardflux.dev/guides/agent-setup.md';
24
+ const { terms_url: TERMS, privacy_url: PRIVACY } = legalFields();
25
+ export function setupGuide(state) {
26
+ const account = state.signedIn
27
+ ? { step: 'Account', commands: ['shard setup --json'], note: `Signed in${state.email ? ` as ${state.email}` : ''}. shard setup saves the organization, project and API key (safe to run again).` }
28
+ : {
29
+ step: 'Account',
30
+ ask: `Do you agree to the Terms of Service (${TERMS}) and the Privacy Policy (${PRIVACY})? Continue once they do.`,
31
+ commands: ['shard signup --accept-terms --json'],
32
+ note: 'It signs up or signs in with this computer’s Codex login and saves an API key. Without a Codex login, or for an account they already have: shard auth login --browser --no-wait --json (give your user the link and the code), then shard auth login --browser --json (returns once they approved) and shard setup --json.',
33
+ };
34
+ return {
35
+ question: SETUP_QUESTION,
36
+ signed_in: state.signedIn ? { email: state.email } : null,
37
+ api_key_saved: state.apiKey,
38
+ account,
39
+ saas: [
40
+ { step: 'API key for your product', commands: ['eval "$(shard env)"'], note: 'Exports SHARDFLUX_API_KEY for the SDKs, the MCP server and your product’s server. Keep it in server-side secrets, never in client code.' },
41
+ {
42
+ step: 'Integrate',
43
+ commands: ['npm install @shardflux/sdk', 'pip install shardflux'],
44
+ note: 'Or the HTTP API (https://api.shardflux.dev/v1) or the MCP server (https://mcp.shardflux.dev/mcp). Your customers’ workspaces start from a template: shard templates build template.yaml. Start at https://docs.shardflux.dev/quickstart.md',
45
+ },
46
+ ],
47
+ personal: [
48
+ {
49
+ step: 'Connect Claude and Codex',
50
+ ask: 'Which agents do you use: Claude, Codex or both?',
51
+ commands: ['shard setup --inspect --connect --json --apps claude,codex'],
52
+ note: 'Claude: your user runs shard agents credentials login claude in their own terminal, once (a claude.ai link and a code). Codex: codex login on this computer; --connect copies it and starts the account’s Codex remote device. Pass only the apps they use.',
53
+ },
54
+ {
55
+ step: 'Build the machine',
56
+ ask: 'What should every cloud agent start with? Languages and runtimes, CLIs and tools, dotfiles and shell, secrets and credentials, editor and agent config.',
57
+ commands: ['shard machine open --json', "shard ws exec <key> -- bash -lc '<install commands>'", 'shard files write <key> <path> --from -', 'shard machine save --wait --json'],
58
+ note: 'machine open prints the machine VM’s workspace key. Install with ws exec, write dotfiles and secret files with files write (contents on standard input, never in arguments), then save: every new conversation starts from it.',
59
+ },
60
+ {
61
+ step: 'Set up the project',
62
+ ask: 'Which repository, which project secrets, and what does a good first run look like (tests, a dev server)?',
63
+ commands: ['shard ./ --json --prompt "Set up this project and run its tests"', 'shard conversations wait <conversation-id> --timeout 90', 'shard template --json'],
64
+ note: 'Run it in the project folder (shard ./ codex … for Codex): the conversation gets its own VM, and setup installs the dependencies and saves the project template. Run the wait again while it says Still working. Project secrets: shard secrets import --from <hsec|1password|bitwarden|keychain> NAME, or printf %s "$VALUE" | shard secrets create NAME.',
65
+ },
66
+ {
67
+ step: 'Hand over',
68
+ commands: ['shard ./'],
69
+ note: 'Your user runs it in their own terminal (shard ./ codex for Codex). Claude conversations continue on claude.ai and in the Claude app; Codex threads in the Codex app, on the web and on the phone.',
70
+ },
71
+ ],
72
+ terms_url: TERMS,
73
+ privacy_url: PRIVACY,
74
+ docs: AGENT_SETUP_DOCS,
75
+ };
76
+ }
77
+ /** Wraps `text` at `width` columns, every line after the first indented by `indent`. */
78
+ function wrap(text, indent, width = 100) {
79
+ const lines = [];
80
+ let line = '';
81
+ for (const word of text.split(' ')) {
82
+ if (line && line.length + 1 + word.length > width - (lines.length ? indent.length : 0)) {
83
+ lines.push(line);
84
+ line = word;
85
+ }
86
+ else
87
+ line = line ? `${line} ${word}` : word;
88
+ }
89
+ if (line)
90
+ lines.push(line);
91
+ return lines.join(`\n${indent}`);
92
+ }
93
+ function stepText(n, s) {
94
+ const head = `${n}. ${s.step}.${s.ask ? ` Ask: ${s.ask}` : ''}`;
95
+ return [
96
+ ` ${wrap(head, ' ')}`,
97
+ ...s.commands.map((c) => ` ${c}`),
98
+ ...(s.note ? [` ${wrap(s.note, ' ', 96)}`] : []),
99
+ ].join('\n');
100
+ }
101
+ export function setupGuideText(g) {
102
+ return [
103
+ 'Set up Shardflux. Ask your user one question first:',
104
+ ` ${wrap(g.question, ' ')}`,
105
+ '',
106
+ stepText(1, g.account),
107
+ '',
108
+ 'SaaS platform:',
109
+ ...g.saas.map((s, i) => stepText(i + 2, s)),
110
+ '',
111
+ 'Personal setup:',
112
+ ...g.personal.map((s, i) => stepText(i + 2, s)),
113
+ '',
114
+ `Every step in detail: ${g.docs}. Every command: shard --help.`,
115
+ ].join('\n') + '\n';
116
+ }
@@ -22,6 +22,7 @@ import { AgentStateStore } from "./agent-state.js";
22
22
  import { workspaceRepositoryPath } from "./agent-repository.js";
23
23
  import { openInChoices, openT3, saveOpenIn, startSshThread, t3Install, t3NotInstalled, t3OpenFailed, t3Status } from "./t3-mode.js";
24
24
  import { openT3Conversation, t3Fields, withT3Context } from "./t3-launch.js";
25
+ import { conversationPooled, pooledNeedsT3, targetsAccounts } from "./account-pool.js";
25
26
  const missing = (e) => e instanceof ShardfluxApiError && e.status === 404;
26
27
  const keyOf = (ref) => ref.includes('/') ? ref : `${projectIdentity('./').slug}/${ref}`;
27
28
  /** How long opening a Claude conversation this computer just started waits for Claude to publish its Remote Control URL. */
@@ -229,6 +230,12 @@ const openTarget = (target) => async (ctx, values, [ref = 'main']) => {
229
230
  return openT3Conversation(ctx, ref, values);
230
231
  // A conversation id opens that conversation's own session on its computer (claude.ai, the desktop apps).
231
232
  const conversation = /^conv_[a-z0-9]{26}$/.test(ref) ? await getConversation(ctx, ref) : undefined;
233
+ // A pooled thread (0.18.0+) has no provider login on its session: it opens remotely in T3 Code.
234
+ if (conversation && conversationPooled(conversation)) {
235
+ if (!detected.targets.includes('t3'))
236
+ throw pooledNeedsT3(ref);
237
+ return openT3Conversation(ctx, ref, values);
238
+ }
232
239
  let ws = conversation ? await conversationWorkspace(ctx, conversation) : await resolveWorkspace(ctx, keyOf(ref));
233
240
  if (values.reachable !== undefined)
234
241
  ws = await ctx.cloud().workspaces.open({ key: ws.key, ...agentReachability(values), wait: false });
@@ -362,11 +369,13 @@ export const TARGET_HANDLERS = {
362
369
  // models (0.17.0+): the model and effort choices a new thread can start with; clients never hardcode them.
363
370
  // T3 Code mode (0.17.0+): T3's state for this project (the agent's, else this folder's), and where `shard ./` opens
364
371
  // each app's new conversations (--open-in sets it).
372
+ // accounts (0.18.0+, --json): the Claude and ChatGPT/Codex accounts with usage and resets, for the menu bar.
373
+ const accounts = ctx.json ? targetsAccounts(ctx) : undefined;
365
374
  const project = targetsProject(ref, typeof values.path === 'string' ? values.path : undefined);
366
375
  const t3 = t3Status(ctx, { project, lastUsed: true });
367
376
  if (typeof values['open-in'] === 'string')
368
377
  saveOpenIn(ctx, values['open-in'], t3, found.clients);
369
- const detected = { ...found, t3, open_in: openInChoices(ctx, project, { clients: found.clients }) };
378
+ const detected = { ...found, t3, open_in: openInChoices(ctx, project, { clients: found.clients }), ...(accounts ? { accounts: await accounts } : {}) };
370
379
  const targets = new AgentTargets(ctx.io.env, ctx.store.dir);
371
380
  const key = ref ? keyOf(ref) : undefined;
372
381
  const before = values.remove === true ? await targets.configure(undefined, ctx.apiUrl, [], false, ctx.signal) : [];
@@ -23,6 +23,17 @@ export interface T3App {
23
23
  version: string | null;
24
24
  linux?: T3LinuxApp;
25
25
  }
26
+ /** Whether an installed T3 runs the Shardflux provider: its server has the ACP Registry driver (t3-asar.ts, t3-linux.ts). */
27
+ export declare const t3AppSupportsAcp: (app: T3App) => boolean;
28
+ /**
29
+ * The T3 every command uses when several are installed (channels, or a .deb and an AppImage): the newest build with the
30
+ * Shardflux provider, else the newest build, whatever order the folders were scanned in, so its version (prewarm, the
31
+ * VM's T3 server) matches the T3 that opens. Ties keep scan order.
32
+ */
33
+ export declare function preferredT3(apps: readonly T3App[]): {
34
+ app: T3App;
35
+ acp: boolean;
36
+ } | undefined;
26
37
  /** The installed T3 Code apps (any channel), with their path and version, in the given app folders (applicationDirs). */
27
38
  export declare function t3Apps(dirs: string[], platform?: NodeJS.Platform, env?: Record<string, string | undefined>): T3App[];
28
39
  /**
@@ -7,7 +7,9 @@ import { writePrivateFile } from "./credentials.js";
7
7
  import { UsageError } from "./errors.js";
8
8
  import { projectWorkspacePath } from "./agent-repository.js";
9
9
  import { withTargetLock } from "./target-lock.js";
10
- import { linuxApplicationDirs, linuxT3Apps } from "./t3-linux.js";
10
+ import { linuxApplicationDirs, linuxSupportsAcp, linuxT3Apps } from "./t3-linux.js";
11
+ import { t3ArchiveSupportsAcp } from "./t3-asar.js";
12
+ import { compareT3Versions } from "./t3-version.js";
11
13
  const PERSISTED_TARGETS = ['claude-desktop', 'codex-app'];
12
14
  const BEGIN = '# BEGIN Shardflux managed targets';
13
15
  const END = '# END Shardflux managed targets';
@@ -37,6 +39,21 @@ export function managedBlock(text, body) {
37
39
  const stop = end + END.length + (text[end + END.length] === '\n' ? 1 : 0);
38
40
  return text.slice(0, start) + block + text.slice(stop);
39
41
  }
42
+ /** Whether an installed T3 runs the Shardflux provider: its server has the ACP Registry driver (t3-asar.ts, t3-linux.ts). */
43
+ export const t3AppSupportsAcp = (app) => app.linux
44
+ ? linuxSupportsAcp({ ...app, linux: app.linux }) : t3ArchiveSupportsAcp(join(app.path, 'Contents', 'Resources', 'app.asar'));
45
+ /**
46
+ * The T3 every command uses when several are installed (channels, or a .deb and an AppImage): the newest build with the
47
+ * Shardflux provider, else the newest build, whatever order the folders were scanned in, so its version (prewarm, the
48
+ * VM's T3 server) matches the T3 that opens. Ties keep scan order.
49
+ */
50
+ export function preferredT3(apps) {
51
+ if (apps.length <= 1)
52
+ return apps[0] && { app: apps[0], acp: t3AppSupportsAcp(apps[0]) };
53
+ const newest = (list) => list.reduce((best, app) => compareT3Versions(app.version, best.version) > 0 ? app : best);
54
+ const capable = apps.filter(t3AppSupportsAcp);
55
+ return capable.length ? { app: newest(capable), acp: true } : { app: newest(apps), acp: false };
56
+ }
40
57
  /** The installed T3 Code apps (any channel), with their path and version, in the given app folders (applicationDirs). */
41
58
  export function t3Apps(dirs, platform = process.platform, env = process.env) {
42
59
  if (platform === 'linux')
@@ -101,9 +118,10 @@ export function installedTargets(env, platform = process.platform, appDirs) {
101
118
  const versions = {};
102
119
  const t3Bundles = t3Apps(dirs, platform, env);
103
120
  const t3 = t3Bundles.length > 0;
104
- for (const app of t3Bundles)
105
- if (app.version)
106
- versions.t3 ??= app.version;
121
+ // The version of the T3 that opens (preferredT3, as t3Install picks it); one install costs no archive read.
122
+ const picked = t3Bundles.length > 1 ? preferredT3(t3Bundles)?.app : t3Bundles[0];
123
+ if (picked?.version)
124
+ versions.t3 = picked.version;
107
125
  const executable = (name) => (env.PATH ?? '').split(':').filter(Boolean).some(p => { try {
108
126
  accessSync(join(p, name), constants.X_OK);
109
127
  return true;
package/dist/args.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const CLI_VERSION = "0.17.0";
1
+ export declare const CLI_VERSION = "0.18.0";
2
2
  export interface OptionSpec {
3
3
  type: 'string' | 'boolean';
4
4
  short?: string;
@@ -34,6 +34,8 @@ export declare const GLOBAL_OPTIONS: Record<string, OptionSpec>;
34
34
  export declare const PROJECT_COMMAND: CommandSpec;
35
35
  export declare const ONBOARDING_COMMAND: CommandSpec;
36
36
  export declare const CENTER_COMMAND: CommandSpec;
37
+ /** Bare `shard` without a terminal, or in a coding agent's shell (0.17.1+): the setup question and both paths' steps. */
38
+ export declare const GUIDE_COMMAND: CommandSpec;
37
39
  export declare const COMMANDS: CommandSpec[];
38
40
  export declare function normalizeWords(words: string[]): string[];
39
41
  export declare function findCommand(words: string[]): CommandSpec | undefined;