@shardflux/cli 0.11.0 → 0.13.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 (86) hide show
  1. package/CHANGELOG.md +65 -1
  2. package/README.md +85 -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 +284 -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.d.ts +14 -0
  66. package/dist/git-remote.js +136 -0
  67. package/dist/handlers.js +9 -1
  68. package/dist/index.d.ts +2 -0
  69. package/dist/index.js +1 -0
  70. package/dist/input.d.ts +5 -0
  71. package/dist/main.js +21 -5
  72. package/dist/menubar.d.ts +32 -0
  73. package/dist/menubar.js +180 -0
  74. package/dist/project-identity.d.ts +14 -0
  75. package/dist/project-identity.js +61 -0
  76. package/dist/project-sync-manifest.d.ts +27 -0
  77. package/dist/project-sync-manifest.js +225 -0
  78. package/dist/project-sync-transfer.d.ts +14 -0
  79. package/dist/project-sync-transfer.js +240 -0
  80. package/dist/project-sync.d.ts +38 -0
  81. package/dist/project-sync.js +243 -0
  82. package/dist/project-template-setup.d.ts +17 -0
  83. package/dist/project-template-setup.js +89 -0
  84. package/dist/vault.d.ts +16 -0
  85. package/dist/vault.js +153 -0
  86. package/package.json +11 -3
@@ -0,0 +1,182 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
3
+ import { homedir, tmpdir } from 'node:os';
4
+ import { join, resolve } from 'node:path';
5
+ import { AgentStateStore } from "./agent-state.js";
6
+ import { projectIdentity } from "./project-identity.js";
7
+ import { localManifest, projectGit } from "./project-sync-manifest.js";
8
+ import { applyRemoteSyncBatch, readLocalSyncFile, writeSyncBlob } from "./project-sync-transfer.js";
9
+ export function projectWorkspacePath(project) {
10
+ if (!/^[a-z0-9][a-z0-9._-]{0,95}$/.test(project.slug))
11
+ throw new Error('invalid sync project slug');
12
+ return `/home/user/${project.slug}`;
13
+ }
14
+ /** New workspaces label this path explicitly; custom remote paths remain available to Git callers. */
15
+ export function workspaceRepositoryPath(ws) {
16
+ return ws.labels.repo_path || projectWorkspacePath({ slug: ws.labels.shard_project || ws.key.split('/')[0] });
17
+ }
18
+ export function needsGitSeed(ctx, ws) {
19
+ const seed = new AgentStateStore(ctx.store.dir).read().git_seeding?.[ws.id];
20
+ return seed !== undefined && seed.api_url === ctx.apiUrl;
21
+ }
22
+ /** One history seed for C5 and C11. Bundles use the bounded files path, without a Git helper or second connection. */
23
+ export async function seedAgentRepository(ctx, ws, folder, options = {}) {
24
+ const project = projectIdentity(folder);
25
+ // A cloud-only `like` launch must never seed an unrelated laptop repository.
26
+ if (ws.labels.repo_remote && ws.labels.repo_remote !== project.remote)
27
+ return;
28
+ const root = options.root ?? workspaceRepositoryPath(ws);
29
+ const retry = needsGitSeed(ctx, ws);
30
+ const state = new AgentStateStore(ctx.store.dir);
31
+ state.update(doc => { const seeds = (doc.git_seeding ?? {}); seeds[ws.id] = { api_url: ctx.apiUrl, folder: project.folder }; doc.git_seeding = seeds; });
32
+ const cell = ws.cell({ agentLabel: ctx.agentLabel, transitionTimeoutMs: ctx.wakeTimeoutMs });
33
+ await cell.files.mkdir(root, { parents: true });
34
+ await initializeProjectGit(cell, project.folder, root, ctx.signal, options.populate, retry, message => { ctx.io.stderr.write(`${message}\n`); });
35
+ state.update(doc => { const seeds = (doc.git_seeding ?? {}); delete seeds[ws.id]; doc.git_seeding = seeds; });
36
+ }
37
+ async function initializeProjectGit(cell, folder, root, signal, populate = false, retry = false, report = () => undefined) {
38
+ let top;
39
+ try {
40
+ top = (await projectGit(folder, ['rev-parse', '--show-toplevel'], signal)).trim();
41
+ }
42
+ catch (error) {
43
+ if (signal?.aborted || error.code !== 128)
44
+ throw error;
45
+ if (populate)
46
+ await seedProjectFiles(cell, folder, root, '', new Set(), true, signal, report);
47
+ return;
48
+ }
49
+ if (top !== folder)
50
+ throw new Error('project sync requires the Git repository root; run shard there');
51
+ const existing = await cell.exec.run(['git', '-C', root, 'rev-parse', '--verify', 'HEAD'], { timeoutMs: 5000, signal });
52
+ if (existing.exitCode !== 0 && existing.exitCode !== 128)
53
+ throw new Error('could not check workspace Git history');
54
+ if (existing.exitCode === 128 || retry) {
55
+ const importHistory = existing.exitCode === 128;
56
+ let head = '', branch = '';
57
+ try {
58
+ head = (await projectGit(folder, ['rev-parse', '--verify', 'HEAD'], signal)).trim();
59
+ }
60
+ catch (error) {
61
+ if (signal?.aborted)
62
+ throw error;
63
+ }
64
+ try {
65
+ branch = (await projectGit(folder, ['symbolic-ref', '-q', 'HEAD'], signal)).trim();
66
+ }
67
+ catch (error) {
68
+ if (signal?.aborted)
69
+ throw error;
70
+ }
71
+ if (head && importHistory) {
72
+ const dir = await mkdtemp(join(tmpdir(), 'shard-sync-git-'));
73
+ const local = join(dir, 'history.bundle'), remote = `/tmp/shard-sync-${randomUUID()}.bundle`;
74
+ try {
75
+ await projectGit(folder, ['bundle', 'create', local, '--all'], signal);
76
+ if ((await stat(local)).size > 256 << 20)
77
+ throw new Error('Git history bundle exceeds the 256 MiB sync limit');
78
+ await writeSyncBlob(cell, remote, await readFile(local));
79
+ const script = 'set -eu; git init -q "$1"; git -C "$1" -c core.hooksPath=/dev/null fetch -q --update-head-ok "$2" HEAD:refs/shard-sync/initial "+refs/heads/*:refs/heads/*" "+refs/tags/*:refs/tags/*"; if [ -n "$4" ]; then git -C "$1" symbolic-ref HEAD "$4"; else git -C "$1" update-ref --no-deref HEAD "$3"; fi; git -C "$1" update-ref HEAD "$3"; git -C "$1" read-tree HEAD; git -C "$1" update-ref -d refs/shard-sync/initial';
80
+ const result = await cell.exec.run(['sh', '-c', script, 'shard-sync', root, remote, head, branch], { timeoutMs: 60_000, maxOutputBytes: 4096, signal });
81
+ if (result.exitCode !== 0)
82
+ throw new Error(`initial Git bundle failed: ${result.stderr.trim()}`);
83
+ }
84
+ finally {
85
+ await rm(dir, { recursive: true, force: true });
86
+ await cell.files.remove(remote).catch(() => undefined);
87
+ }
88
+ }
89
+ else if (importHistory) {
90
+ const result = await cell.exec.run(['git', 'init', '-q', ...(branch.startsWith('refs/heads/') ? ['--initial-branch', branch.slice(11)] : []), root], { timeoutMs: 5000, signal });
91
+ if (result.exitCode !== 0)
92
+ throw new Error(`initial Git repository failed: ${result.stderr.trim()}`);
93
+ }
94
+ for (const key of ['user.name', 'user.email']) {
95
+ let value = '';
96
+ try {
97
+ value = (await projectGit(folder, ['config', '--get', key], signal)).trim();
98
+ }
99
+ catch (error) {
100
+ if (signal?.aborted)
101
+ throw error;
102
+ }
103
+ if (value) {
104
+ const result = await cell.exec.run(['git', '-C', root, 'config', key, value], { timeoutMs: 5000, signal });
105
+ if (result.exitCode !== 0)
106
+ throw new Error(`could not copy Git ${key}`);
107
+ }
108
+ }
109
+ const origin = projectIdentity(folder).remote;
110
+ if (origin && !origin.startsWith('file:')) {
111
+ const result = await cell.exec.run(['git', '-C', root, 'config', 'remote.origin.url', origin], { timeoutMs: 5000, signal });
112
+ if (result.exitCode !== 0)
113
+ throw new Error('could not copy Git origin');
114
+ }
115
+ // Ignore files must exist before the first remote scan; otherwise a
116
+ // template's dependency outputs would look like agent-created source.
117
+ // For unborn repos, intent-to-add preserves tracked ignored paths too.
118
+ const tracked = new Set((await projectGit(folder, ['ls-files', '-z', '--cached'], signal)).split('\0'));
119
+ await seedProjectFiles(cell, folder, root, head, tracked, populate, signal, report);
120
+ }
121
+ // Git's info/exclude and laptop-wide excludes are selection metadata, copied
122
+ // into a dedicated Git exclude file without syncing credentials or .git data.
123
+ const readOptional = async (path) => {
124
+ try {
125
+ return await readFile(path, 'utf8');
126
+ }
127
+ catch (error) {
128
+ if (error.code === 'ENOENT')
129
+ return '';
130
+ throw error;
131
+ }
132
+ };
133
+ const info = (await projectGit(folder, ['rev-parse', '--git-path', 'info/exclude'], signal)).trim();
134
+ let global = '';
135
+ try {
136
+ global = (await projectGit(folder, ['config', '--path', '--get', 'core.excludesFile'], signal)).trim();
137
+ }
138
+ catch (error) {
139
+ if (signal?.aborted)
140
+ throw error;
141
+ }
142
+ const excludes = await readOptional(resolve(folder, info)) + '\n' + await readOptional(global ? resolve(folder, global) : join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'git', 'ignore'));
143
+ await cell.files.write(`${root}/.git/shard-sync-excludes`, excludes, { mode: '0600' });
144
+ const result = await cell.exec.run(['git', '-C', root, 'config', 'core.excludesFile', `${root}/.git/shard-sync-excludes`], { timeoutMs: 5000, signal });
145
+ if (result.exitCode !== 0)
146
+ throw new Error('could not apply project Git exclude rules');
147
+ }
148
+ async function seedProjectFiles(cell, folder, root, head, tracked, populate, signal, report) {
149
+ const raw = await localManifest(folder, signal);
150
+ for (const skip of raw.skipped)
151
+ report(`Project seed skipped ${skip.path}: ${skip.reason}`);
152
+ const seed = raw.entries.filter((entry) => populate || entry.path.split('/').at(-1) === '.gitignore' || (!head && tracked.has(entry.path)));
153
+ for (let offset = 0; offset < seed.length; offset += 32) {
154
+ const entries = seed.slice(offset, offset + 32);
155
+ const probe = await cell.exec.run(['python3', '-c', 'import os,sys,json;root=sys.argv[1];print(json.dumps([p for p in json.loads(sys.argv[2]) if not os.path.lexists(root+"/"+p)]))', root, JSON.stringify(entries.map((entry) => entry.path))], { timeoutMs: 5000, signal });
156
+ if (probe.exitCode !== 0)
157
+ throw new Error('could not inspect initial project ignore files');
158
+ const missing = new Set(JSON.parse(probe.stdout));
159
+ let seeds = new Map(), bytes = 0;
160
+ for (const entry of entries)
161
+ if (missing.has(entry.path)) {
162
+ if (bytes + entry.size > 8 << 20 && seeds.size) {
163
+ await applyRemoteSyncBatch(cell, root, seeds, {}, signal);
164
+ seeds = new Map();
165
+ bytes = 0;
166
+ }
167
+ const file = await readLocalSyncFile(folder, entry);
168
+ seeds.set(entry.path, file);
169
+ bytes += file.data.length;
170
+ }
171
+ if (seeds.size)
172
+ await applyRemoteSyncBatch(cell, root, seeds, {}, signal);
173
+ if (!head) {
174
+ const add = entries.filter((entry) => tracked.has(entry.path)).map((entry) => entry.path);
175
+ if (add.length) {
176
+ const result = await cell.exec.run(['git', '-C', root, '-c', 'core.hooksPath=/dev/null', 'add', '-N', '-f', '--', ...add], { timeoutMs: 5000, signal });
177
+ if (result.exitCode !== 0)
178
+ throw new Error(`initial tracked project files failed: ${result.stderr.trim()}`);
179
+ }
180
+ }
181
+ }
182
+ }
@@ -0,0 +1,16 @@
1
+ type State = Record<string, unknown> & {
2
+ projects?: Record<string, {
3
+ app?: string;
4
+ }>;
5
+ };
6
+ /** Keep other lanes' sync manifests and project settings when remembering the app choice. */
7
+ export declare class AgentStateStore {
8
+ readonly file: string;
9
+ readonly dir: string;
10
+ constructor(dir: string);
11
+ read(): State;
12
+ app(project: string, fallback?: 'claude' | 'codex'): 'claude' | 'codex';
13
+ update(change: (state: State) => void): void;
14
+ save(project: string, app: 'claude' | 'codex'): void;
15
+ }
16
+ export {};
@@ -0,0 +1,63 @@
1
+ import { readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { ensurePrivateDir, writePrivateFile } from "./credentials.js";
4
+ import { UsageError } from "./errors.js";
5
+ /** Keep other lanes' sync manifests and project settings when remembering the app choice. */
6
+ export class AgentStateStore {
7
+ file;
8
+ dir;
9
+ constructor(dir) { this.dir = dir; this.file = join(dir, 'agents.json'); }
10
+ read() {
11
+ try {
12
+ const parsed = JSON.parse(readFileSync(this.file, 'utf8'));
13
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
14
+ throw new Error('expected an object');
15
+ return parsed;
16
+ }
17
+ catch (error) {
18
+ if (error.code === 'ENOENT')
19
+ return {};
20
+ throw new UsageError(`cannot read ${this.file}: ${error instanceof Error ? error.message : String(error)}`);
21
+ }
22
+ }
23
+ app(project, fallback = 'claude') {
24
+ const app = this.read().projects?.[project]?.app;
25
+ return app === 'claude' || app === 'codex' ? app : fallback;
26
+ }
27
+ update(change) {
28
+ ensurePrivateDir(this.dir);
29
+ const lock = `${this.file}.lock`;
30
+ try {
31
+ writeFileSync(lock, String(process.pid), { flag: 'wx', mode: 0o600 });
32
+ }
33
+ catch (error) {
34
+ if (error.code !== 'EEXIST')
35
+ throw error;
36
+ try {
37
+ process.kill(Number(readFileSync(lock, 'utf8')), 0);
38
+ }
39
+ catch (probe) {
40
+ if (probe.code === 'ESRCH') {
41
+ rmSync(lock);
42
+ return this.update(change);
43
+ }
44
+ }
45
+ throw new UsageError('another shard process is updating agent state; run the command again');
46
+ }
47
+ try {
48
+ const state = this.read();
49
+ change(state);
50
+ writePrivateFile(this.dir, this.file, JSON.stringify(state) + '\n');
51
+ }
52
+ finally {
53
+ rmSync(lock, { force: true });
54
+ }
55
+ }
56
+ save(project, app) {
57
+ this.update(doc => {
58
+ doc.projects ??= {};
59
+ doc.projects[project] = { ...doc.projects[project], app };
60
+ delete doc.terminal_sessions;
61
+ });
62
+ }
63
+ }
@@ -0,0 +1,10 @@
1
+ import { Duplex } from 'node:stream';
2
+ import type { CellClient, Workspace } from '@shardflux/sdk';
3
+ import type { Ctx, Handler, Io } from './commands.js';
4
+ import { AgentTargets } from './agent-targets.js';
5
+ /** Optimistic file replacement preserves keys installed by other devices connecting at the same time. */
6
+ export declare function installDeviceKey(cell: CellClient, key: string, signal?: AbortSignal): Promise<void>;
7
+ /** Host trust is established over the authenticated files API, before sending any SSH protocol bytes. */
8
+ export declare function prepareSsh(ws: Workspace, ctx: Ctx, targets: AgentTargets, host: string): Promise<CellClient>;
9
+ export declare function proxyStream(io: Io): Duplex;
10
+ export declare const TARGET_HANDLERS: Record<string, Handler>;
@@ -0,0 +1,171 @@
1
+ import { agentReachability } from "./agent-reachability.js";
2
+ import { ensureCloudClaude } from "./claude-cloud-login.js";
3
+ import { spawn } from 'node:child_process';
4
+ import { Readable, Duplex } from 'node:stream';
5
+ import { ShardfluxApiError, WorkspaceTunnels } from '@shardflux/sdk';
6
+ import { out, resolveWorkspace } from "./commands.js";
7
+ import { AgentTargets, installedTargets } from "./agent-targets.js";
8
+ import { projectIdentity } from "./project-identity.js";
9
+ import { projectWorkspacePath } from "./agent-repository.js";
10
+ import { UsageError } from "./errors.js";
11
+ import { openUrl } from "./browser.js";
12
+ const missing = (e) => e instanceof ShardfluxApiError && e.status === 404;
13
+ const keyOf = (ref) => ref.includes('/') ? ref : `${projectIdentity('./').slug}/${ref}`;
14
+ /** Optimistic file replacement preserves keys installed by other devices connecting at the same time. */
15
+ export async function installDeviceKey(cell, key, signal) {
16
+ const path = '/home/user/.ssh/authorized_keys';
17
+ await cell.files.mkdir('/home/user/.ssh', { mode: '0700', parents: true });
18
+ for (let attempt = 0; attempt < 4; attempt++) {
19
+ let text = '', revision = 'absent';
20
+ try {
21
+ const read = await cell.files.readWithInfo(path);
22
+ if (!read.revision || read.data.byteLength > 1024 * 1024)
23
+ throw new Error('authorized_keys must have a revision and be at most 1 MiB');
24
+ text = new TextDecoder('utf-8', { fatal: true }).decode(read.data);
25
+ revision = read.revision;
26
+ }
27
+ catch (e) {
28
+ if (!missing(e))
29
+ throw e;
30
+ }
31
+ const publicPart = key.split(' ').slice(0, 2).join(' ');
32
+ if (text.split('\n').some(line => line.trim().split(/\s+/).slice(0, 2).join(' ') === publicPart))
33
+ return;
34
+ try {
35
+ await cell.files.patch({ path, content: text + (text && !text.endsWith('\n') ? '\n' : '') + key + '\n', expectedRevision: revision, mode: '0600' }, { signal });
36
+ return;
37
+ }
38
+ catch (e) {
39
+ if (!(e instanceof ShardfluxApiError && e.status === 409 && e.details?.reason === 'revision_mismatch'))
40
+ throw e;
41
+ }
42
+ }
43
+ throw new Error('authorized_keys kept changing; retry the SSH connection');
44
+ }
45
+ /** Host trust is established over the authenticated files API, before sending any SSH protocol bytes. */
46
+ export async function prepareSsh(ws, ctx, targets, host) {
47
+ if (!ws.ready)
48
+ await ws.wake({ timeoutMs: ctx.wakeTimeoutMs, signal: ctx.signal });
49
+ const cell = ws.cell({ agentLabel: ctx.agentLabel, transitionTimeoutMs: ctx.wakeTimeoutMs });
50
+ const hostKey = await cell.files.readText('/etc/ssh/ssh_host_ed25519_key.pub', { length: 4096 });
51
+ targets.pin(host, hostKey);
52
+ await installDeviceKey(cell, targets.deviceKey(), ctx.signal);
53
+ return cell;
54
+ }
55
+ async function openApp(target, env) {
56
+ const child = spawn('open', ['-b', target === 'claude-desktop' ? 'com.anthropic.claudefordesktop' : 'com.openai.codex'], { env, stdio: 'ignore' });
57
+ await new Promise((resolve, reject) => {
58
+ const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('app launcher timed out')); }, 10_000);
59
+ child.once('error', e => { clearTimeout(timer); reject(e); });
60
+ child.once('exit', code => { clearTimeout(timer); if (code === 0)
61
+ resolve();
62
+ else
63
+ reject(new Error(`app launcher exited ${code}`)); });
64
+ });
65
+ }
66
+ export function proxyStream(io) {
67
+ const stdout = io.stdout;
68
+ const source = Readable.from(io.stdin, { objectMode: false });
69
+ let blocked = false;
70
+ const pump = () => {
71
+ while (!blocked && !stream.destroyed) {
72
+ const bytes = source.read();
73
+ if (bytes === null)
74
+ return;
75
+ blocked = !stream.push(bytes);
76
+ }
77
+ };
78
+ const stream = new Duplex({
79
+ allowHalfOpen: true,
80
+ read() { blocked = false; pump(); },
81
+ write(bytes, _encoding, done) {
82
+ try {
83
+ if (stdout.write(bytes))
84
+ done();
85
+ else if (stdout.once)
86
+ stdout.once('drain', done);
87
+ else
88
+ done(new Error('stdout cannot signal backpressure'));
89
+ }
90
+ catch (e) {
91
+ done(e);
92
+ }
93
+ },
94
+ destroy(error, done) { source.destroy(); done(error); },
95
+ });
96
+ source.on('readable', pump);
97
+ source.once('end', () => stream.push(null));
98
+ source.once('error', (error) => stream.destroy(error));
99
+ return stream;
100
+ }
101
+ const openTarget = (target) => async (ctx, values, [ref = 'main']) => {
102
+ if (target !== 'web' && !installedTargets(ctx.io.env).targets.includes(target))
103
+ throw new UsageError(`${target} is not installed on this machine`);
104
+ let ws = await resolveWorkspace(ctx, keyOf(ref));
105
+ if (values.reachable !== undefined)
106
+ ws = await ctx.cloud().workspaces.open({ key: ws.key, ...agentReachability(values), wait: false });
107
+ if (target !== 'codex-app')
108
+ await ensureCloudClaude(ctx, ws);
109
+ if (target === 'web') {
110
+ let cursor;
111
+ let url;
112
+ do {
113
+ const page = await ctx.cloud().agents.list({ limit: 200, ...(cursor ? { cursor } : {}) });
114
+ url = page.data.find(a => a.workspace_id === ws.id && a.app === 'claude' && a.state !== 'exited' && a.remote_control_url)?.remote_control_url;
115
+ cursor = page.nextCursor ?? undefined;
116
+ } while (!url && cursor && !ctx.signal.aborted);
117
+ if (!url)
118
+ throw new UsageError(`${ws.key} has no active Remote Control URL; start Claude with Remote Control first`);
119
+ const parsed = new URL(url);
120
+ if (parsed.protocol !== 'https:' || parsed.hostname !== 'claude.ai' || !parsed.pathname.startsWith('/code/') || parsed.username || parsed.password)
121
+ throw new Error('agent returned an invalid Claude Remote Control URL');
122
+ if (!ctx.json && !openUrl(url, ctx.io.env))
123
+ throw new Error(`could not open ${url}`);
124
+ out(ctx, `${url}\n`, { target, workspace: ws.data, url });
125
+ return 0;
126
+ }
127
+ const targets = new AgentTargets(ctx.io.env, ctx.store.dir);
128
+ const entry = targets.configure(ws.key, ctx.apiUrl, [target]).find(e => e.workspace === ws.key);
129
+ const instructions = target === 'claude-desktop'
130
+ ? `Restart Claude Desktop to load the connection, then choose Shardflux · ${entry.project} · ${entry.agent} in Code's environment dropdown and open ${projectWorkspacePath({ slug: entry.project })}.`
131
+ : `Restart Codex to import ${entry.host} and its remote project. If it has already been imported, enable ${entry.host} in Settings → Connections → SSH.`;
132
+ if (!ctx.json)
133
+ await openApp(target, ctx.io.env);
134
+ out(ctx, `${instructions}\n`, { target, workspace: ws.data, host: entry.host, instructions });
135
+ return 0;
136
+ };
137
+ export const TARGET_HANDLERS = {
138
+ 'agents targets'(ctx, values, [ref]) {
139
+ const detected = installedTargets(ctx.io.env);
140
+ const targets = new AgentTargets(ctx.io.env, ctx.store.dir);
141
+ const entries = ref || values.remove === true
142
+ ? targets.configure(ref ? keyOf(ref) : undefined, ctx.apiUrl, detected.targets.filter((t) => t === 'claude-desktop' || t === 'codex-app'), values.remove === true)
143
+ : [];
144
+ out(ctx, `${detected.clients.join(', ') || 'No installed agent clients'}\nOpen targets: ${detected.targets.join(', ')}\n${entries.map(e => e.host).join('\n')}${entries.length ? '\n' : ''}`, { ...detected, entries });
145
+ return Promise.resolve(0);
146
+ },
147
+ 'agents open claude-desktop': openTarget('claude-desktop'),
148
+ 'agents open codex-app': openTarget('codex-app'),
149
+ 'agents open web': openTarget('web'),
150
+ async 'ssh-proxy'(ctx, _values, [host]) {
151
+ if (ctx.json || !host)
152
+ throw new UsageError('ssh-proxy requires a managed host and binary standard IO');
153
+ const targets = new AgentTargets(ctx.io.env, ctx.store.dir), entry = targets.entry(host, ctx.apiUrl);
154
+ const ws = await resolveWorkspace(ctx, entry.workspace);
155
+ const cell = await prepareSsh(ws, ctx, targets, host);
156
+ const stream = proxyStream(ctx.io);
157
+ try {
158
+ const tunnel = await new WorkspaceTunnels(() => cell, () => ws.grantedTools).forward({ remotePort: 22, stream, signal: ctx.signal });
159
+ try {
160
+ await tunnel.closed;
161
+ }
162
+ finally {
163
+ await tunnel.close();
164
+ }
165
+ }
166
+ finally {
167
+ stream.destroy();
168
+ }
169
+ return 0;
170
+ },
171
+ };
@@ -0,0 +1,33 @@
1
+ export type DesktopTarget = 'claude-desktop' | 'codex-app';
2
+ export interface TargetEntry {
3
+ host: string;
4
+ workspace: string;
5
+ api_url: string;
6
+ project: string;
7
+ agent: string;
8
+ targets: DesktopTarget[];
9
+ }
10
+ export declare function targetIdentity(key: string): Pick<TargetEntry, 'host' | 'workspace' | 'project' | 'agent'>;
11
+ /** Replace only a marked block, retaining every byte outside it. Put Includes before user Host blocks. */
12
+ export declare function managedBlock(text: string, body: string): string;
13
+ export declare function installedTargets(env: Record<string, string | undefined>, platform?: NodeJS.Platform): {
14
+ targets: string[];
15
+ clients: string[];
16
+ };
17
+ /** All paths are supplied by the invocation; tests use a temporary HOME, never the owner's app stores. */
18
+ export declare class AgentTargets {
19
+ #private;
20
+ readonly home: string;
21
+ readonly dir: string;
22
+ readonly identityFile: string;
23
+ readonly knownHosts: string;
24
+ readonly sshFile: string;
25
+ readonly claudeFile: string;
26
+ readonly codexFile: string;
27
+ readonly manifestFile: string;
28
+ constructor(env: Record<string, string | undefined>, dir: string);
29
+ entry(host: string, apiUrl: string): TargetEntry;
30
+ deviceKey(): string;
31
+ pin(host: string, publicKey: string): void;
32
+ configure(key: string | undefined, apiUrl: string, targets: DesktopTarget[], remove?: boolean): TargetEntry[];
33
+ }