@shardflux/cli 0.22.0 → 0.23.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.
- package/CHANGELOG.md +102 -0
- package/README.md +47 -14
- package/dist/acp-journal.js +58 -6
- package/dist/acp-login.d.ts +10 -3
- package/dist/acp-login.js +31 -4
- package/dist/acp-models.d.ts +6 -1
- package/dist/acp-models.js +21 -16
- package/dist/acp.js +60 -7
- package/dist/agent-commands.js +29 -15
- package/dist/agent-target-commands.d.ts +20 -1
- package/dist/agent-target-commands.js +49 -18
- package/dist/agent-targets.js +6 -1
- package/dist/args.d.ts +1 -1
- package/dist/args.js +14 -14
- package/dist/cli-proxy-forward.d.ts +0 -2
- package/dist/cli-proxy-forward.js +2 -9
- package/dist/cli-proxy-hub.d.ts +22 -30
- package/dist/cli-proxy-hub.js +65 -94
- package/dist/cli-proxy.d.ts +12 -38
- package/dist/cli-proxy.js +51 -168
- package/dist/codex-cloud.d.ts +9 -4
- package/dist/codex-cloud.js +47 -14
- package/dist/codex-linux.d.ts +14 -0
- package/dist/codex-linux.js +69 -0
- package/dist/command-center-actions.js +13 -4
- package/dist/command-center-model.d.ts +2 -0
- package/dist/command-center-model.js +6 -1
- package/dist/command-center.js +3 -3
- package/dist/commands.js +2 -2
- package/dist/conversation-commands.js +16 -2
- package/dist/conversation-terminal.js +3 -2
- package/dist/conversations.d.ts +14 -1
- package/dist/conversations.js +61 -7
- package/dist/desktop-account.d.ts +16 -0
- package/dist/desktop-account.js +29 -0
- package/dist/desktop-linux.js +16 -3
- package/dist/errors.d.ts +2 -1
- package/dist/errors.js +5 -2
- package/dist/filesystem-commands.js +25 -3
- package/dist/menubar.d.ts +6 -5
- package/dist/menubar.js +40 -28
- package/dist/move-worktrees.d.ts +13 -7
- package/dist/move-worktrees.js +68 -17
- package/dist/move.js +20 -25
- package/dist/onboarding.js +4 -2
- package/dist/prepared-repository.d.ts +2 -0
- package/dist/prepared-repository.js +1 -1
- package/dist/prepared-repository.py +3 -2
- package/dist/project-preparation.d.ts +14 -0
- package/dist/project-preparation.js +16 -4
- package/dist/project-sync.d.ts +3 -0
- package/dist/project-sync.js +4 -1
- package/dist/t3-launch.d.ts +2 -1
- package/dist/t3-launch.js +11 -2
- package/dist/t3-proxy.d.ts +81 -0
- package/dist/t3-proxy.js +325 -0
- package/dist/terminal-apps.d.ts +11 -0
- package/dist/terminal-apps.js +18 -5
- package/dist/update.d.ts +2 -0
- package/dist/update.js +32 -6
- package/package.json +5 -5
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { TomlTable } from 'smol-toml';
|
|
2
|
+
import type { CellClient, Workspace } from '@shardflux/sdk';
|
|
3
|
+
import type { Ctx } from './commands.js';
|
|
4
|
+
type Env = Record<string, string | undefined>;
|
|
5
|
+
export interface ProxyRoute {
|
|
6
|
+
/** The proxy's base URL as the setup names it. */
|
|
7
|
+
url: string;
|
|
8
|
+
/** It points at this computer. */
|
|
9
|
+
local: boolean;
|
|
10
|
+
/** Its port (the scheme's default when the URL names none). */
|
|
11
|
+
port: number;
|
|
12
|
+
}
|
|
13
|
+
export interface ClaudeProxy extends ProxyRoute {
|
|
14
|
+
/** The `ANTHROPIC_*` variables (and `API_TIMEOUT_MS`) Claude Code runs with. */
|
|
15
|
+
env: Record<string, string>;
|
|
16
|
+
/** The setup brings its own credential (`ANTHROPIC_AUTH_TOKEN` or `ANTHROPIC_API_KEY`). */
|
|
17
|
+
credential: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface CodexProxy extends ProxyRoute {
|
|
20
|
+
/** The variables the provider names (`env_key`, `env_http_headers`), or `OPENAI_BASE_URL` and `OPENAI_API_KEY`. */
|
|
21
|
+
env: Record<string, string>;
|
|
22
|
+
/** `model_provider` and its `model_providers.<id>` table; null when `OPENAI_BASE_URL` names the proxy. */
|
|
23
|
+
config: TomlTable | null;
|
|
24
|
+
}
|
|
25
|
+
export interface T3ProxySetup {
|
|
26
|
+
claude: ClaudeProxy | null;
|
|
27
|
+
codex: CodexProxy | null;
|
|
28
|
+
}
|
|
29
|
+
export interface ProxyReadOptions {
|
|
30
|
+
env: Env;
|
|
31
|
+
/** A file's text, null when it is missing or unreadable (tests replace it). */
|
|
32
|
+
read?: (path: string) => string | null;
|
|
33
|
+
}
|
|
34
|
+
/** A URL whose host is this computer: 127.0.0.0/8, localhost, ::1 (and 0.0.0.0, which a local listener answers on). */
|
|
35
|
+
export declare function onThisComputer(url: string): boolean;
|
|
36
|
+
/** The proxy each app of T3 Code's own providers uses on this computer (null: it talks to its provider directly). */
|
|
37
|
+
export declare function readT3ProxySetup(opts: ProxyReadOptions): T3ProxySetup;
|
|
38
|
+
export interface VmProxy {
|
|
39
|
+
/** Variables for each app's agent in the VM, with proxy URLs as the VM reaches them. */
|
|
40
|
+
env: {
|
|
41
|
+
claude: Record<string, string>;
|
|
42
|
+
codex: Record<string, string>;
|
|
43
|
+
};
|
|
44
|
+
/** Keys for the VM's ~/.codex/config.toml (null: it stays as it is). */
|
|
45
|
+
codexConfig: TomlTable | null;
|
|
46
|
+
/** The VM forwarder's hub info, when a proxy on this computer is reached through the hosted CLIProxyAPI. */
|
|
47
|
+
hubInfo: string | null;
|
|
48
|
+
/** Claude Code goes through the proxy with the setup's own credential: no Shardflux Claude sign-in is needed. */
|
|
49
|
+
claudeCredential: boolean;
|
|
50
|
+
/** Apps whose proxy only this computer reaches: their VM agents use Shardflux's sign-ins. */
|
|
51
|
+
unreachable: Array<'claude' | 'codex'>;
|
|
52
|
+
/** Of those, the apps whose proxy is a CLIProxyAPI address here: VMs reach it once it is in the account (import). */
|
|
53
|
+
importable: Array<'claude' | 'codex'>;
|
|
54
|
+
}
|
|
55
|
+
/** The proxy URL as a VM reaches it: one on this computer becomes the VM forwarder's address (same path). */
|
|
56
|
+
export declare function vmProxyUrl(url: string): string;
|
|
57
|
+
/**
|
|
58
|
+
* What a conversation VM needs to follow the setup. `hubInfo` is the account's VM hub info (vmHubInfoText), if any;
|
|
59
|
+
* `hubPorts` are the ports a CLIProxyAPI on this computer answers on (8317, and the one the import recorded).
|
|
60
|
+
*/
|
|
61
|
+
export declare function vmProxy(setup: T3ProxySetup, hubInfo: string | null, hubPorts?: readonly number[]): VmProxy;
|
|
62
|
+
/** The T3 Code proxy setup as this account's VMs follow it (read again on every call: settings change). */
|
|
63
|
+
export declare function accountVmProxy(ctx: Pick<Ctx, 'io' | 'store' | 'apiUrl' | 'addSecret'>): VmProxy;
|
|
64
|
+
/**
|
|
65
|
+
* Puts what the VM's agents need in place before they start: the forwarder's hub info (0600), and the Codex provider in
|
|
66
|
+
* ~/.codex/config.toml (its other settings kept). Writes only what differs.
|
|
67
|
+
*/
|
|
68
|
+
export declare function prepareVmProxy(cell: Pick<CellClient, 'files'>, plan: VmProxy): Promise<void>;
|
|
69
|
+
/**
|
|
70
|
+
* The VM's own T3 Code (SSH and the T3 app through T3 Connect) runs its Claude Code and Codex providers with the setup's
|
|
71
|
+
* variables, stored as T3 stores them: plain values in its settings.json, credentials in its secret store
|
|
72
|
+
* (`secrets/provider-env-<base64url instance>-<base64url name>.bin`, 0600) with `valueRedacted` in the settings. The
|
|
73
|
+
* instances' other variables and the file's other settings stay. Also places prepareVmProxy's files.
|
|
74
|
+
*/
|
|
75
|
+
export declare function prepareVmT3(cell: Pick<CellClient, 'files'>, plan: VmProxy): Promise<void>;
|
|
76
|
+
/**
|
|
77
|
+
* Before T3 Code opens a project VM (`shard agents new|open --in t3`): its T3 Code follows the user's proxy setup. A
|
|
78
|
+
* proxy on this computer without the hosted copy is named with the import; nothing here fails the open.
|
|
79
|
+
*/
|
|
80
|
+
export declare function followT3ProxyInVm(ctx: Ctx, ws: Workspace): Promise<void>;
|
|
81
|
+
export {};
|
package/dist/t3-proxy.js
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A T3 Code user's proxy setup (CLIProxyAPI, or any Claude- or OpenAI-compatible proxy), read the way T3 Code's own
|
|
3
|
+
* Claude Code and Codex providers run, so a Shardflux conversation in T3 Code sends its model traffic the same way
|
|
4
|
+
* (0.23.0+):
|
|
5
|
+
*
|
|
6
|
+
* - Claude Code: T3's Claude provider (its default instance `claudeAgent`, else another enabled one) runs with its
|
|
7
|
+
* environment variables over T3's own, and Claude Code applies its settings.json `env` over both. `ANTHROPIC_BASE_URL`
|
|
8
|
+
* names the proxy; the `ANTHROPIC_*` variables (base URL, token, models, headers) go to the VM's Claude Code.
|
|
9
|
+
* - Codex: config.toml in the provider's home (`CODEX_HOME`, default ~/.codex): a `model_provider` (the active
|
|
10
|
+
* profile's first) with a `base_url`, else `OPENAI_BASE_URL`. The provider's table and the variables it names
|
|
11
|
+
* (`env_key`, `env_http_headers`) go to the VM's Codex.
|
|
12
|
+
*
|
|
13
|
+
* A CLIProxyAPI on this computer (127.0.0.1, localhost or ::1 at port 8317, or the port the import recorded) is reached
|
|
14
|
+
* from VMs through the account's hosted copy (`shard cli-proxy import`), whose VM forwarder answers on
|
|
15
|
+
* http://127.0.0.1:8317. Another service on this computer stays here (the VM uses Shardflux's sign-ins); any other URL
|
|
16
|
+
* is used as written. T3 Code's default provider instance is the one followed while it is enabled.
|
|
17
|
+
* Sensitive T3 variables come from T3's secret store (`<T3 home>/userdata/secrets/*.bin`); none is ever printed.
|
|
18
|
+
*/
|
|
19
|
+
import { readFileSync } from 'node:fs';
|
|
20
|
+
import { homedir } from 'node:os';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
import { parse, stringify } from 'smol-toml';
|
|
23
|
+
import { ShardfluxApiError } from '@shardflux/sdk';
|
|
24
|
+
import { CLI_PROXY_PORT, readHubInfo, vmHubInfoText } from "./cli-proxy-hub.js";
|
|
25
|
+
import { VM_HUB_INFO_PATH } from "./cli-proxy.js";
|
|
26
|
+
import { t3HomeDir } from "./t3-provider.js";
|
|
27
|
+
import { AgentStateStore } from "./agent-state.js";
|
|
28
|
+
const readText = (path) => { try {
|
|
29
|
+
return readFileSync(path, 'utf8');
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
} };
|
|
34
|
+
const object = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
|
|
35
|
+
const text = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
|
|
36
|
+
const json = (t) => { try {
|
|
37
|
+
return object(JSON.parse(t ?? ''));
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return {};
|
|
41
|
+
} };
|
|
42
|
+
const httpUrl = (v) => { try {
|
|
43
|
+
return ['http:', 'https:'].includes(new URL(v).protocol);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return false;
|
|
47
|
+
} };
|
|
48
|
+
const b64 = (s) => Buffer.from(s, 'utf8').toString('base64url');
|
|
49
|
+
/** A URL whose host is this computer: 127.0.0.0/8, localhost, ::1 (and 0.0.0.0, which a local listener answers on). */
|
|
50
|
+
export function onThisComputer(url) {
|
|
51
|
+
let host;
|
|
52
|
+
try {
|
|
53
|
+
host = new URL(url).hostname.toLowerCase();
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
return host === 'localhost' || host.endsWith('.localhost') || host === '[::1]' || host === '0.0.0.0' || /^127\.\d+\.\d+\.\d+$/.test(host);
|
|
59
|
+
}
|
|
60
|
+
const portOf = (url) => { const u = new URL(url); return Number(u.port) || (u.protocol === 'https:' ? 443 : 80); };
|
|
61
|
+
const route = (url) => ({ url, local: onThisComputer(url), port: portOf(url) });
|
|
62
|
+
/**
|
|
63
|
+
* T3's enabled instances of a driver, its default one (the driver's own id) first. The default runs on the legacy
|
|
64
|
+
* `providers.<driver>` settings when it has no entry of its own. Variables marked sensitive are read from T3's secret
|
|
65
|
+
* store, as T3 does before it starts the provider.
|
|
66
|
+
*/
|
|
67
|
+
function t3Instances(t3, driver, read) {
|
|
68
|
+
const settings = json(read(join(t3, 'userdata', 'settings.json')));
|
|
69
|
+
const all = object(settings.providerInstances), legacy = object(object(settings.providers)[driver]);
|
|
70
|
+
const ids = Object.keys(all).filter((id) => object(all[id]).driver === driver && object(all[id]).enabled !== false);
|
|
71
|
+
if (!(driver in all) && legacy.enabled !== false)
|
|
72
|
+
ids.push(driver);
|
|
73
|
+
ids.sort((a, b) => Number(b === driver) - Number(a === driver));
|
|
74
|
+
return ids.map((id) => {
|
|
75
|
+
const entry = object(all[id]), env = {};
|
|
76
|
+
for (const v of Array.isArray(entry.environment) ? entry.environment : []) {
|
|
77
|
+
const variable = object(v), name = text(variable.name);
|
|
78
|
+
if (!name)
|
|
79
|
+
continue;
|
|
80
|
+
env[name] = variable.sensitive === true && variable.valueRedacted === true
|
|
81
|
+
? read(join(t3, 'userdata', 'secrets', `provider-env-${b64(id)}-${b64(name)}.bin`)) ?? ''
|
|
82
|
+
: typeof variable.value === 'string' ? variable.value : '';
|
|
83
|
+
}
|
|
84
|
+
return { id, env, homePath: text(object(entry.config).homePath) ?? (id === driver ? text(legacy.homePath) : null) };
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
const expand = (path, home) => (path === '~' ? home : path.startsWith('~/') ? join(home, path.slice(2)) : path);
|
|
88
|
+
const pick = (env, names) => {
|
|
89
|
+
const out = {};
|
|
90
|
+
for (const n of names) {
|
|
91
|
+
const v = env[n];
|
|
92
|
+
if (typeof v === 'string' && v !== '')
|
|
93
|
+
out[n] = v;
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
};
|
|
97
|
+
function claudeProxy(instance, base, home, read) {
|
|
98
|
+
const env = { ...base, ...instance?.env };
|
|
99
|
+
const dir = instance?.homePath ? expand(instance.homePath, home) : text(env.CLAUDE_CONFIG_DIR) ? expand(text(env.CLAUDE_CONFIG_DIR), home) : join(home, '.claude');
|
|
100
|
+
// Claude Code applies its settings' env over the environment it was started with.
|
|
101
|
+
for (const [k, v] of Object.entries(object(json(read(join(dir, 'settings.json'))).env)))
|
|
102
|
+
if (typeof v === 'string')
|
|
103
|
+
env[k] = v;
|
|
104
|
+
const url = text(env.ANTHROPIC_BASE_URL);
|
|
105
|
+
if (!url || !httpUrl(url))
|
|
106
|
+
return null;
|
|
107
|
+
const vars = pick(env, Object.keys(env).filter((k) => k.startsWith('ANTHROPIC_') || k === 'API_TIMEOUT_MS'));
|
|
108
|
+
return { ...route(url), env: vars, credential: Boolean(vars.ANTHROPIC_AUTH_TOKEN || vars.ANTHROPIC_API_KEY) };
|
|
109
|
+
}
|
|
110
|
+
function codexProxy(instance, base, home, read) {
|
|
111
|
+
const env = { ...base, ...instance?.env };
|
|
112
|
+
const dir = instance?.homePath ? expand(instance.homePath, home) : text(env.CODEX_HOME) ? expand(text(env.CODEX_HOME), home) : join(home, '.codex');
|
|
113
|
+
let config = {};
|
|
114
|
+
try {
|
|
115
|
+
config = parse(read(join(dir, 'config.toml')) ?? '');
|
|
116
|
+
}
|
|
117
|
+
catch { /* not TOML: Codex refuses it too */ }
|
|
118
|
+
const profile = object(object(config.profiles)[text(config.profile) ?? '']);
|
|
119
|
+
const id = text(profile.model_provider) ?? text(config.model_provider);
|
|
120
|
+
const provider = id ? object(object(config.model_providers)[id]) : {};
|
|
121
|
+
const url = text(provider.base_url);
|
|
122
|
+
if (id && id !== 'openai' && url && httpUrl(url)) {
|
|
123
|
+
const names = [text(provider.env_key), ...Object.values(object(provider.env_http_headers)).map(text)].filter((n) => n !== null);
|
|
124
|
+
return { ...route(url), env: pick(env, names), config: { model_provider: id, model_providers: { [id]: provider } } };
|
|
125
|
+
}
|
|
126
|
+
const openai = text(env.OPENAI_BASE_URL);
|
|
127
|
+
if (openai && httpUrl(openai))
|
|
128
|
+
return { ...route(openai), env: pick(env, ['OPENAI_BASE_URL', 'OPENAI_API_KEY']), config: null };
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
/** The proxy each app of T3 Code's own providers uses on this computer (null: it talks to its provider directly). */
|
|
132
|
+
export function readT3ProxySetup(opts) {
|
|
133
|
+
const read = opts.read ?? readText, home = opts.env.HOME || homedir(), t3 = t3HomeDir(opts.env);
|
|
134
|
+
const first = (driver, fn) => {
|
|
135
|
+
// T3 Code's default instance while it is enabled; otherwise its other enabled instances, the first with a proxy.
|
|
136
|
+
const list = t3Instances(t3, driver, read);
|
|
137
|
+
for (const i of list[0]?.id === driver ? [list[0]] : list.length ? list : [null]) {
|
|
138
|
+
const found = fn(i);
|
|
139
|
+
if (found)
|
|
140
|
+
return found;
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
};
|
|
144
|
+
return {
|
|
145
|
+
claude: first('claudeAgent', (i) => claudeProxy(i, opts.env, home, read)),
|
|
146
|
+
codex: first('codex', (i) => codexProxy(i, opts.env, home, read)),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
// ---- the VM side ---------------------------------------------------------------------------------------------------
|
|
150
|
+
const VM_CODEX_CONFIG = '/home/user/.codex/config.toml';
|
|
151
|
+
/** The proxy URL as a VM reaches it: one on this computer becomes the VM forwarder's address (same path). */
|
|
152
|
+
export function vmProxyUrl(url) {
|
|
153
|
+
const u = new URL(url);
|
|
154
|
+
return `http://127.0.0.1:${CLI_PROXY_PORT}${u.pathname === '/' && !url.endsWith('/') ? '' : u.pathname}${u.search}`;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* What a conversation VM needs to follow the setup. `hubInfo` is the account's VM hub info (vmHubInfoText), if any;
|
|
158
|
+
* `hubPorts` are the ports a CLIProxyAPI on this computer answers on (8317, and the one the import recorded).
|
|
159
|
+
*/
|
|
160
|
+
export function vmProxy(setup, hubInfo, hubPorts = [CLI_PROXY_PORT]) {
|
|
161
|
+
const out = { env: { claude: {}, codex: {} }, codexConfig: null, hubInfo: null, claudeCredential: false, unreachable: [], importable: [] };
|
|
162
|
+
const reach = (app, r) => {
|
|
163
|
+
if (!r.local)
|
|
164
|
+
return r.url;
|
|
165
|
+
if (!hubPorts.includes(r.port)) {
|
|
166
|
+
out.unreachable.push(app);
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
if (!hubInfo) {
|
|
170
|
+
out.unreachable.push(app);
|
|
171
|
+
out.importable.push(app);
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
out.hubInfo = hubInfo;
|
|
175
|
+
return vmProxyUrl(r.url);
|
|
176
|
+
};
|
|
177
|
+
if (setup.claude) {
|
|
178
|
+
const url = reach('claude', setup.claude);
|
|
179
|
+
if (url) {
|
|
180
|
+
out.env.claude = { ...setup.claude.env, ANTHROPIC_BASE_URL: url };
|
|
181
|
+
out.claudeCredential = setup.claude.credential;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (setup.codex) {
|
|
185
|
+
const url = reach('codex', setup.codex);
|
|
186
|
+
if (url && setup.codex.config) {
|
|
187
|
+
const id = setup.codex.config.model_provider;
|
|
188
|
+
const provider = object(object(setup.codex.config.model_providers)[id]);
|
|
189
|
+
out.codexConfig = { model_provider: id, model_providers: { [id]: { ...provider, base_url: url } } };
|
|
190
|
+
out.env.codex = setup.codex.env;
|
|
191
|
+
}
|
|
192
|
+
else if (url)
|
|
193
|
+
out.env.codex = { ...setup.codex.env, OPENAI_BASE_URL: url };
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
/** The variables that carry a credential: kept out of every message, and in T3's secret store in a VM. */
|
|
198
|
+
function secretVar(app, name) {
|
|
199
|
+
return app === 'codex' ? name !== 'OPENAI_BASE_URL' : /KEY|TOKEN|SECRET|PASSWORD|HEADER|AUTH|CREDENTIAL/i.test(name);
|
|
200
|
+
}
|
|
201
|
+
/** The T3 Code proxy setup as this account's VMs follow it (read again on every call: settings change). */
|
|
202
|
+
export function accountVmProxy(ctx) {
|
|
203
|
+
const info = readHubInfo(ctx.store.dir, ctx.apiUrl);
|
|
204
|
+
// The port the imported CLIProxyAPI listened on here (tools that used it reach the hosted copy the same way).
|
|
205
|
+
const imported = object(new AgentStateStore(ctx.store.dir).read().cli_proxy_local);
|
|
206
|
+
const ports = [CLI_PROXY_PORT, ...(imported.api_url === ctx.apiUrl && typeof imported.port === 'number' ? [imported.port] : [])];
|
|
207
|
+
const plan = vmProxy(readT3ProxySetup({ env: ctx.io.env }), info ? vmHubInfoText(info) : null, ports);
|
|
208
|
+
for (const app of ['claude', 'codex'])
|
|
209
|
+
for (const [name, value] of Object.entries(plan.env[app]))
|
|
210
|
+
if (secretVar(app, name))
|
|
211
|
+
ctx.addSecret(value);
|
|
212
|
+
if (plan.hubInfo && info?.vm_callback_url)
|
|
213
|
+
ctx.addSecret(info.vm_callback_url);
|
|
214
|
+
return plan;
|
|
215
|
+
}
|
|
216
|
+
const missing = (e) => e instanceof ShardfluxApiError && e.status === 404;
|
|
217
|
+
async function remoteText(cell, path) {
|
|
218
|
+
try {
|
|
219
|
+
return await cell.files.readText(path);
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
if (missing(error))
|
|
223
|
+
return null;
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Puts what the VM's agents need in place before they start: the forwarder's hub info (0600), and the Codex provider in
|
|
229
|
+
* ~/.codex/config.toml (its other settings kept). Writes only what differs.
|
|
230
|
+
*/
|
|
231
|
+
export async function prepareVmProxy(cell, plan) {
|
|
232
|
+
// The VM's copy stays when it is this hub's with an address at least as new (another computer may have renewed it).
|
|
233
|
+
const hub = async (info) => {
|
|
234
|
+
const current = await remoteText(cell, VM_HUB_INFO_PATH);
|
|
235
|
+
if (current === info)
|
|
236
|
+
return;
|
|
237
|
+
const mine = json(info), theirs = json(current);
|
|
238
|
+
if (current !== null && theirs.workspace_id === mine.workspace_id && Date.parse(String(theirs.callback_created_at)) >= Date.parse(String(mine.callback_created_at)))
|
|
239
|
+
return;
|
|
240
|
+
await cell.files.write(VM_HUB_INFO_PATH, info, { mode: '0600', createParents: true });
|
|
241
|
+
};
|
|
242
|
+
const codex = async (keys) => {
|
|
243
|
+
const current = await remoteText(cell, VM_CODEX_CONFIG);
|
|
244
|
+
let config;
|
|
245
|
+
try {
|
|
246
|
+
config = parse(current ?? '');
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
return; /* the VM's own config is not TOML: Codex reports it there */
|
|
250
|
+
}
|
|
251
|
+
const id = keys.model_provider;
|
|
252
|
+
const next = { ...config, model_provider: id, model_providers: { ...object(config.model_providers), ...object(keys.model_providers) } };
|
|
253
|
+
// An active profile's own model_provider would win over the top-level one.
|
|
254
|
+
const active = text(config.profile), profiles = object(config.profiles);
|
|
255
|
+
if (active && active in profiles)
|
|
256
|
+
next.profiles = { ...profiles, [active]: { ...object(profiles[active]), model_provider: id } };
|
|
257
|
+
const out = stringify(next);
|
|
258
|
+
if (current === null || stringify(config) !== out)
|
|
259
|
+
await cell.files.write(VM_CODEX_CONFIG, out, { mode: '0600', createParents: true });
|
|
260
|
+
};
|
|
261
|
+
await Promise.all([plan.hubInfo ? hub(plan.hubInfo) : undefined, plan.codexConfig ? codex(plan.codexConfig) : undefined]);
|
|
262
|
+
}
|
|
263
|
+
const VM_T3 = '/home/user/.t3/userdata';
|
|
264
|
+
/**
|
|
265
|
+
* The VM's own T3 Code (SSH and the T3 app through T3 Connect) runs its Claude Code and Codex providers with the setup's
|
|
266
|
+
* variables, stored as T3 stores them: plain values in its settings.json, credentials in its secret store
|
|
267
|
+
* (`secrets/provider-env-<base64url instance>-<base64url name>.bin`, 0600) with `valueRedacted` in the settings. The
|
|
268
|
+
* instances' other variables and the file's other settings stay. Also places prepareVmProxy's files.
|
|
269
|
+
*/
|
|
270
|
+
export async function prepareVmT3(cell, plan) {
|
|
271
|
+
await prepareVmProxy(cell, plan);
|
|
272
|
+
const wanted = [['claudeAgent', 'claude', plan.env.claude], ['codex', 'codex', plan.env.codex]];
|
|
273
|
+
if (wanted.every(([, , vars]) => !Object.keys(vars).length))
|
|
274
|
+
return;
|
|
275
|
+
const path = `${VM_T3}/settings.json`, current = await remoteText(cell, path);
|
|
276
|
+
let doc = {};
|
|
277
|
+
try {
|
|
278
|
+
if (current !== null)
|
|
279
|
+
doc = JSON.parse(current);
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
return; /* T3 reports its own unreadable settings */
|
|
283
|
+
}
|
|
284
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc))
|
|
285
|
+
return;
|
|
286
|
+
const instances = { ...object(doc.providerInstances) }, secrets = [];
|
|
287
|
+
for (const [id, app, vars] of wanted) {
|
|
288
|
+
if (!Object.keys(vars).length)
|
|
289
|
+
continue;
|
|
290
|
+
const entry = object(instances[id]);
|
|
291
|
+
const kept = (Array.isArray(entry.environment) ? entry.environment : []).filter((v) => !(String(object(v).name) in vars));
|
|
292
|
+
const added = Object.entries(vars).map(([name, value]) => {
|
|
293
|
+
if (!secretVar(app, name))
|
|
294
|
+
return { name, value, sensitive: false };
|
|
295
|
+
secrets.push([`${VM_T3}/secrets/provider-env-${b64(id)}-${b64(name)}.bin`, value]);
|
|
296
|
+
return { name, value: '', sensitive: true, valueRedacted: true };
|
|
297
|
+
});
|
|
298
|
+
instances[id] = { ...entry, driver: id, environment: [...kept, ...added] };
|
|
299
|
+
}
|
|
300
|
+
// The secrets first: T3 reloads its settings when the file changes and reads them then.
|
|
301
|
+
await Promise.all(secrets.map(async ([p, v]) => { if ((await remoteText(cell, p)) !== v)
|
|
302
|
+
await cell.files.write(p, v, { mode: '0600', createParents: true }); }));
|
|
303
|
+
const next = `${JSON.stringify({ ...doc, providerInstances: instances }, null, 2)}\n`;
|
|
304
|
+
if (next !== current)
|
|
305
|
+
await cell.files.write(path, next, { mode: '0600', createParents: true });
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Before T3 Code opens a project VM (`shard agents new|open --in t3`): its T3 Code follows the user's proxy setup. A
|
|
309
|
+
* proxy on this computer without the hosted copy is named with the import; nothing here fails the open.
|
|
310
|
+
*/
|
|
311
|
+
export async function followT3ProxyInVm(ctx, ws) {
|
|
312
|
+
const plan = accountVmProxy(ctx);
|
|
313
|
+
if (plan.importable.length) {
|
|
314
|
+
const apps = plan.importable.map((a) => (a === 'claude' ? 'Claude Code' : 'Codex')).join(' and ');
|
|
315
|
+
ctx.io.stderr.write(`Your T3 Code setup sends ${apps} through CLIProxyAPI on this computer. Cloud VMs use it once it is in your account: shard cli-proxy import\n`);
|
|
316
|
+
}
|
|
317
|
+
if (!plan.hubInfo && !plan.codexConfig && !Object.keys(plan.env.claude).length && !Object.keys(plan.env.codex).length)
|
|
318
|
+
return;
|
|
319
|
+
try {
|
|
320
|
+
await prepareVmT3(ws.cell(), plan);
|
|
321
|
+
}
|
|
322
|
+
catch (error) {
|
|
323
|
+
ctx.io.stderr.write(`T3 Code in ${ws.key} uses your Shardflux sign-ins for now: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
324
|
+
}
|
|
325
|
+
}
|
package/dist/terminal-apps.d.ts
CHANGED
|
@@ -93,6 +93,17 @@ export declare function terminalReport(dir: string, env: Env, terminals: readonl
|
|
|
93
93
|
export declare const shellWord: (s: string) => string;
|
|
94
94
|
/** The command as typed: with this computer's config folder when it is not the default (an app's own, a test's). */
|
|
95
95
|
export declare function commandLine(argv: readonly string[], env: Env): string;
|
|
96
|
+
/** Set in the command a terminal this CLI opened runs (0.23.0+): it attaches there and never hands off to yet another
|
|
97
|
+
* terminal, even if that terminal does not look like one a person types into. */
|
|
98
|
+
export declare const TERMINAL_HANDOFF = "SHARDFLUX_TERMINAL_ATTACH";
|
|
99
|
+
export declare const handedToTerminal: (env: Env) => boolean;
|
|
100
|
+
/**
|
|
101
|
+
* The command a new terminal runs: without the coding-agent shell variables (AGENT_SHELL_VARIABLES), which a terminal
|
|
102
|
+
* started from an agent's shell inherits on Linux, so the child is a terminal a person types into; and with the
|
|
103
|
+
* one-time hand-off guard. Nimblefox (2026-10-13): from a Codex shell each new terminal looked like an agent shell
|
|
104
|
+
* again and opened another one, stealing focus in a loop.
|
|
105
|
+
*/
|
|
106
|
+
export declare function launchLine(argv: readonly string[], env: Env): string;
|
|
96
107
|
/** What a shell in a new tab types: into the project folder, then the command. */
|
|
97
108
|
export declare const typedLine: (argv: readonly string[], cwd: string, env: Env) => string;
|
|
98
109
|
/** A new window's own command (Linux, Alacritty): it keeps the window open on an error so the person can read it. */
|
package/dist/terminal-apps.js
CHANGED
|
@@ -15,7 +15,7 @@ import { accessSync, constants, existsSync, mkdirSync, readFileSync, readdirSync
|
|
|
15
15
|
import { homedir } from 'node:os';
|
|
16
16
|
import { basename, dirname, join } from 'node:path';
|
|
17
17
|
import { AgentStateStore } from "./agent-state.js";
|
|
18
|
-
import { codingAgentShell } from "./agent-setup.js";
|
|
18
|
+
import { AGENT_SHELL_VARIABLES, codingAgentShell } from "./agent-setup.js";
|
|
19
19
|
import { applicationDirs } from "./agent-targets.js";
|
|
20
20
|
import { UsageError } from "./errors.js";
|
|
21
21
|
import { linuxTerminalCommand } from "./linux-terminal.js";
|
|
@@ -258,11 +258,24 @@ export const shellWord = (s) => s && BARE_WORD.test(s) ? s : `'${s.replaceAll("'
|
|
|
258
258
|
export function commandLine(argv, env) {
|
|
259
259
|
return `${env.SHARDFLUX_CONFIG_DIR ? `env SHARDFLUX_CONFIG_DIR=${shellWord(env.SHARDFLUX_CONFIG_DIR)} ` : ''}${argv.map(shellWord).join(' ')}`;
|
|
260
260
|
}
|
|
261
|
+
/** Set in the command a terminal this CLI opened runs (0.23.0+): it attaches there and never hands off to yet another
|
|
262
|
+
* terminal, even if that terminal does not look like one a person types into. */
|
|
263
|
+
export const TERMINAL_HANDOFF = 'SHARDFLUX_TERMINAL_ATTACH';
|
|
264
|
+
export const handedToTerminal = (env) => env[TERMINAL_HANDOFF] === '1';
|
|
265
|
+
/**
|
|
266
|
+
* The command a new terminal runs: without the coding-agent shell variables (AGENT_SHELL_VARIABLES), which a terminal
|
|
267
|
+
* started from an agent's shell inherits on Linux, so the child is a terminal a person types into; and with the
|
|
268
|
+
* one-time hand-off guard. Nimblefox (2026-10-13): from a Codex shell each new terminal looked like an agent shell
|
|
269
|
+
* again and opened another one, stealing focus in a loop.
|
|
270
|
+
*/
|
|
271
|
+
export function launchLine(argv, env) {
|
|
272
|
+
return `env ${AGENT_SHELL_VARIABLES.map(name => `-u ${name} `).join('')}${TERMINAL_HANDOFF}=1 ${commandLine(argv, env).replace(/^env /, '')}`;
|
|
273
|
+
}
|
|
261
274
|
/** What a shell in a new tab types: into the project folder, then the command. */
|
|
262
|
-
export const typedLine = (argv, cwd, env) => `cd ${shellWord(cwd)} && ${
|
|
275
|
+
export const typedLine = (argv, cwd, env) => `cd ${shellWord(cwd)} && ${launchLine(argv, env)}`;
|
|
263
276
|
/** A new window's own command (Linux, Alacritty): it keeps the window open on an error so the person can read it. */
|
|
264
277
|
export function windowCommand(argv, cwd, env) {
|
|
265
|
-
return ['sh', '-c', `cd ${shellWord(cwd)} 2>/dev/null; ${
|
|
278
|
+
return ['sh', '-c', `cd ${shellWord(cwd)} 2>/dev/null; ${launchLine(argv, env)}; status=$?; if [ "$status" -ne 0 ]; then printf '\\nPress Enter to close.'; read answer; fi; exit "$status"`];
|
|
266
279
|
}
|
|
267
280
|
/** An AppleScript string literal. */
|
|
268
281
|
export const appleString = (s) => `"${s.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('\n', '\\n').replaceAll('\r', '\\r')}"`;
|
|
@@ -304,7 +317,7 @@ export function warpTabConfig(argv, cwd, env) {
|
|
|
304
317
|
'id = "conversation"',
|
|
305
318
|
'type = "terminal"',
|
|
306
319
|
`directory = ${JSON.stringify(cwd)}`,
|
|
307
|
-
`commands = [${JSON.stringify(
|
|
320
|
+
`commands = [${JSON.stringify(launchLine(argv, env))}]`,
|
|
308
321
|
'is_focused = true',
|
|
309
322
|
'',
|
|
310
323
|
].join('\n');
|
|
@@ -366,7 +379,7 @@ async function openWith(t, mode, l) {
|
|
|
366
379
|
const { argv, cwd, env, platform } = l;
|
|
367
380
|
const line = typedLine(argv, cwd, env);
|
|
368
381
|
const name = t.name;
|
|
369
|
-
const command =
|
|
382
|
+
const command = launchLine(argv, env);
|
|
370
383
|
const mac = platform === 'darwin';
|
|
371
384
|
const activate = async () => { if (mac && t.path)
|
|
372
385
|
await terminalNative.run('/usr/bin/open', ['-a', t.path], { env, timeoutMs: 5000 }); };
|
package/dist/update.d.ts
CHANGED
|
@@ -55,6 +55,8 @@ export interface UpdateDeps {
|
|
|
55
55
|
/** True when nothing is behind but the menu bar app, and the app the npm package carries is already that version. */
|
|
56
56
|
export declare function onlyAppToSwap(pending: Component[], packagedApp: string | null): boolean;
|
|
57
57
|
export declare function update(ctx: Ctx, values: Record<string, unknown>, deps?: UpdateDeps): Promise<number>;
|
|
58
|
+
/** Keep an upgrade-stable alias only when it runs this exact Node (never another version from PATH). */
|
|
59
|
+
export declare function stableNodePath(node: string, env: NodeJS.ProcessEnv): string;
|
|
58
60
|
/** Records where `shard` runs, for the menu bar's update button. Written on macOS, only when it changed. */
|
|
59
61
|
export declare function recordInstall(dir: string, runtime: Runtime, distribution: {
|
|
60
62
|
package: string;
|
package/dist/update.js
CHANGED
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
* macOS) and shows the outcome from last-update.json, also when the update replaced the app that started it.
|
|
10
10
|
*/
|
|
11
11
|
import { spawn } from 'node:child_process';
|
|
12
|
-
import { closeSync, existsSync, openSync, readFileSync, realpathSync, rmSync, writeSync } from 'node:fs';
|
|
12
|
+
import { accessSync, closeSync, constants, existsSync, openSync, readFileSync, realpathSync, rmSync, writeSync } from 'node:fs';
|
|
13
13
|
import { homedir } from 'node:os';
|
|
14
|
-
import { basename, dirname, join, sep } from 'node:path';
|
|
14
|
+
import { basename, dirname, isAbsolute, join, sep } from 'node:path';
|
|
15
15
|
import { clientVersionStatus, SDK_VERSION } from '@shardflux/sdk';
|
|
16
16
|
import { CLI_VERSION } from "./args.js";
|
|
17
17
|
import { AgentStateStore } from "./agent-state.js";
|
|
@@ -108,7 +108,7 @@ export async function components(ctx, install, catalog, menubar = menubarManager
|
|
|
108
108
|
local.push(['@shardflux/cli', CLI_VERSION]);
|
|
109
109
|
local.push(['@shardflux/sdk', SDK_VERSION]);
|
|
110
110
|
if (menubar.platform === 'darwin') {
|
|
111
|
-
const status = await menubar.status().catch(() => null);
|
|
111
|
+
const status = await menubar.status(ctx.store.dir).catch(() => null);
|
|
112
112
|
if (status?.installed && status.version)
|
|
113
113
|
local.push([MENUBAR, status.version]);
|
|
114
114
|
}
|
|
@@ -292,7 +292,7 @@ async function refreshMenubar(ctx, menubar, runtime, run, env) {
|
|
|
292
292
|
return { status: 'off' };
|
|
293
293
|
if (new AgentStateStore(ctx.store.dir).read().menubar_stopped === true)
|
|
294
294
|
return { status: 'off' };
|
|
295
|
-
const before = await menubar.status().catch(() => null);
|
|
295
|
+
const before = await menubar.status(ctx.store.dir).catch(() => null);
|
|
296
296
|
if (!before?.installed)
|
|
297
297
|
return { status: 'off' };
|
|
298
298
|
if (runtime && run && env) {
|
|
@@ -306,9 +306,35 @@ async function refreshMenubar(ctx, menubar, runtime, run, env) {
|
|
|
306
306
|
return { status: 'current' };
|
|
307
307
|
await menubar.start(ctx.store.dir, ctx.apiUrl);
|
|
308
308
|
}
|
|
309
|
-
const after = await menubar.status().catch(() => null);
|
|
309
|
+
const after = await menubar.status(ctx.store.dir).catch(() => null);
|
|
310
310
|
return after?.version && after.version !== before.version ? { status: 'restarted', version: after.version } : { status: 'current' };
|
|
311
311
|
}
|
|
312
|
+
/** Keep an upgrade-stable alias only when it runs this exact Node (never another version from PATH). */
|
|
313
|
+
export function stableNodePath(node, env) {
|
|
314
|
+
let real;
|
|
315
|
+
try {
|
|
316
|
+
real = realpathSync(node);
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
return node;
|
|
320
|
+
}
|
|
321
|
+
const candidates = (env.PATH ?? '').split(':').filter(isAbsolute).map(dir => join(dir, 'node'));
|
|
322
|
+
// Homebrew's opt link also works when launchd's PATH has no Homebrew entry (including versioned formulae).
|
|
323
|
+
const brew = /^(.*)\/Cellar\/(node(?:@[^/]+)?)\/[^/]+\/bin\/node$/.exec(node);
|
|
324
|
+
if (brew)
|
|
325
|
+
candidates.push(join(brew[1], 'opt', brew[2], 'bin/node'), join(brew[1], 'bin/node'));
|
|
326
|
+
for (const candidate of candidates) {
|
|
327
|
+
try {
|
|
328
|
+
if (candidate.includes('/Cellar/'))
|
|
329
|
+
continue;
|
|
330
|
+
accessSync(candidate, constants.X_OK);
|
|
331
|
+
if (realpathSync(candidate) === real)
|
|
332
|
+
return candidate;
|
|
333
|
+
}
|
|
334
|
+
catch { /* missing alias: try the next one */ }
|
|
335
|
+
}
|
|
336
|
+
return node;
|
|
337
|
+
}
|
|
312
338
|
/** Records where `shard` runs, for the menu bar's update button. Written on macOS, only when it changed. */
|
|
313
339
|
export function recordInstall(dir, runtime, distribution, env = {}) {
|
|
314
340
|
let script = runtime.script;
|
|
@@ -336,7 +362,7 @@ export function recordInstall(dir, runtime, distribution, env = {}) {
|
|
|
336
362
|
catch {
|
|
337
363
|
menubarApp = null;
|
|
338
364
|
}
|
|
339
|
-
const doc = { version: 1, node: runtime.node, script, package: distribution.package, package_version: distribution.version, cli_version: CLI_VERSION, ...(menubarApp ? { menubar_app: menubarApp } : {}) };
|
|
365
|
+
const doc = { version: 1, node: stableNodePath(runtime.node, env), script, package: distribution.package, package_version: distribution.version, cli_version: CLI_VERSION, ...(menubarApp ? { menubar_app: menubarApp } : {}) };
|
|
340
366
|
const text = `${JSON.stringify(doc, null, 2)}\n`;
|
|
341
367
|
try {
|
|
342
368
|
if (readFileSync(join(dir, INSTALL_FILE), 'utf8') === text)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shardflux/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "shard: the Shardflux command line. Create an account, sign in, set up keys and billing, then open, run commands in, move files to, suspend, resume and fork serverless agent workspaces.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"smol-toml": "1.9.0",
|
|
48
48
|
"snappyjs": "0.7.0",
|
|
49
49
|
"yaml": "2.9.1",
|
|
50
|
-
"@shardflux/sdk": "^0.
|
|
50
|
+
"@shardflux/sdk": "^0.27.1"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@eslint/js": "10.0.1",
|
|
@@ -58,9 +58,9 @@
|
|
|
58
58
|
"typescript-eslint": "8.70.1"
|
|
59
59
|
},
|
|
60
60
|
"optionalDependencies": {
|
|
61
|
-
"@shardflux/desktop-linux-x64": "0.1.
|
|
62
|
-
"@shardflux/desktop-linux-arm64": "0.1.
|
|
63
|
-
"@shardflux/menubar-darwin": "^0.8.
|
|
61
|
+
"@shardflux/desktop-linux-x64": "0.1.11",
|
|
62
|
+
"@shardflux/desktop-linux-arm64": "0.1.11",
|
|
63
|
+
"@shardflux/menubar-darwin": "^0.8.5"
|
|
64
64
|
},
|
|
65
65
|
"scripts": {
|
|
66
66
|
"build": "node scripts/build.mjs",
|