@shardflux/cli 0.11.1 → 0.13.1

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 (88) hide show
  1. package/CHANGELOG.md +64 -1
  2. package/README.md +83 -15
  3. package/dist/account-args.js +2 -0
  4. package/dist/account-commands.js +39 -0
  5. package/dist/agent-commands.d.ts +8 -0
  6. package/dist/agent-commands.js +223 -0
  7. package/dist/agent-config-files.d.ts +23 -0
  8. package/dist/agent-config-files.js +203 -0
  9. package/dist/agent-config-mcp.d.ts +36 -0
  10. package/dist/agent-config-mcp.js +225 -0
  11. package/dist/agent-config-sync.d.ts +58 -0
  12. package/dist/agent-config-sync.js +366 -0
  13. package/dist/agent-credentials.d.ts +14 -0
  14. package/dist/agent-credentials.js +93 -0
  15. package/dist/agent-git-commands.d.ts +10 -0
  16. package/dist/agent-git-commands.js +294 -0
  17. package/dist/agent-git.d.ts +47 -0
  18. package/dist/agent-git.js +285 -0
  19. package/dist/agent-reachability.d.ts +4 -0
  20. package/dist/agent-reachability.js +13 -0
  21. package/dist/agent-repository.d.ts +13 -0
  22. package/dist/agent-repository.js +182 -0
  23. package/dist/agent-state.d.ts +16 -0
  24. package/dist/agent-state.js +63 -0
  25. package/dist/agent-target-commands.d.ts +10 -0
  26. package/dist/agent-target-commands.js +171 -0
  27. package/dist/agent-targets.d.ts +33 -0
  28. package/dist/agent-targets.js +267 -0
  29. package/dist/agent-terminal.d.ts +35 -0
  30. package/dist/agent-terminal.js +209 -0
  31. package/dist/args.d.ts +8 -4
  32. package/dist/args.js +120 -10
  33. package/dist/assisted-setup.d.ts +24 -0
  34. package/dist/assisted-setup.js +77 -0
  35. package/dist/browser-commands.d.ts +3 -0
  36. package/dist/browser-commands.js +83 -0
  37. package/dist/browser-profile-copy.d.ts +2 -0
  38. package/dist/browser-profile-copy.js +2 -0
  39. package/dist/browser-readers.d.ts +13 -0
  40. package/dist/browser-readers.js +249 -0
  41. package/dist/browser-sites.d.ts +1 -0
  42. package/dist/browser-sites.js +13 -0
  43. package/dist/claude-cloud-login.d.ts +9 -0
  44. package/dist/claude-cloud-login.js +143 -0
  45. package/dist/command-center-actions.d.ts +6 -0
  46. package/dist/command-center-actions.js +132 -0
  47. package/dist/command-center-keys.d.ts +6 -0
  48. package/dist/command-center-keys.js +38 -0
  49. package/dist/command-center-model.d.ts +29 -0
  50. package/dist/command-center-model.js +90 -0
  51. package/dist/command-center-renderer.d.ts +25 -0
  52. package/dist/command-center-renderer.js +100 -0
  53. package/dist/command-center.d.ts +18 -0
  54. package/dist/command-center.js +320 -0
  55. package/dist/commands.d.ts +8 -2
  56. package/dist/commands.js +123 -8
  57. package/dist/credential-prompts.d.ts +22 -0
  58. package/dist/credential-prompts.js +193 -0
  59. package/dist/filesystem-commands.d.ts +2 -0
  60. package/dist/filesystem-commands.js +63 -0
  61. package/dist/filesystem-state.d.ts +3 -0
  62. package/dist/filesystem-state.js +12 -0
  63. package/dist/git-remote-bin.d.ts +2 -0
  64. package/dist/git-remote-bin.js +37 -0
  65. package/dist/git-remote-helper.d.ts +8 -0
  66. package/dist/git-remote-helper.js +31 -0
  67. package/dist/git-remote.d.ts +14 -0
  68. package/dist/git-remote.js +136 -0
  69. package/dist/handlers.js +9 -1
  70. package/dist/index.d.ts +2 -0
  71. package/dist/index.js +1 -0
  72. package/dist/input.d.ts +5 -0
  73. package/dist/main.js +21 -5
  74. package/dist/menubar.d.ts +32 -0
  75. package/dist/menubar.js +180 -0
  76. package/dist/project-identity.d.ts +14 -0
  77. package/dist/project-identity.js +61 -0
  78. package/dist/project-sync-manifest.d.ts +27 -0
  79. package/dist/project-sync-manifest.js +225 -0
  80. package/dist/project-sync-transfer.d.ts +14 -0
  81. package/dist/project-sync-transfer.js +240 -0
  82. package/dist/project-sync.d.ts +38 -0
  83. package/dist/project-sync.js +243 -0
  84. package/dist/project-template-setup.d.ts +17 -0
  85. package/dist/project-template-setup.js +89 -0
  86. package/dist/vault.d.ts +16 -0
  87. package/dist/vault.js +153 -0
  88. package/package.json +9 -2
@@ -0,0 +1,249 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { createDecipheriv, createHash, pbkdf2Sync, timingSafeEqual } from 'node:crypto';
3
+ import { cp, mkdtemp, readFile, readdir, rm, stat } from 'node:fs/promises';
4
+ import { homedir, tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { DatabaseSync } from 'node:sqlite';
7
+ import { promisify } from 'node:util';
8
+ import { ClassicLevel } from 'classic-level';
9
+ import { uncompress } from 'snappyjs';
10
+ import { UsageError } from "./errors.js";
11
+ const run = promisify(execFile);
12
+ const apps = { chrome: ['Google/Chrome', 'Chrome'], arc: ['Arc/User Data', 'Arc'], brave: ['BraveSoftware/Brave-Browser', 'Brave'], edge: ['Microsoft Edge', 'Microsoft Edge'] };
13
+ export { browserSessionNote } from "./browser-profile-copy.js";
14
+ import { normalizeBrowserSite } from "./browser-sites.js";
15
+ export { normalizeBrowserSite } from "./browser-sites.js";
16
+ const domainMatches = (domain, host) => { const d = domain.replace(/^\./, '').toLowerCase(); return d === host || d.includes('.') && host.endsWith(`.${d}`); };
17
+ const present = async (file) => { try {
18
+ return (await stat(file)).isFile();
19
+ }
20
+ catch {
21
+ return false;
22
+ } };
23
+ /** Called only by an explicit import. Tests pass a throwaway directory and synthetic key provider. */
24
+ export async function browserDirectory(browser) {
25
+ if (browser === 'safari')
26
+ return join(homedir(), 'Library');
27
+ if (browser === 'firefox') {
28
+ const root = join(homedir(), 'Library/Application Support/Firefox');
29
+ const ini = await readFile(join(root, 'profiles.ini'), 'utf8');
30
+ const profiles = ini.split(/\r?\n\[/).map(s => ({ path: /^Path=(.+)$/m.exec(s)?.[1], relative: !/^IsRelative=0$/m.test(s), preferred: /^Default=1$/m.test(s) }));
31
+ const profile = profiles.find(p => p.path && p.preferred) ?? profiles.find(p => p.path);
32
+ if (!profile?.path)
33
+ throw new UsageError('No Firefox profile was found. Choose one with --profile-dir.');
34
+ return profile.relative ? join(root, profile.path) : profile.path;
35
+ }
36
+ const root = join(homedir(), 'Library/Application Support', apps[browser][0]);
37
+ const state = JSON.parse(await readFile(join(root, 'Local State'), 'utf8'));
38
+ const leaf = state.profile?.last_used ?? 'Default';
39
+ if (!/^(?:Default|Profile [0-9]+)$/.test(leaf))
40
+ throw new UsageError('Choose this browser profile with --profile-dir.');
41
+ return join(root, leaf);
42
+ }
43
+ export async function safeStorageKey(browser) {
44
+ if (process.platform !== 'darwin')
45
+ throw new UsageError('Local browser import uses macOS Safe Storage.');
46
+ try {
47
+ const { stdout } = await run('/usr/bin/security', ['find-generic-password', '-w', '-s', `${apps[browser][1]} Safe Storage`], { encoding: 'buffer', timeout: 30_000, maxBuffer: 65536 });
48
+ const password = Buffer.from(stdout);
49
+ stdout.fill(0);
50
+ if (password.at(-1) === 10)
51
+ return password.subarray(0, password.length - 1);
52
+ return password;
53
+ }
54
+ catch {
55
+ throw new UsageError('Allow access to the browser’s Safe Storage key in the macOS prompt, then retry import.');
56
+ }
57
+ }
58
+ export function decryptChromiumCookie(encrypted, password, host, version) {
59
+ const b = Buffer.from(encrypted);
60
+ if (b.subarray(0, 3).toString() !== 'v10')
61
+ throw new UsageError('This browser cookie encryption format is unsupported. Sign in through the workspace and use shard browser save.');
62
+ const key = pbkdf2Sync(password, 'saltysalt', 1003, 16, 'sha1');
63
+ let plain;
64
+ try {
65
+ const cipher = createDecipheriv('aes-128-cbc', key, Buffer.alloc(16, 32));
66
+ plain = Buffer.concat([cipher.update(b.subarray(3)), cipher.final()]);
67
+ if (version >= 24) {
68
+ const expected = createHash('sha256').update(host).digest();
69
+ if (plain.length < 32 || !timingSafeEqual(plain.subarray(0, 32), expected))
70
+ throw new Error('host binding');
71
+ return plain.subarray(32).toString('utf8');
72
+ }
73
+ return plain.toString('utf8');
74
+ }
75
+ catch {
76
+ throw new UsageError('The selected browser cookies could not be decrypted. Retry with the matching browser.');
77
+ }
78
+ finally {
79
+ key.fill(0);
80
+ plain?.fill(0);
81
+ }
82
+ }
83
+ function sqlRows(file, query, ...params) {
84
+ const db = new DatabaseSync(file, { readOnly: true });
85
+ try {
86
+ return db.prepare(query).all(...params);
87
+ }
88
+ finally {
89
+ db.close();
90
+ }
91
+ }
92
+ const n = (v) => Number(v);
93
+ const text = (v) => typeof v === 'string' ? v : Buffer.from(v).toString('utf16le');
94
+ const bytes = (v) => Buffer.from(v);
95
+ export async function readBrowserState(browser, directory, rawSites, keyProvider = safeStorageKey) {
96
+ const sites = [...new Set(rawSites.map(normalizeBrowserSite))];
97
+ if (!sites.length)
98
+ throw new UsageError('Choose at least one site.');
99
+ const result = sites.map(site => ({ site, storage_state: { cookies: [], origins: [] } }));
100
+ let key;
101
+ try {
102
+ if (browser === 'safari') {
103
+ const file = join(directory, 'Cookies/Cookies.binarycookies');
104
+ const sandbox = join(directory, 'Containers/com.apple.Safari/Data/Library/Cookies/Cookies.binarycookies');
105
+ const cookies = readSafariCookies(await readFile(await present(file) ? file : sandbox));
106
+ for (const r of result)
107
+ r.storage_state.cookies = cookies.filter(c => domainMatches(c.domain, new URL(r.site).hostname));
108
+ await safariLocalStorage(directory, result);
109
+ }
110
+ else if (browser === 'firefox') {
111
+ const file = join(directory, 'cookies.sqlite');
112
+ for (const r of result) {
113
+ const host = new URL(r.site).hostname;
114
+ const domains = host.split('.').map((_, i, a) => a.slice(i).join('.')).filter(d => d === host || d.includes('.'));
115
+ const rows = sqlRows(file, `SELECT host,name,value,path,expiry,isSecure,isHttpOnly,sameSite FROM moz_cookies WHERE originAttributes='' AND host IN (${domains.flatMap(() => ['?', '?']).join(',')})`, ...domains.flatMap(d => [d, `.${d}`]));
116
+ r.storage_state.cookies = rows.map(c => ({ name: String(c.name), value: String(c.value), domain: String(c.host), path: String(c.path), expires: n(c.expiry), secure: !!c.isSecure, httpOnly: !!c.isHttpOnly, sameSite: n(c.sameSite) === 2 ? 'Strict' : n(c.sameSite) === 1 ? 'Lax' : 'None' }));
117
+ }
118
+ await firefoxLocalStorage(directory, result);
119
+ }
120
+ else {
121
+ const file = await present(join(directory, 'Network/Cookies')) ? join(directory, 'Network/Cookies') : join(directory, 'Cookies');
122
+ const version = n(sqlRows(file, "SELECT value FROM meta WHERE key='version'")[0]?.value);
123
+ const partitionColumn = sqlRows(file, 'PRAGMA table_info(cookies)').some(c => c.name === 'top_frame_site_key') ? 'top_frame_site_key' : "'' AS top_frame_site_key";
124
+ for (const r of result) {
125
+ const host = new URL(r.site).hostname;
126
+ const domains = host.split('.').map((_, i, a) => a.slice(i).join('.')).filter(d => d === host || d.includes('.'));
127
+ const rows = sqlRows(file, `SELECT host_key,name,value,encrypted_value,path,CAST(expires_utc AS TEXT) expires_utc,is_secure,is_httponly,samesite,${partitionColumn} FROM cookies WHERE host_key IN (${domains.flatMap(() => ['?', '?']).join(',')})`, ...domains.flatMap(d => [d, `.${d}`]));
128
+ for (const c of rows) {
129
+ let value = String(c.value);
130
+ if (bytes(c.encrypted_value).length) {
131
+ key ??= await keyProvider(browser);
132
+ value = decryptChromiumCookie(bytes(c.encrypted_value), key, String(c.host_key), version);
133
+ }
134
+ r.storage_state.cookies.push({ name: String(c.name), value, domain: String(c.host_key), path: String(c.path), expires: n(c.expires_utc) ? n(c.expires_utc) / 1e6 - 11644473600 : -1, secure: !!c.is_secure, httpOnly: !!c.is_httponly, sameSite: n(c.samesite) === 2 ? 'Strict' : n(c.samesite) === 1 ? 'Lax' : 'None', ...(typeof c.top_frame_site_key === 'string' && c.top_frame_site_key ? { partitionKey: c.top_frame_site_key } : {}) });
135
+ }
136
+ }
137
+ await chromiumLocalStorage(directory, result);
138
+ }
139
+ return result;
140
+ }
141
+ finally {
142
+ key?.fill(0);
143
+ }
144
+ }
145
+ const blinkString = (b) => { if (b[0] === 0)
146
+ return b.subarray(1).toString('utf16le'); if (b[0] === 1)
147
+ return b.subarray(1).toString('latin1'); throw new UsageError('This browser local storage encoding is unsupported.'); };
148
+ async function chromiumLocalStorage(directory, result) {
149
+ const source = join(directory, 'Local Storage/leveldb');
150
+ try {
151
+ await stat(source);
152
+ }
153
+ catch {
154
+ return;
155
+ }
156
+ const tmp = await mkdtemp(join(tmpdir(), 'shard-browser-'));
157
+ await cp(source, join(tmp, 'leveldb'), { recursive: true }).catch(async (error) => { await rm(tmp, { recursive: true, force: true }); throw error; });
158
+ const db = new ClassicLevel(join(tmp, 'leveldb'), { keyEncoding: 'buffer', valueEncoding: 'buffer', createIfMissing: false });
159
+ try {
160
+ await db.open();
161
+ for (const r of result) {
162
+ const prefix = Buffer.from(`_${r.site}\0`), items = [];
163
+ for await (const [key, value] of db.iterator({ gte: prefix, lt: Buffer.concat([prefix.subarray(0, -1), Buffer.from([1])]) })) {
164
+ if (key.subarray(0, prefix.length).equals(prefix))
165
+ items.push({ name: blinkString(key.subarray(prefix.length)), value: blinkString(value) });
166
+ }
167
+ if (items.length)
168
+ r.storage_state.origins.push({ origin: r.site, localStorage: items });
169
+ }
170
+ }
171
+ finally {
172
+ await db.close();
173
+ await rm(tmp, { recursive: true, force: true });
174
+ }
175
+ }
176
+ async function firefoxLocalStorage(directory, result) {
177
+ const base = join(directory, 'storage/default');
178
+ let dirs;
179
+ try {
180
+ dirs = await readdir(base);
181
+ }
182
+ catch {
183
+ return;
184
+ }
185
+ for (const r of result) {
186
+ const u = new URL(r.site), leaf = `${u.protocol.slice(0, -1)}+++${u.hostname}${u.port ? `+${u.port}` : ''}`;
187
+ // Container/partition origins carry a suffix and never join the default login.
188
+ if (!dirs.includes(leaf))
189
+ continue;
190
+ const file = join(base, leaf, 'ls/data.sqlite');
191
+ if (!await present(file))
192
+ continue;
193
+ const origin = sqlRows(file, 'SELECT origin FROM database')[0]?.origin;
194
+ if (origin !== r.site)
195
+ continue;
196
+ const items = sqlRows(file, 'SELECT key,value,conversion_type,compression_type FROM data').map(c => {
197
+ const value = n(c.compression_type) === 1 ? Buffer.from(uncompress(bytes(c.value))) : bytes(c.value);
198
+ if (![0, 1].includes(n(c.conversion_type)) || ![0, 1].includes(n(c.compression_type)))
199
+ throw new UsageError('This Firefox storage encoding is unsupported.');
200
+ return { name: String(c.key), value: value.toString(n(c.conversion_type) === 1 ? 'utf8' : 'utf16le') };
201
+ });
202
+ r.storage_state.origins.push({ origin: r.site, localStorage: items });
203
+ }
204
+ }
205
+ async function safariLocalStorage(directory, result) {
206
+ const base = join(directory, 'WebKit/com.apple.Safari/LocalStorage');
207
+ let files;
208
+ try {
209
+ files = await readdir(base);
210
+ }
211
+ catch {
212
+ return;
213
+ }
214
+ for (const r of result) {
215
+ const u = new URL(r.site), filename = `${u.protocol.slice(0, -1)}_${u.hostname}_${u.port || '0'}.localstorage`;
216
+ if (!files.includes(filename))
217
+ continue;
218
+ const items = sqlRows(join(base, filename), 'SELECT key,value FROM ItemTable').map(c => ({ name: text(c.key), value: text(c.value) }));
219
+ r.storage_state.origins.push({ origin: r.site, localStorage: items });
220
+ }
221
+ }
222
+ export function readSafariCookies(b) {
223
+ if (b.subarray(0, 4).toString() !== 'cook')
224
+ throw new UsageError('Invalid Safari cookie file.');
225
+ const cookies = [];
226
+ const pages = b.readUInt32BE(4);
227
+ if (pages > 100000 || 8 + pages * 4 > b.length)
228
+ throw new UsageError('Invalid Safari cookie file.');
229
+ let start = 8 + pages * 4;
230
+ for (let p = 0; p < pages; p++) {
231
+ const length = b.readUInt32BE(8 + p * 4), page = b.subarray(start, start + length);
232
+ start += length;
233
+ if (page.length !== length || length < 8)
234
+ throw new UsageError('Invalid Safari cookie page.');
235
+ const count = page.readUInt32LE(4);
236
+ if (8 + count * 4 > page.length)
237
+ throw new UsageError('Invalid Safari cookie page.');
238
+ for (let i = 0; i < count; i++) {
239
+ const offset = page.readUInt32LE(8 + i * 4), size = page.readUInt32LE(offset), c = page.subarray(offset, offset + size);
240
+ if (c.length !== size || size < 56)
241
+ throw new UsageError('Invalid Safari cookie record.');
242
+ const field = (at) => { const o = c.readUInt32LE(at), end = c.indexOf(0, o); if (o < 56 || end < o)
243
+ throw new UsageError('Invalid Safari cookie field.'); return c.subarray(o, end).toString(); };
244
+ const flags = c.readUInt32LE(8);
245
+ cookies.push({ domain: field(16), name: field(20), path: field(24), value: field(28), expires: c.readDoubleLE(40) + 978307200, secure: !!(flags & 1), httpOnly: !!(flags & 4), sameSite: 'None' });
246
+ }
247
+ }
248
+ return cookies;
249
+ }
@@ -0,0 +1 @@
1
+ export declare function normalizeBrowserSite(raw: string): string;
@@ -0,0 +1,13 @@
1
+ import { UsageError } from "./errors.js";
2
+ export function normalizeBrowserSite(raw) {
3
+ let u;
4
+ try {
5
+ u = new URL(raw.includes('://') ? raw : `https://${raw}`);
6
+ }
7
+ catch {
8
+ throw new UsageError('site must be an HTTP or HTTPS origin');
9
+ }
10
+ if (!['http:', 'https:'].includes(u.protocol) || u.username || u.password || u.search || u.hash || u.pathname !== '/')
11
+ throw new UsageError('site must be an origin without credentials, a path or a query');
12
+ return u.origin;
13
+ }
@@ -0,0 +1,9 @@
1
+ import type { AgentCredentialMetadata, Workspace } from '@shardflux/sdk';
2
+ import type { Ctx } from './commands.js';
3
+ export declare function requireClaudeTerminal(ctx: Ctx): void;
4
+ export declare function cloudClaudeLogin(ctx: Ctx): Promise<AgentCredentialMetadata | undefined>;
5
+ /** Parse only complete provider authorize links, including the native OSC-8 link. */
6
+ export declare function claudeLoginUrls(output: string): string[];
7
+ /** A transient Linux profile holds the initial pair; it is removed before an agent starts. */
8
+ export declare function loginClaudeInWorkspace(ctx: Ctx, ws: Workspace): Promise<AgentCredentialMetadata>;
9
+ export declare function ensureCloudClaude(ctx: Ctx, ws: Workspace): Promise<void>;
@@ -0,0 +1,143 @@
1
+ import { ShardfluxApiError } from '@shardflux/sdk';
2
+ import { UsageError } from "./errors.js";
3
+ import { openUrl } from "./browser.js";
4
+ import { attachTerminal, cellConnector, PtyOutputCursor } from "./agent-terminal.js";
5
+ const NEED_TERMINAL = 'Claude sign-in needed. Run shard agents credentials login claude in a terminal.';
6
+ const VERSION = '2.1.289';
7
+ export function requireClaudeTerminal(ctx) {
8
+ if (ctx.json || !ctx.io.stdin.isTTY || !ctx.io.stdin.setRawMode || !ctx.io.stdin.on)
9
+ throw new UsageError(NEED_TERMINAL);
10
+ }
11
+ export async function cloudClaudeLogin(ctx) {
12
+ return (await ctx.cloud().agentCredentials.list()).find(row => row.app === 'claude' && row.scope === 'account' && row.status === 'ready');
13
+ }
14
+ /** Parse only complete provider authorize links, including the native OSC-8 link. */
15
+ export function claudeLoginUrls(output) {
16
+ const urls = new Set();
17
+ // Native terminal OSC-8 links end at control bytes; reject partial frames.
18
+ // eslint-disable-next-line no-control-regex
19
+ for (const match of output.matchAll(/https:\/\/[^\s\x00-\x20]+(?=[\s\x00-\x20])/g)) {
20
+ try {
21
+ const url = new URL(match[0]);
22
+ if (['claude.com', 'claude.ai', 'platform.claude.com', 'console.anthropic.com'].includes(url.hostname) &&
23
+ url.pathname.endsWith('/oauth/authorize') && url.searchParams.get('code') === 'true')
24
+ urls.add(url.toString());
25
+ }
26
+ catch { /* fragmented terminal links wait for the next frame */ }
27
+ }
28
+ return [...urls];
29
+ }
30
+ function credentialFile(path, value, ctx) {
31
+ const raw = JSON.stringify(value), content_b64 = Buffer.from(raw).toString('base64');
32
+ const hide = (value) => {
33
+ if (typeof value === 'string')
34
+ ctx.addSecret(value);
35
+ else if (Array.isArray(value))
36
+ value.forEach(hide);
37
+ else if (value && typeof value === 'object')
38
+ Object.values(value).forEach(hide);
39
+ };
40
+ hide(value);
41
+ ctx.addSecret(raw);
42
+ ctx.addSecret(content_b64);
43
+ return { path, mode: '0600', content_b64 };
44
+ }
45
+ /** A transient Linux profile holds the initial pair; it is removed before an agent starts. */
46
+ export async function loginClaudeInWorkspace(ctx, ws) {
47
+ requireClaudeTerminal(ctx);
48
+ const api = ctx.cloud().agentCredentials;
49
+ let claim;
50
+ try {
51
+ claim = await api.beginLogin(ws.id);
52
+ }
53
+ catch (error) {
54
+ if (error instanceof ShardfluxApiError && error.details?.reason === 'claude_already_signed_in') {
55
+ const ready = await cloudClaudeLogin(ctx);
56
+ if (ready)
57
+ return ready;
58
+ }
59
+ throw error;
60
+ }
61
+ const root = `/tmp/shard-claude-login-${claim.login_id}`;
62
+ const session = `claude-login-${claim.login_id}`;
63
+ const cell = ws.cell({ agentLabel: ctx.agentLabel, transitionTimeoutMs: ctx.wakeTimeoutMs });
64
+ let complete = false;
65
+ try {
66
+ await cell.files.mkdir(root, { parents: true, mode: '0700' });
67
+ const env = { HOME: root, CLAUDE_CONFIG_DIR: `${root}/.claude`, BROWSER: '/bin/false', DISABLE_AUTOUPDATER: '1' };
68
+ const version = await cell.exec.run(['claude', '--version'], { env, cwd: root, signal: ctx.signal, timeoutMs: 10_000 });
69
+ if (version.exitCode !== 0 || version.stdout.trim() !== `${VERSION} (Claude Code)`)
70
+ throw new UsageError(`Cloud Claude sign-in requires Claude Code ${VERSION} in the workspace image.`);
71
+ await cell.pty.open({ session_id: session, argv: ['claude', 'auth', 'login', '--claudeai'], env, cwd: root, rows: 24, cols: 100 });
72
+ const connect = cellConnector(cell, session);
73
+ const opened = new Set();
74
+ const cursor = new PtyOutputCursor();
75
+ let output = '';
76
+ const code = await attachTerminal({ io: ctx.io, signal: ctx.signal, wake: signal => ws.wake({ signal }), invalidate: () => { },
77
+ sleep: (ms, signal) => new Promise(resolve => {
78
+ const done = () => { clearTimeout(timer); signal.removeEventListener('abort', done); resolve(); };
79
+ const timer = setTimeout(done, ms);
80
+ signal.addEventListener('abort', done, { once: true });
81
+ if (signal.aborted)
82
+ done();
83
+ }),
84
+ connect: async (offset, signal) => {
85
+ const connection = await connect(offset, signal);
86
+ return {
87
+ input: bytes => connection.input(bytes), resize: (rows, cols) => connection.resize(rows, cols), close: () => connection.close(),
88
+ messages: async function* () {
89
+ for await (const message of connection.messages()) {
90
+ const bytes = cursor.consume(message);
91
+ if (bytes) {
92
+ output = (output + bytes.toString()).slice(-16384);
93
+ for (const url of claudeLoginUrls(output))
94
+ if (!opened.has(url)) {
95
+ opened.add(url);
96
+ openUrl(url, ctx.io.env);
97
+ }
98
+ }
99
+ yield message;
100
+ }
101
+ },
102
+ };
103
+ },
104
+ });
105
+ const ended = await cell.pty.get(session);
106
+ if (code !== 0 || ended.state !== 'exited' || ended.exit_code !== 0)
107
+ throw new UsageError('Claude cloud sign-in was interrupted. Run shard agents credentials login claude again.');
108
+ const credential = JSON.parse(await cell.files.readText(`${root}/.claude/.credentials.json`));
109
+ const config = JSON.parse(await cell.files.readText(`${root}/.claude/.claude.json`));
110
+ const files = [credentialFile('~/.claude/.credentials.json', credential, ctx), credentialFile('~/.claude.json', { oauthAccount: config.oauthAccount }, ctx)];
111
+ // Delete the initial refresh token and all native backups before network
112
+ // persistence. An upload error requires a new cloud login, never guest refresh.
113
+ await cell.files.remove(root, { recursive: true });
114
+ const metadata = await api.put('claude', files, { loginId: claim.login_id });
115
+ const access = Object.fromEntries(['accessToken', 'expiresAt', 'scopes', 'subscriptionType', 'rateLimitTier'].filter(key => key in credential.claudeAiOauth).map(key => [key, credential.claudeAiOauth[key]]));
116
+ await cell.files.write('/home/user/.claude/.credentials.json', JSON.stringify({ claudeAiOauth: access }), { mode: '0600', createParents: true });
117
+ let document = {}, revision = 'absent';
118
+ try {
119
+ const current = await cell.files.readWithInfo('/home/user/.claude.json');
120
+ document = JSON.parse(Buffer.from(current.data).toString());
121
+ if (!current.revision)
122
+ throw new UsageError('Claude account configuration has no safe content revision.');
123
+ revision = current.revision;
124
+ }
125
+ catch (error) {
126
+ if (!(error instanceof Error && 'status' in error && error.status === 404))
127
+ throw error;
128
+ }
129
+ await cell.files.patch({ path: '/home/user/.claude.json', content: JSON.stringify({ ...document, oauthAccount: config.oauthAccount }), expectedRevision: revision, mode: '0600', createParents: true }, { signal: ctx.signal });
130
+ complete = true;
131
+ return metadata;
132
+ }
133
+ finally {
134
+ await cell.pty.close(session).catch(() => undefined);
135
+ await cell.files.remove(root, { recursive: true }).catch(() => undefined);
136
+ if (!complete)
137
+ await api.cancelLogin(claim.login_id).catch(() => undefined);
138
+ }
139
+ }
140
+ export async function ensureCloudClaude(ctx, ws) {
141
+ if (!await cloudClaudeLogin(ctx))
142
+ await loginClaudeInWorkspace(ctx, ws);
143
+ }
@@ -0,0 +1,6 @@
1
+ import type { Ctx, Handler } from './commands.js';
2
+ import type { Values } from './args.js';
3
+ /** L9 can register target-specific handlers after detecting the installed applications. */
4
+ export declare function availableTargets(ctx: Ctx): Promise<string[]>;
5
+ export declare function agentAction(ctx: Ctx, action: string, values: Values, args: string[]): Promise<number>;
6
+ export declare const CENTER_HANDLERS: Record<string, Handler>;
@@ -0,0 +1,132 @@
1
+ import { out, resolveWorkspace, str, waitTimeout, workspaceCaps } from "./commands.js";
2
+ import { launchProject } from "./agent-commands.js";
3
+ import { AgentStateStore } from "./agent-state.js";
4
+ import { agentName } from "./command-center-model.js";
5
+ import { json } from "./format.js";
6
+ import { UsageError } from "./errors.js";
7
+ import { cellConnector, PtyOutputCursor } from "./agent-terminal.js";
8
+ import { rememberGitAgent } from "./agent-git-commands.js";
9
+ import { agentName as gitAgentName } from "./agent-git.js";
10
+ /** L9 can register target-specific handlers after detecting the installed applications. */
11
+ export async function availableTargets(ctx) {
12
+ const { HANDLERS } = await import("./handlers.js");
13
+ const detect = HANDLERS['agents targets'];
14
+ if (!detect)
15
+ return ['terminal'];
16
+ let output = '';
17
+ const code = await detect({ ...ctx, json: true, io: { ...ctx.io, stdout: { write: chunk => { output += Buffer.from(chunk).toString(); return true; } } } }, {}, []);
18
+ if (code !== 0)
19
+ throw new Error('open target detection failed');
20
+ const result = JSON.parse(output);
21
+ return ['terminal', ...result.targets.filter(target => ['claude-desktop', 'codex-app', 'web'].includes(target) && HANDLERS[`agents open ${target}`])];
22
+ }
23
+ export async function agentAction(ctx, action, values, args) {
24
+ const { HANDLERS } = await import("./handlers.js");
25
+ const handler = HANDLERS[`agents ${action}`];
26
+ if (!handler)
27
+ throw new UsageError(`agents ${action} is unavailable in this CLI`);
28
+ return handler(ctx, values, args);
29
+ }
30
+ export const CENTER_HANDLERS = {
31
+ async 'agents new'(ctx, values, [ref]) {
32
+ if (values.name !== undefined || !ref || values.path !== undefined) {
33
+ return launchProject(ctx, { ...values, ...(ref && values.like === undefined ? { like: ref } : {}) }, [str(values.path) ?? './', ...(str(values.app) ? [str(values.app)] : [])]);
34
+ }
35
+ return agentAction(ctx, 'open', { ...values, in: 'terminal' }, [ref]);
36
+ },
37
+ async 'agents fork'(ctx, values, [ref, name]) {
38
+ if (!ref || !name || !/^[a-z0-9][a-z0-9._-]{0,63}$/.test(name))
39
+ throw new UsageError('agents fork requires a source and a new agent name');
40
+ gitAgentName(name);
41
+ const source = await resolveWorkspace(ctx, ref);
42
+ const key = `${source.labels.shard_project || source.key.split('/')[0]}/${name}`;
43
+ const labels = { ...source.labels, shard_project: source.labels.shard_project || source.key.split('/')[0], agent: name,
44
+ agent_pty_name: source.labels.agent_pty_name || agentName(source.key, source.labels) };
45
+ const caps = workspaceCaps(values);
46
+ const result = await ctx.cloud().workspaces.fork(source.id, { key, labels, ...(caps ? { caps } : {}) }, {
47
+ agentLabel: ctx.agentLabel, wait: values.wait === false ? false : { timeoutMs: waitTimeout(values), signal: ctx.signal },
48
+ });
49
+ const agent = result.operation.result?.agent;
50
+ const receipt = agent && typeof agent === 'object' ? agent : undefined;
51
+ if (result.operation.state === 'succeeded' && (!receipt ||
52
+ ['id', 'workspace_key', 'session_id'].some(field => typeof receipt[field] !== 'string' || !receipt[field]) ||
53
+ receipt.workspace_key !== key || !['working', 'waiting'].includes(String(receipt.state)))) {
54
+ throw new Error(`Fork ${key} has no independent agent session receipt`);
55
+ }
56
+ await ctx.cloud().workspaces.setRetention(result.workspace.id, { delete_after_idle_days: 7 });
57
+ await rememberGitAgent(ctx, result.workspace, './');
58
+ out(ctx, `Forked ${key}: operation ${result.operation.id} ${result.operation.state}\n`, { workspace: result.workspace.data, operation: result.operation, ...(agent ? { agent } : {}) });
59
+ return 0;
60
+ },
61
+ async 'agents park'(ctx, values, [ref]) {
62
+ if (!ref)
63
+ throw new UsageError('agents park requires an agent workspace key');
64
+ const { HANDLERS } = await import("./handlers.js");
65
+ return HANDLERS['workspaces suspend'](ctx, { ...values, 'when-idle': str(values['when-idle']) ?? '1s' }, [ref]);
66
+ },
67
+ async 'agents logs'(ctx, values, [ref]) {
68
+ if (!ref)
69
+ throw new UsageError('agents logs requires an agent workspace key');
70
+ if (values.app !== undefined && values.app !== 'claude' && values.app !== 'codex')
71
+ throw new UsageError('--app must be claude or codex');
72
+ const ws = await resolveWorkspace(ctx, ref);
73
+ const project = ws.labels.repo_remote || ws.labels.shard_project || ws.key;
74
+ const app = str(values.app) ?? new AgentStateStore(ctx.store.dir).app(project, ws.labels.app === 'codex' ? 'codex' : 'claude');
75
+ const id = `shard-${ws.labels.agent_pty_name || agentName(ws.key, ws.labels)}-${app}`;
76
+ const cell = ws.cell({ agentLabel: ctx.agentLabel, ...(ctx.wake ? {} : { wake: null }), transitionTimeoutMs: ctx.wakeTimeoutMs });
77
+ const offsetText = str(values.offset);
78
+ if (offsetText !== undefined && (!/^\d+$/.test(offsetText) || !Number.isSafeInteger(Number(offsetText))))
79
+ throw new UsageError('--offset must be a nonnegative safe integer');
80
+ const controller = new AbortController();
81
+ const signal = AbortSignal.any([ctx.signal, controller.signal]);
82
+ let connection;
83
+ const stop = () => { void connection?.close(); cell.close(); };
84
+ signal.addEventListener('abort', stop, { once: true });
85
+ let timer;
86
+ try {
87
+ if (signal.aborted)
88
+ return 130;
89
+ const session = await cell.pty.get(id);
90
+ if (signal.aborted)
91
+ return 130;
92
+ const cursor = new PtyOutputCursor();
93
+ cursor.offset = offsetText === undefined ? Math.max(0, session.output_size - 65_536) : Number(offsetText);
94
+ const decoder = new TextDecoder();
95
+ let output = '', exited = false;
96
+ const following = !ctx.json && values.follow === true;
97
+ if (!following)
98
+ timer = setTimeout(() => controller.abort(), 1500);
99
+ connection = await cellConnector(cell, id)(cursor.offset, signal);
100
+ for await (const message of connection.messages()) {
101
+ if (signal.aborted)
102
+ break;
103
+ const bytes = cursor.consume(message);
104
+ if (bytes) {
105
+ const text = decoder.decode(bytes, { stream: true });
106
+ if (ctx.json)
107
+ output += text;
108
+ else
109
+ ctx.io.stdout.write(text);
110
+ if (!following && cursor.offset >= session.output_size)
111
+ break;
112
+ }
113
+ if (message.type === 'exit') {
114
+ exited = true;
115
+ break;
116
+ }
117
+ }
118
+ const remainder = decoder.decode();
119
+ if (ctx.json)
120
+ ctx.io.stdout.write(json({ pty_id: id, output: output + remainder, nextOffset: cursor.offset, session, exited }));
121
+ else
122
+ ctx.io.stdout.write(remainder);
123
+ return 0;
124
+ }
125
+ finally {
126
+ clearTimeout(timer);
127
+ signal.removeEventListener('abort', stop);
128
+ await connection?.close();
129
+ cell.close();
130
+ }
131
+ },
132
+ };
@@ -0,0 +1,6 @@
1
+ export type CenterKey = string;
2
+ export declare class CenterKeys {
3
+ #private;
4
+ feed(chunk: Buffer | string): CenterKey[];
5
+ escape(): CenterKey[];
6
+ }
@@ -0,0 +1,38 @@
1
+ import { StringDecoder } from 'node:string_decoder';
2
+ export class CenterKeys {
3
+ #pending = '';
4
+ #decoder = new StringDecoder('utf8');
5
+ feed(chunk) {
6
+ this.#pending += typeof chunk === 'string' ? chunk : this.#decoder.write(chunk);
7
+ const keys = [];
8
+ while (this.#pending) {
9
+ if (this.#pending[0] === '\x1b') {
10
+ if (this.#pending.length === 1 || /^\[[0-9;]*$/.test(this.#pending.slice(1)) || this.#pending === '\x1bO')
11
+ break;
12
+ const sequence = /^(?:\[[0-9;]*|O)([A-Za-z~])/.exec(this.#pending.slice(1));
13
+ if (sequence) {
14
+ this.#pending = this.#pending.slice(sequence[0].length + 1);
15
+ if (sequence[1] === 'A')
16
+ keys.push('up');
17
+ if (sequence[1] === 'B')
18
+ keys.push('down');
19
+ continue;
20
+ }
21
+ this.#pending = this.#pending.slice(1);
22
+ keys.push('escape');
23
+ continue;
24
+ }
25
+ const ch = [...this.#pending][0];
26
+ this.#pending = this.#pending.slice(ch.length);
27
+ keys.push(ch === '\r' || ch === '\n' ? 'enter' : ch === '\x7f' || ch === '\b' ? 'backspace' : ch === '\x03' || ch === '\x04' ? 'quit' : ch);
28
+ }
29
+ return keys;
30
+ }
31
+ escape() {
32
+ if (this.#pending === '\x1b') {
33
+ this.#pending = '';
34
+ return ['escape'];
35
+ }
36
+ return [];
37
+ }
38
+ }
@@ -0,0 +1,29 @@
1
+ import type { BrowserProfile, Agent, WorkspaceView } from '@shardflux/sdk';
2
+ import type { Ctx } from './commands.js';
3
+ import { projectIdentity } from './project-identity.js';
4
+ import type { SetupProgress } from './assisted-setup.js';
5
+ export interface CenterData {
6
+ workspaces: WorkspaceView[];
7
+ agents: Agent[];
8
+ browserProfiles?: BrowserProfile[];
9
+ setups?: Record<string, SetupProgress>;
10
+ }
11
+ export type ProjectScope = ReturnType<typeof projectIdentity>;
12
+ export interface CenterRow {
13
+ id: string;
14
+ project: string;
15
+ key: string;
16
+ agent?: Agent;
17
+ workspace?: WorkspaceView;
18
+ start?: boolean;
19
+ badge: string;
20
+ attention: number;
21
+ browserProfiles?: BrowserProfile[];
22
+ setup?: SetupProgress;
23
+ }
24
+ /** Older K5 captures used the workspace key as the agent label. K1 needs its leaf name. */
25
+ export declare const agentName: (key: string, labels: Record<string, string>) => string;
26
+ export declare const projectName: (key: string, labels: Record<string, string>) => string;
27
+ export declare function loadCenter(ctx: Ctx): Promise<CenterData>;
28
+ export declare function scopedProject(data: CenterData, scope: ProjectScope): string;
29
+ export declare function centerRows(data: CenterData, scope?: ProjectScope, filter?: string): CenterRow[];