@shardflux/cli 0.13.6 → 0.14.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 (64) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +44 -4
  3. package/dist/acp-catalog.d.ts +7 -0
  4. package/dist/acp-catalog.js +40 -0
  5. package/dist/acp-journal.d.ts +18 -0
  6. package/dist/acp-journal.js +232 -0
  7. package/dist/acp-models.d.ts +36 -0
  8. package/dist/acp-models.js +161 -0
  9. package/dist/acp-rpc.d.ts +41 -0
  10. package/dist/acp-rpc.js +115 -0
  11. package/dist/acp.d.ts +29 -0
  12. package/dist/acp.js +464 -0
  13. package/dist/agent-commands.d.ts +10 -2
  14. package/dist/agent-commands.js +37 -18
  15. package/dist/agent-machine.d.ts +6 -0
  16. package/dist/agent-machine.js +93 -0
  17. package/dist/agent-repository.d.ts +2 -0
  18. package/dist/agent-repository.js +42 -31
  19. package/dist/agent-state.d.ts +3 -0
  20. package/dist/agent-state.js +33 -12
  21. package/dist/agent-target-commands.d.ts +4 -1
  22. package/dist/agent-target-commands.js +110 -40
  23. package/dist/agent-targets.d.ts +6 -4
  24. package/dist/agent-targets.js +72 -31
  25. package/dist/args.d.ts +1 -1
  26. package/dist/args.js +53 -14
  27. package/dist/command-center-actions.js +26 -3
  28. package/dist/command-center.js +20 -4
  29. package/dist/conversation-commands.d.ts +3 -0
  30. package/dist/conversation-commands.js +36 -0
  31. package/dist/conversation-local-resume.d.ts +8 -0
  32. package/dist/conversation-local-resume.js +56 -0
  33. package/dist/conversation-local.d.ts +7 -0
  34. package/dist/conversation-local.js +61 -0
  35. package/dist/conversation-router.d.ts +12 -0
  36. package/dist/conversation-router.js +92 -0
  37. package/dist/conversation-stash.d.ts +11 -0
  38. package/dist/conversation-stash.js +158 -0
  39. package/dist/conversation-terminal.d.ts +3 -0
  40. package/dist/conversation-terminal.js +74 -0
  41. package/dist/conversation-worktree.d.ts +4 -0
  42. package/dist/conversation-worktree.js +22 -0
  43. package/dist/conversations.d.ts +53 -0
  44. package/dist/conversations.js +119 -0
  45. package/dist/device-bridge.d.ts +80 -0
  46. package/dist/device-bridge.js +409 -0
  47. package/dist/device-helper.d.ts +3 -0
  48. package/dist/device-helper.js +104 -0
  49. package/dist/errors.js +5 -0
  50. package/dist/format.js +3 -2
  51. package/dist/git-remote-bin.js +3 -1
  52. package/dist/git-remote.d.ts +3 -1
  53. package/dist/git-remote.js +2 -1
  54. package/dist/handlers.js +3 -1
  55. package/dist/main.js +15 -3
  56. package/dist/project-sync.d.ts +5 -1
  57. package/dist/project-sync.js +7 -6
  58. package/dist/t3-provider.d.ts +6 -0
  59. package/dist/t3-provider.js +99 -0
  60. package/dist/t3-runtime.d.ts +6 -0
  61. package/dist/t3-runtime.js +63 -0
  62. package/dist/target-lock.d.ts +3 -0
  63. package/dist/target-lock.js +143 -0
  64. package/package.json +3 -3
@@ -9,13 +9,16 @@ import { join, relative } from 'node:path';
9
9
  import { projectIdentity } from "./project-identity.js";
10
10
  import { projectWorkspacePath } from "./agent-repository.js";
11
11
  import { UsageError } from "./errors.js";
12
+ import { prepareDesktopRouter } from "./conversation-router.js";
12
13
  import { openUrl } from "./browser.js";
14
+ import { agentMachineName, prepareAgentMachine } from "./agent-machine.js";
15
+ import { prewarmT3 } from "./t3-runtime.js";
16
+ import { AgentStateStore } from "./agent-state.js";
13
17
  const missing = (e) => e instanceof ShardfluxApiError && e.status === 404;
14
18
  const keyOf = (ref) => ref.includes('/') ? ref : `${projectIdentity('./').slug}/${ref}`;
15
19
  /** Optimistic file replacement preserves keys installed by other devices connecting at the same time. */
16
20
  export async function installDeviceKey(cell, key, signal) {
17
21
  const path = '/home/user/.ssh/authorized_keys';
18
- await cell.files.mkdir('/home/user/.ssh', { mode: '0700', parents: true });
19
22
  for (let attempt = 0; attempt < 4; attempt++) {
20
23
  let text = '', revision = 'absent';
21
24
  try {
@@ -30,8 +33,11 @@ export async function installDeviceKey(cell, key, signal) {
30
33
  throw e;
31
34
  }
32
35
  const publicPart = key.split(' ').slice(0, 2).join(' ');
36
+ // A reconnect finds its key with one read; only a first key creates the directory.
33
37
  if (text.split('\n').some(line => line.trim().split(/\s+/).slice(0, 2).join(' ') === publicPart))
34
38
  return;
39
+ if (revision === 'absent')
40
+ await cell.files.mkdir('/home/user/.ssh', { mode: '0700', parents: true });
35
41
  try {
36
42
  await cell.files.patch({ path, content: text + (text && !text.endsWith('\n') ? '\n' : '') + key + '\n', expectedRevision: revision, mode: '0600' }, { signal });
37
43
  return;
@@ -43,18 +49,26 @@ export async function installDeviceKey(cell, key, signal) {
43
49
  }
44
50
  throw new Error('authorized_keys kept changing; retry the SSH connection');
45
51
  }
46
- /** Host trust is established over the authenticated files API, before sending any SSH protocol bytes. */
47
- export async function prepareSsh(ws, ctx, targets, host) {
52
+ async function sshCell(ws, ctx) {
48
53
  if (!ws.ready)
49
54
  await ws.wake({ timeoutMs: ctx.wakeTimeoutMs, signal: ctx.signal });
50
- const cell = ws.cell({ agentLabel: ctx.agentLabel, transitionTimeoutMs: ctx.wakeTimeoutMs });
51
- const hostKey = await cell.files.readText('/etc/ssh/ssh_host_ed25519_key.pub', { length: 4096 });
52
- targets.pin(host, hostKey);
53
- await installDeviceKey(cell, targets.deviceKey(), ctx.signal);
55
+ if (ws.labels?.t3_environment_id)
56
+ await prepareAgentMachine(ctx, ws);
57
+ return ws.cell({ agentLabel: ctx.agentLabel, transitionTimeoutMs: ctx.wakeTimeoutMs });
58
+ }
59
+ /** Host trust over the authenticated files API: the host key pin and this device's key, read together. */
60
+ async function trustSsh(cell, targets, host, signal) {
61
+ const [hostKey] = await Promise.all([cell.files.readText('/etc/ssh/ssh_host_ed25519_key.pub', { length: 4096 }), installDeviceKey(cell, targets.deviceKey(), signal)]);
62
+ await targets.pin(host, hostKey, signal);
63
+ }
64
+ /** Host trust is established over the authenticated files API, before sending any SSH protocol bytes. */
65
+ export async function prepareSsh(ws, ctx, targets, host) {
66
+ const cell = await sshCell(ws, ctx);
67
+ await trustSsh(cell, targets, host, ctx.signal);
54
68
  return cell;
55
69
  }
56
70
  async function openApp(target, env) {
57
- const child = spawn('open', ['-b', target === 'claude-desktop' ? 'com.anthropic.claudefordesktop' : 'com.openai.codex'], { env, stdio: 'ignore' });
71
+ const child = spawn('open', ['-b', { 'claude-desktop': 'com.anthropic.claudefordesktop', 'codex-app': 'com.openai.codex', t3: 'com.t3tools.t3code' }[target]], { env, stdio: 'ignore' });
58
72
  await new Promise((resolve, reject) => {
59
73
  const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('app launcher timed out')); }, 10_000);
60
74
  child.once('error', e => { clearTimeout(timer); reject(e); });
@@ -64,10 +78,10 @@ async function openApp(target, env) {
64
78
  reject(new Error(`app launcher exited ${code}`)); });
65
79
  });
66
80
  }
67
- export function proxyStream(io) {
81
+ export function proxyStream(io, ended, outputGate) {
68
82
  const stdout = io.stdout;
69
83
  const source = Readable.from(io.stdin, { objectMode: false });
70
- let blocked = false;
84
+ let blocked = false, gate = outputGate;
71
85
  const pump = () => {
72
86
  while (!blocked && !stream.destroyed) {
73
87
  const bytes = source.read();
@@ -80,27 +94,43 @@ export function proxyStream(io) {
80
94
  allowHalfOpen: true,
81
95
  read() { blocked = false; pump(); },
82
96
  write(bytes, _encoding, done) {
83
- try {
84
- if (stdout.write(bytes))
85
- done();
86
- else if (stdout.once)
87
- stdout.once('drain', done);
88
- else
89
- done(new Error('stdout cannot signal backpressure'));
90
- }
91
- catch (e) {
92
- done(e);
93
- }
97
+ const send = () => {
98
+ try {
99
+ if (stdout.write(bytes))
100
+ done();
101
+ else if (stdout.once)
102
+ stdout.once('drain', done);
103
+ else
104
+ done(new Error('stdout cannot signal backpressure'));
105
+ }
106
+ catch (e) {
107
+ done(e);
108
+ }
109
+ };
110
+ // No guest byte reaches the SSH client before the gate opens (its host key pinned).
111
+ if (gate)
112
+ void gate.then(() => { gate = undefined; send(); }, (e) => done(e instanceof Error ? e : new Error(String(e))));
113
+ else
114
+ send();
94
115
  },
95
- destroy(error, done) { source.destroy(); done(error); },
116
+ // A guest can close before SSH authenticates (e.g. sshd MaxStartups).
117
+ // Once output drains, end the proxy so SSH sees EOF instead of waiting
118
+ // for input EOF from the client that is itself waiting for us.
119
+ final(done) { done(); ended?.(); },
120
+ destroy(error, done) { source.destroy(); stdout.off?.('error', outputError); stdout.off?.('close', outputClosed); done(error); },
96
121
  });
122
+ const outputError = (error) => stream.destroy(error);
123
+ const outputClosed = () => stream.destroy(new Error('SSH client output closed'));
124
+ stdout.once?.('error', outputError);
125
+ stdout.once?.('close', outputClosed);
97
126
  source.on('readable', pump);
98
- source.once('end', () => stream.push(null));
127
+ source.once('end', () => { stream.push(null); ended?.(); });
99
128
  source.once('error', (error) => stream.destroy(error));
100
129
  return stream;
101
130
  }
102
131
  const openTarget = (target) => async (ctx, values, [ref = 'main']) => {
103
- if (target !== 'web' && !installedTargets(ctx.io.env).targets.includes(target))
132
+ const detected = installedTargets(ctx.io.env);
133
+ if (target !== 'web' && !detected.targets.includes(target))
104
134
  throw new UsageError(`${target} is not installed on this machine`);
105
135
  let ws = await resolveWorkspace(ctx, keyOf(ref));
106
136
  if (values.reachable !== undefined)
@@ -125,37 +155,57 @@ const openTarget = (target) => async (ctx, values, [ref = 'main']) => {
125
155
  out(ctx, `${url}\n`, { target, workspace: ws.data, url });
126
156
  return 0;
127
157
  }
158
+ return openDesktopWorkspace(ctx, ws, target, detected.versions.t3);
159
+ };
160
+ export async function openDesktopWorkspace(ctx, ws, target, t3Version, prewarmed = false) {
161
+ if (target !== 't3')
162
+ ws = await prepareDesktopRouter(ctx, ws);
163
+ await prepareAgentMachine(ctx, ws);
164
+ if (target === 't3' && t3Version && !prewarmed)
165
+ await queueT3Prewarm(ctx, ws, t3Version);
128
166
  const targets = new AgentTargets(ctx.io.env, ctx.store.dir);
129
- const entry = targets.configure(ws.key, ctx.apiUrl, [target]).find(e => e.workspace === ws.key);
130
- const instructions = target === 'claude-desktop'
131
- ? `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 })}.`
132
- : `Restart Codex to import ${entry.host} and its remote project. If it has already been imported, enable ${entry.host} in Settings → Connections → SSH.`;
167
+ const entry = (await (ws.labels.shard_router === 'project' ? targets.configureProject(ws.key, ctx.apiUrl, [target], ctx.signal) : targets.configure(ws.key, ctx.apiUrl, [target], false, ctx.signal))).find(e => e.workspace === ws.key);
168
+ const instructions = target === 't3'
169
+ ? `Add environment → SSH → ${entry.host} (${agentMachineName(ws.key)}), then open ${projectWorkspacePath({ slug: entry.project })}.`
170
+ : target === 'claude-desktop'
171
+ ? `Restart Claude Desktop to load the connection, then choose ${targetLabel(entry)} in Code's environment dropdown and open ${projectWorkspacePath({ slug: entry.project })}.`
172
+ : `Restart Codex to import ${entry.host} and its remote project. If it has already been imported, enable ${entry.host} in Settings → Connections → SSH.`;
133
173
  if (!ctx.json)
134
174
  await openApp(target, ctx.io.env);
175
+ new AgentStateStore(ctx.store.dir).saveTarget(ws.labels.repo_remote || ws.labels.shard_project || entry.project, target);
135
176
  out(ctx, `${instructions}\n`, { target, workspace: ws.data, host: entry.host, instructions });
136
177
  return 0;
137
- };
178
+ }
179
+ export async function queueT3Prewarm(ctx, ws, version) {
180
+ try {
181
+ await prewarmT3(ctx, ws, version);
182
+ }
183
+ catch (error) {
184
+ ctx.io.stderr.write(`T3 runtime will install on connect: ${error instanceof Error ? error.message : String(error)}\n`);
185
+ }
186
+ }
138
187
  const tilde = (targets, file) => file.startsWith(`${targets.home}/`) ? `~/${relative(targets.home, file)}` : file;
139
- const targetLabel = (e) => `[SF] ${e.project} · ${e.agent}`;
188
+ const targetLabel = (e) => e.agent === 'router' ? `Shardflux · ${e.project}` : `[SF] ${e.project} · ${e.agent}`;
140
189
  /** Where each managed connection of an entry lives: the SSH host block (and its Include), then each desktop app's file. */
141
- function configuredTargets(targets, e) {
190
+ function configuredTargets(targets, e, t3 = false) {
142
191
  return [
143
192
  { target: 'ssh', label: 'SSH', file: targets.sshFile, name: e.host, detail: `(included from ${tilde(targets, join(targets.home, '.ssh', 'config'))}): ssh ${e.host}` },
144
193
  ...(e.targets.includes('claude-desktop') ? [{ target: 'claude-desktop', label: 'Claude Desktop', file: targets.claudeFile, name: targetLabel(e), detail: `(sshConfigs: ${targetLabel(e)}); restart Claude Desktop, then pick it in Code's environment list` }] : []),
145
194
  ...(e.targets.includes('codex-app') ? [{ target: 'codex-app', label: 'Codex app', file: targets.codexFile, name: targetLabel(e), detail: `(remoteConnections: ${targetLabel(e)}); restart Codex to import it` }] : []),
195
+ ...(t3 ? [{ target: 't3', label: 'T3 Code', file: targets.sshFile, name: agentMachineName(e.workspace), detail: `Add environment → SSH → ${e.host}; project ${projectWorkspacePath({ slug: e.project })}` }] : []),
146
196
  ];
147
197
  }
148
198
  function targetFiles(targets, entries) {
149
199
  return [...new Set(entries.flatMap(e => configuredTargets(targets, e).map(c => c.file)))];
150
200
  }
151
201
  export const TARGET_HANDLERS = {
152
- 'agents targets'(ctx, values, [ref]) {
202
+ async 'agents targets'(ctx, values, [ref]) {
153
203
  const detected = installedTargets(ctx.io.env);
154
204
  const targets = new AgentTargets(ctx.io.env, ctx.store.dir);
155
205
  const key = ref ? keyOf(ref) : undefined;
156
- const before = values.remove === true ? targets.configure(undefined, ctx.apiUrl, []) : [];
206
+ const before = values.remove === true ? await targets.configure(undefined, ctx.apiUrl, [], false, ctx.signal) : [];
157
207
  const entries = ref || values.remove === true
158
- ? targets.configure(key, ctx.apiUrl, detected.targets.filter((t) => t === 'claude-desktop' || t === 'codex-app'), values.remove === true)
208
+ ? await targets.configure(key, ctx.apiUrl, detected.targets.filter((t) => t === 'claude-desktop' || t === 'codex-app' || t === 't3'), values.remove === true, ctx.signal)
159
209
  : [];
160
210
  const detection = `${detected.clients.join(', ') || 'No installed agent clients'}\nOpen targets: ${detected.targets.join(', ')}\n`;
161
211
  if (values.remove === true) {
@@ -168,23 +218,43 @@ export const TARGET_HANDLERS = {
168
218
  out(ctx, detection, { ...detected, entries });
169
219
  return Promise.resolve(0);
170
220
  }
171
- const configured = configuredTargets(targets, entry);
221
+ const t3 = detected.targets.includes('t3');
222
+ const configured = configuredTargets(targets, entry, t3);
172
223
  out(ctx, `Configured ${entry.workspace} as SSH host ${entry.host}:\n${configured.map(c => ` ${c.label.padEnd(15)} ${tilde(targets, c.file)}${c.detail ? ` ${c.detail}` : ''}`).join('\n')}\n` +
173
- ['claude-desktop', 'codex-app'].filter(t => !entry.targets.includes(t)).map(t => ` ${(t === 'claude-desktop' ? 'Claude Desktop' : 'Codex app').padEnd(15)} not installed on this computer\n`).join(''), { ...detected, entries, configured: configured.map(({ target, file, name }) => ({ target, file, name })) });
224
+ ['claude-desktop', 'codex-app', 't3'].filter(t => t === 't3' ? !t3 : !entry.targets.includes(t)).map(t => ` ${{ 'claude-desktop': 'Claude Desktop', 'codex-app': 'Codex app', t3: 'T3 Code' }[t].padEnd(15)} not installed on this computer\n`).join(''), { ...detected, entries, configured: configured.map(({ target, file, name }) => ({ target, file, name })) });
174
225
  return Promise.resolve(0);
175
226
  },
176
227
  'agents open claude-desktop': openTarget('claude-desktop'),
177
228
  'agents open codex-app': openTarget('codex-app'),
229
+ 'agents open t3': openTarget('t3'),
178
230
  'agents open web': openTarget('web'),
179
231
  async 'ssh-proxy'(ctx, _values, [host]) {
180
232
  if (ctx.json || !host)
181
233
  throw new UsageError('ssh-proxy requires a managed host and binary standard IO');
182
234
  const targets = new AgentTargets(ctx.io.env, ctx.store.dir), entry = targets.entry(host, ctx.apiUrl);
183
- const ws = await resolveWorkspace(ctx, entry.workspace);
184
- const cell = await prepareSsh(ws, ctx, targets, host);
185
- const stream = proxyStream(ctx.io);
235
+ const controller = new AbortController();
236
+ const signal = AbortSignal.any([ctx.signal, controller.signal]);
237
+ const proxyCtx = { ...ctx, signal };
238
+ let trusted, distrusted;
239
+ const trust = new Promise((resolve, reject) => { trusted = resolve; distrusted = reject; });
240
+ trust.catch(() => { });
241
+ const stream = proxyStream(ctx.io, () => controller.abort(new Error('SSH proxy stream ended')), trust);
242
+ stream.on('error', error => controller.abort(error));
186
243
  try {
187
- const tunnel = await new WorkspaceTunnels(() => cell, () => ws.grantedTools).forward({ remotePort: 22, stream, signal: ctx.signal });
244
+ const ws = await resolveWorkspace(proxyCtx, entry.workspace);
245
+ void ws.hint({ signal }).catch(() => undefined);
246
+ const cell = await sshCell(ws, proxyCtx);
247
+ // Trust and the tunnel start run together; the stream holds guest bytes until the host key is pinned.
248
+ const trusting = trustSsh(cell, targets, host, signal).then(trusted, (e) => { distrusted(e); controller.abort(e); throw e; });
249
+ const [opened, checked] = await Promise.allSettled([new WorkspaceTunnels(() => cell, () => ws.grantedTools).forward({ remotePort: 22, stream, signal, passive: true }), trusting]);
250
+ if (checked.status === 'rejected') {
251
+ if (opened.status === 'fulfilled')
252
+ await opened.value.close().catch(() => { });
253
+ throw checked.reason;
254
+ }
255
+ if (opened.status === 'rejected')
256
+ throw opened.reason;
257
+ const tunnel = opened.value;
188
258
  try {
189
259
  await tunnel.closed;
190
260
  }
@@ -1,4 +1,4 @@
1
- export type DesktopTarget = 'claude-desktop' | 'codex-app';
1
+ export type DesktopTarget = 'claude-desktop' | 'codex-app' | 't3';
2
2
  export interface TargetEntry {
3
3
  host: string;
4
4
  workspace: string;
@@ -10,9 +10,10 @@ export interface TargetEntry {
10
10
  export declare function targetIdentity(key: string): Pick<TargetEntry, 'host' | 'workspace' | 'project' | 'agent'>;
11
11
  /** Replace only a marked block, retaining every byte outside it. Put Includes before user Host blocks. */
12
12
  export declare function managedBlock(text: string, body: string): string;
13
- export declare function installedTargets(env: Record<string, string | undefined>, platform?: NodeJS.Platform): {
13
+ export declare function installedTargets(env: Record<string, string | undefined>, platform?: NodeJS.Platform, appDirs?: string[]): {
14
14
  targets: string[];
15
15
  clients: string[];
16
+ versions: Partial<Record<DesktopTarget, string>>;
16
17
  };
17
18
  /** All paths are supplied by the invocation; tests use a temporary HOME, never the owner's app stores. */
18
19
  export declare class AgentTargets {
@@ -28,6 +29,7 @@ export declare class AgentTargets {
28
29
  constructor(env: Record<string, string | undefined>, dir: string);
29
30
  entry(host: string, apiUrl: string): TargetEntry;
30
31
  deviceKey(): string;
31
- pin(host: string, publicKey: string): void;
32
- configure(key: string | undefined, apiUrl: string, targets: DesktopTarget[], remove?: boolean): TargetEntry[];
32
+ pin(host: string, publicKey: string, signal?: AbortSignal): Promise<void>;
33
+ configure(key: string | undefined, apiUrl: string, targets: DesktopTarget[], remove?: boolean, signal?: AbortSignal): Promise<TargetEntry[]>;
34
+ configureProject(key: string, apiUrl: string, targets: DesktopTarget[], signal?: AbortSignal): Promise<TargetEntry[]>;
33
35
  }
@@ -1,16 +1,20 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import { createHash } from 'node:crypto';
3
- import { accessSync, constants, chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
3
+ import { accessSync, constants, chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync } from 'node:fs';
4
4
  import { homedir } from 'node:os';
5
5
  import { dirname, join, resolve } from 'node:path';
6
6
  import { writePrivateFile } from "./credentials.js";
7
7
  import { UsageError } from "./errors.js";
8
8
  import { projectWorkspacePath } from "./agent-repository.js";
9
+ import { withTargetLock } from "./target-lock.js";
10
+ const PERSISTED_TARGETS = ['claude-desktop', 'codex-app'];
9
11
  const BEGIN = '# BEGIN Shardflux managed targets';
10
12
  const END = '# END Shardflux managed targets';
11
13
  const object = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
12
14
  const shellQuote = (s) => "'" + s.replaceAll("'", "'\\''").replaceAll('%', '%%') + "'";
13
15
  const quote = (s) => '"' + s.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('%', '%%') + '"';
16
+ // Some SSH config readers (T3 Code's host discovery) split Include on whitespace and keep quotes; quote only when needed.
17
+ const includePath = (s) => /^[^\s"'\\#%]+$/.test(s) ? s : quote(s);
14
18
  export function targetIdentity(key) {
15
19
  const parts = key.split('/');
16
20
  if (parts.length !== 2 || parts.some(p => !/^[a-z0-9][a-z0-9._-]{0,95}$/.test(p)))
@@ -29,15 +33,48 @@ export function managedBlock(text, body) {
29
33
  const stop = end + END.length + (text[end + END.length] === '\n' ? 1 : 0);
30
34
  return text.slice(0, start) + block + text.slice(stop);
31
35
  }
32
- export function installedTargets(env, platform = process.platform) {
36
+ export function installedTargets(env, platform = process.platform, appDirs) {
33
37
  const home = env.HOME || homedir();
34
- const hasApp = (name, bundle) => platform === 'darwin' && [join('/Applications', name), join(home, 'Applications', name)].some(path => {
38
+ // SHARDFLUX_APPLICATIONS_DIRS (colon-separated) replaces the scanned app folders, like SHARDFLUX_CONFIG_DIR does for config.
39
+ const dirs = appDirs ?? (env.SHARDFLUX_APPLICATIONS_DIRS !== undefined ? env.SHARDFLUX_APPLICATIONS_DIRS.split(':').filter(Boolean) : ['/Applications', join(home, 'Applications')]);
40
+ const hasApp = (name, bundle) => platform === 'darwin' && dirs.map(dir => join(dir, name)).some(path => {
35
41
  const info = join(path, 'Contents', 'Info.plist');
36
42
  if (!existsSync(info))
37
43
  return false;
38
44
  const result = spawnSync('/usr/libexec/PlistBuddy', ['-c', 'Print :CFBundleIdentifier', info], { input: '', encoding: 'utf8', timeout: 1000 });
39
45
  return result.status === 0 && result.stdout.trim() === bundle;
40
46
  });
47
+ const versions = {};
48
+ let t3 = false;
49
+ if (platform === 'darwin')
50
+ for (const dir of dirs) {
51
+ let names;
52
+ try {
53
+ names = readdirSync(dir);
54
+ }
55
+ catch {
56
+ continue;
57
+ }
58
+ for (const name of names.filter(name => name.endsWith('.app'))) {
59
+ const info = join(dir, name, 'Contents', 'Info.plist');
60
+ // XML and binary plists store this ASCII bundle id verbatim. Avoid a
61
+ // subprocess for every unrelated app on the normal no-T3 path.
62
+ try {
63
+ if (!readFileSync(info).includes('com.t3tools.t3code'))
64
+ continue;
65
+ }
66
+ catch {
67
+ continue;
68
+ }
69
+ const result = spawnSync('/usr/libexec/PlistBuddy', ['-c', 'Print :CFBundleIdentifier', '-c', 'Print :CFBundleShortVersionString', info], { input: '', encoding: 'utf8', timeout: 1000 });
70
+ const [bundle, version] = result.stdout?.trim().split('\n') ?? [];
71
+ if (bundle !== 'com.t3tools.t3code')
72
+ continue;
73
+ t3 = true;
74
+ if (result.status === 0 && version && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version))
75
+ versions.t3 ??= version;
76
+ }
77
+ }
41
78
  const executable = (name) => (env.PATH ?? '').split(':').filter(Boolean).some(p => { try {
42
79
  accessSync(join(p, name), constants.X_OK);
43
80
  return true;
@@ -46,7 +83,9 @@ export function installedTargets(env, platform = process.platform) {
46
83
  return false;
47
84
  } });
48
85
  const clients = [hasApp('Claude.app', 'com.anthropic.claudefordesktop') && 'claude-desktop', (hasApp('Codex.app', 'com.openai.codex') || hasApp('ChatGPT.app', 'com.openai.codex')) && 'codex-app', executable('claude') && 'claude', executable('codex') && 'codex'].filter((v) => !!v);
49
- return { targets: [...(platform === 'darwin' ? clients.filter(c => c.endsWith('-app') || c === 'claude-desktop') : []), 'web'], clients };
86
+ if (t3)
87
+ clients.push('t3');
88
+ return { targets: [...(platform === 'darwin' ? clients.filter(c => c.endsWith('-app') || c === 'claude-desktop' || c === 't3') : []), 'web'], clients, versions };
50
89
  }
51
90
  /** All paths are supplied by the invocation; tests use a temporary HOME, never the owner's app stores. */
52
91
  export class AgentTargets {
@@ -107,11 +146,16 @@ export class AgentTargets {
107
146
  if (Object.values(doc.created).some(v => !Array.isArray(v) || v.some(k => typeof k !== 'string')))
108
147
  throw new UsageError('invalid target ownership metadata');
109
148
  for (const e of doc.entries) {
110
- if (!object(e) || typeof e.workspace !== 'string' || typeof e.api_url !== 'string' || /[\r\n\0]/.test(e.api_url) || !Array.isArray(e.targets) || e.targets.some(t => t !== 'claude-desktop' && t !== 'codex-app'))
149
+ if (!object(e) || typeof e.workspace !== 'string' || typeof e.api_url !== 'string' || /[\r\n\0]/.test(e.api_url) || !Array.isArray(e.targets) || e.targets.some(t => typeof t !== 'string'))
111
150
  throw new UsageError('invalid Shardflux target entry');
151
+ // Forward compatible: a newer CLI may record targets this one does not know; they write no files here.
152
+ e.targets = e.targets.filter(t => PERSISTED_TARGETS.includes(t));
112
153
  const identity = targetIdentity(e.workspace);
113
154
  if (identity.host !== e.host || identity.project !== e.project || identity.agent !== e.agent)
114
155
  throw new UsageError('invalid Shardflux target identity');
156
+ // Earlier CVE entries used this optional flag. Identity now comes solely
157
+ // from the router workspace key; remove the obsolete flag on rewrite.
158
+ delete e.project_router;
115
159
  }
116
160
  return doc;
117
161
  }
@@ -139,8 +183,8 @@ export class AgentTargets {
139
183
  const key = r.stdout.trim().split(/\s+/).slice(0, 2).join(' ');
140
184
  return `${key} shardflux-device-${createHash('sha256').update(key).digest('hex').slice(0, 16)}`;
141
185
  }
142
- pin(host, publicKey) {
143
- this.#locked(() => this.#pin(host, publicKey));
186
+ pin(host, publicKey, signal) {
187
+ return withTargetLock(this.dir, () => this.#pin(host, publicKey), signal);
144
188
  }
145
189
  #pin(host, publicKey) {
146
190
  if (!/^shardflux-[a-z0-9._-]+$/.test(host) || !/^ssh-ed25519 [A-Za-z0-9+/=]+(?: [^\r\n]*)?$/.test(publicKey.trim()))
@@ -156,28 +200,19 @@ export class AgentTargets {
156
200
  if (!previous.length)
157
201
  writePrivateFile(this.dir, this.knownHosts, text + (text && !text.endsWith('\n') ? '\n' : '') + `${host} ${key}\n`);
158
202
  }
159
- configure(key, apiUrl, targets, remove = false) {
160
- return this.#locked(() => this.#configure(key, apiUrl, targets, remove));
203
+ configure(key, apiUrl, targets, remove = false, signal) {
204
+ return withTargetLock(this.dir, () => this.#configure(key, apiUrl, targets, remove), signal);
161
205
  }
162
- #locked(work) {
163
- mkdirSync(this.dir, { recursive: true, mode: 0o700 });
164
- const lock = join(this.dir, 'targets.lock');
165
- try {
166
- mkdirSync(lock, { mode: 0o700 });
167
- }
168
- catch (e) {
169
- if (e.code === 'EEXIST')
170
- throw new UsageError('another target update holds targets.lock; retry after it finishes');
171
- throw e;
172
- }
173
- try {
174
- return work();
175
- }
176
- finally {
177
- rmSync(lock, { recursive: true });
178
- }
206
+ configureProject(key, apiUrl, targets, signal) {
207
+ return withTargetLock(this.dir, () => {
208
+ const identity = targetIdentity(key);
209
+ if (identity.agent !== 'router')
210
+ throw new UsageError('Project targets require the managed router workspace.');
211
+ const previous = this.#manifest().entries.filter(e => e.project === identity.project && e.api_url === apiUrl).flatMap(e => e.targets);
212
+ return this.#configure(key, apiUrl, [...new Set([...previous, ...targets])], false, true);
213
+ }, signal);
179
214
  }
180
- #configure(key, apiUrl, targets, remove) {
215
+ #configure(key, apiUrl, targets, remove, projectTarget = false) {
181
216
  const url = new URL(apiUrl);
182
217
  if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || /[\r\n\0]/.test(apiUrl))
183
218
  throw new UsageError('invalid target API URL');
@@ -188,8 +223,11 @@ export class AgentTargets {
188
223
  const existing = old.find(e => e.host === identity?.host);
189
224
  if (existing && (existing.workspace !== key || existing.api_url !== apiUrl))
190
225
  throw new UsageError('SSH alias already belongs to another workspace or API URL');
191
- const added = identity ? { ...identity, api_url: apiUrl, targets: [...new Set([...(existing?.targets ?? []), ...targets])] } : undefined;
192
- const entries = remove ? old.filter(e => identity ? e.host !== identity.host : e.api_url !== apiUrl) : existing ? old.map(e => e === existing ? added : e) : [...old, added];
226
+ // Only targets that own a config file are recorded: published CLIs reject any other value in this manifest.
227
+ const added = identity ? { ...identity, api_url: apiUrl, targets: [...new Set([...(existing?.targets ?? []), ...targets.filter((t) => PERSISTED_TARGETS.includes(t))])] } : undefined;
228
+ let entries = remove ? old.filter(e => identity ? e.host !== identity.host : e.api_url !== apiUrl) : existing ? old.map(e => e === existing ? added : e) : [...old, added];
229
+ if (projectTarget)
230
+ entries = entries.map(e => e.host !== added.host && e.project === added.project && e.api_url === apiUrl ? { ...e, targets: [] } : e);
193
231
  const sshConfig = join(this.home, '.ssh', 'config');
194
232
  const writes = new Map();
195
233
  const rememberFile = (file) => {
@@ -208,7 +246,7 @@ export class AgentTargets {
208
246
  const text = this.#text(this.knownHosts);
209
247
  writes.set(this.knownHosts, text.split(/(?<=\n)/).filter(line => !removed.some(e => line.split(/\s+/)[0] === e.host)).join(''));
210
248
  }
211
- writes.set(sshConfig, managedBlock(this.#text(sshConfig), entries.length ? `Include ${quote(this.sshFile)}` : ''));
249
+ writes.set(sshConfig, managedBlock(this.#text(sshConfig), entries.length ? `Include ${includePath(this.sshFile)}` : ''));
212
250
  const blocks = entries.map(e => `Host ${e.host}\n HostName ${e.host}\n ProxyCommand ${this.dir === join(this.home, '.config', 'shardflux') ? '' : `env ${shellQuote(`SHARDFLUX_CONFIG_DIR=${this.dir}`)} `}shard ssh-proxy %n --api-url ${shellQuote(e.api_url)}\n User user\n UserKnownHostsFile ${quote(this.knownHosts)}\n StrictHostKeyChecking yes\n IdentityFile ${quote(this.identityFile)}\n IdentitiesOnly yes`);
213
251
  writes.set(this.sshFile, managedBlock(this.#text(this.sshFile), blocks.join('\n\n')));
214
252
  for (const [file, prop, target] of [[this.claudeFile, 'sshConfigs', 'claude-desktop'], [this.codexFile, 'remoteConnections', 'codex-app']]) {
@@ -229,7 +267,10 @@ export class AgentTargets {
229
267
  manifest.created[file] ??= [];
230
268
  if (!(prop in doc) && !manifest.created[file].includes(prop))
231
269
  manifest.created[file].push(prop);
232
- doc[prop] = [...kept, ...entries.filter(e => e.targets.includes(target)).map(e => target === 'claude-desktop' ? { id: e.host, name: `[SF] ${e.project} · ${e.agent}`, sshHost: e.host, startDirectory: projectWorkspacePath({ slug: e.project }) } : { sshAlias: e.host, projects: [{ remotePath: projectWorkspacePath({ slug: e.project }), label: `[SF] ${e.project} · ${e.agent}` }] })];
270
+ doc[prop] = [...kept, ...entries.filter(e => e.targets.includes(target)).map(e => {
271
+ const label = e.agent === 'router' ? `Shardflux · ${e.project}` : `[SF] ${e.project} · ${e.agent}`;
272
+ return target === 'claude-desktop' ? { id: e.host, name: label, sshHost: e.host, startDirectory: projectWorkspacePath({ slug: e.project }) } : { sshAlias: e.host, projects: [{ remotePath: projectWorkspacePath({ slug: e.project }), label }] };
273
+ })];
233
274
  if (!doc[prop].length && manifest.created[file].includes(prop))
234
275
  delete doc[prop];
235
276
  if (file === this.codexFile) {
package/dist/args.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const CLI_VERSION = "0.13.6";
1
+ export declare const CLI_VERSION = "0.14.1";
2
2
  export interface OptionSpec {
3
3
  type: 'string' | 'boolean';
4
4
  short?: string;