@shardflux/cli 0.20.0 → 0.23.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.
- package/CHANGELOG.md +312 -1
- package/README.md +242 -75
- package/dist/account-args.js +7 -2
- package/dist/account-commands.js +56 -106
- package/dist/account-pool.d.ts +19 -15
- package/dist/account-pool.js +47 -96
- package/dist/account-scope.d.ts +65 -0
- package/dist/account-scope.js +246 -0
- package/dist/acp-journal.js +58 -6
- package/dist/acp-login.d.ts +13 -5
- package/dist/acp-login.js +37 -8
- package/dist/acp-models.d.ts +6 -1
- package/dist/acp-models.js +29 -19
- package/dist/acp.js +120 -14
- package/dist/agent-commands.d.ts +4 -1
- package/dist/agent-commands.js +110 -42
- package/dist/agent-config-return.d.ts +46 -2
- package/dist/agent-config-return.js +110 -4
- package/dist/agent-credentials.js +7 -3
- package/dist/agent-repository.d.ts +16 -3
- package/dist/agent-repository.js +149 -74
- package/dist/agent-setup.d.ts +14 -3
- package/dist/agent-setup.js +78 -36
- package/dist/agent-target-commands.d.ts +20 -1
- package/dist/agent-target-commands.js +73 -31
- package/dist/agent-targets.js +6 -1
- package/dist/args.d.ts +1 -1
- package/dist/args.js +95 -54
- package/dist/bin.js +5 -1
- package/dist/browser.d.ts +13 -2
- package/dist/browser.js +30 -14
- package/dist/claude-cloud-login.d.ts +24 -2
- package/dist/claude-cloud-login.js +151 -11
- package/dist/cli-proxy-hub.d.ts +61 -25
- package/dist/cli-proxy-hub.js +202 -54
- package/dist/cli-proxy.d.ts +44 -1
- package/dist/cli-proxy.js +177 -28
- package/dist/client-settings.d.ts +39 -0
- package/dist/client-settings.js +91 -0
- package/dist/codex-cloud.d.ts +14 -4
- package/dist/codex-cloud.js +135 -31
- package/dist/codex-linux.d.ts +14 -0
- package/dist/codex-linux.js +69 -0
- package/dist/command-center-actions.js +18 -5
- 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 +173 -24
- package/dist/conversation-commands.d.ts +5 -0
- package/dist/conversation-commands.js +8 -5
- package/dist/conversation-terminal.d.ts +10 -2
- package/dist/conversation-terminal.js +78 -13
- package/dist/conversations.d.ts +86 -1
- package/dist/conversations.js +306 -23
- package/dist/desktop-linux.js +16 -3
- package/dist/device-bridge.d.ts +1 -1
- package/dist/device-bridge.js +6 -4
- package/dist/device-helper.js +43 -22
- package/dist/errors.d.ts +29 -2
- package/dist/errors.js +50 -7
- package/dist/filesystem-commands.d.ts +16 -9
- package/dist/filesystem-commands.js +133 -57
- package/dist/format.d.ts +2 -1
- package/dist/format.js +10 -1
- package/dist/handlers.js +3 -1
- package/dist/initial-prompt.d.ts +8 -0
- package/dist/initial-prompt.js +43 -1
- package/dist/large-media.d.ts +14 -0
- package/dist/large-media.js +100 -0
- package/dist/linux-terminal.d.ts +5 -3
- package/dist/linux-terminal.js +6 -20
- package/dist/local-sessions.d.ts +26 -2
- package/dist/local-sessions.js +113 -24
- package/dist/machine-setup.d.ts +11 -1
- package/dist/machine-setup.js +69 -22
- package/dist/machine-status.d.ts +67 -0
- package/dist/machine-status.js +140 -0
- package/dist/main.js +11 -13
- package/dist/menubar.d.ts +6 -5
- package/dist/menubar.js +38 -28
- package/dist/move-worktrees-restore.py +144 -0
- package/dist/move-worktrees.d.ts +69 -0
- package/dist/move-worktrees.js +252 -0
- package/dist/move.d.ts +10 -1
- package/dist/move.js +111 -16
- package/dist/onboarding.d.ts +11 -7
- package/dist/onboarding.js +63 -29
- package/dist/prepared-repository.d.ts +204 -0
- package/dist/prepared-repository.js +860 -0
- package/dist/prepared-repository.py +1307 -0
- package/dist/project-preparation.d.ts +62 -0
- package/dist/project-preparation.js +316 -0
- package/dist/project-sync-manifest.d.ts +4 -0
- package/dist/project-sync-manifest.js +5 -1
- package/dist/project-sync-transfer.js +1 -1
- package/dist/project-sync.d.ts +22 -4
- package/dist/project-sync.js +34 -8
- package/dist/project-template-setup.d.ts +33 -1
- package/dist/project-template-setup.js +59 -2
- package/dist/secret-ask.d.ts +72 -0
- package/dist/secret-ask.js +351 -0
- package/dist/seed-phases.d.ts +8 -1
- package/dist/seed-phases.js +17 -2
- package/dist/setup-cache.d.ts +7 -0
- package/dist/setup-cache.js +32 -0
- package/dist/t3-launch.d.ts +2 -1
- package/dist/t3-launch.js +12 -6
- package/dist/t3-mode.d.ts +8 -2
- package/dist/t3-mode.js +19 -4
- package/dist/t3-phone.d.ts +4 -2
- package/dist/t3-phone.js +12 -4
- package/dist/t3-proxy.d.ts +81 -0
- package/dist/t3-proxy.js +325 -0
- package/dist/terminal-apps.d.ts +168 -0
- package/dist/terminal-apps.js +574 -0
- package/dist/terminal-onboarding.js +6 -1
- package/dist/this-computer.d.ts +11 -0
- package/dist/this-computer.js +36 -0
- package/dist/thread-on-computer.js +4 -1
- package/dist/update.d.ts +2 -0
- package/dist/update.js +43 -9
- package/dist/worktree-twin.d.ts +10 -2
- package/dist/worktree-twin.js +27 -9
- package/package.json +5 -5
package/dist/t3-launch.js
CHANGED
|
@@ -9,7 +9,6 @@ import { conversationWorkspace, getConversation } from "./conversations.js";
|
|
|
9
9
|
import { resolveOn } from "./thread-on-computer.js";
|
|
10
10
|
import { ensureCloudClaude } from "./claude-cloud-login.js";
|
|
11
11
|
import { modelChoice } from "./agent-models.js";
|
|
12
|
-
import { conversationPooled } from "./account-pool.js";
|
|
13
12
|
/**
|
|
14
13
|
* A `t3_*` refusal raised once the workspace, conversation or thread exists names them (details.workspace_key,
|
|
15
14
|
* conversation_id, t3_thread_id), so a client opens what exists instead of starting it again.
|
|
@@ -100,13 +99,22 @@ export function t3Fields(ws, project, install, started, conversation) {
|
|
|
100
99
|
}
|
|
101
100
|
/**
|
|
102
101
|
* `agents open t3 <conversation>` for a conversation that runs on T3's Shardflux provider (ACP mode): T3 is brought to
|
|
103
|
-
* the front with that thread's id. A
|
|
102
|
+
* the front with that thread's id. A Codex Cloud conversation opens its Codex chat; a conversation of another route
|
|
103
|
+
* opens its VM as T3's SSH environment.
|
|
104
104
|
*/
|
|
105
105
|
export async function openT3Conversation(ctx, conversationId, values = {}) {
|
|
106
|
+
const conversation = await getConversation(ctx, conversationId);
|
|
107
|
+
// A Codex Cloud conversation's chat is its Codex thread, which T3 Code cannot show: its existing history opens (as
|
|
108
|
+
// `agents open` does), without waking the VM, linking it to T3 or starting another conversation (0.23.0+).
|
|
109
|
+
if (conversation.route === 'codex-cloud') {
|
|
110
|
+
if (!ctx.json)
|
|
111
|
+
ctx.io.stderr.write('Codex Cloud runs this conversation, so its chat is in Codex, not T3 Code.\n');
|
|
112
|
+
const { openCodexConversation } = await import("./codex-cloud.js");
|
|
113
|
+
return openCodexConversation(ctx, conversation);
|
|
114
|
+
}
|
|
106
115
|
const install = t3Install(ctx.io.env);
|
|
107
116
|
if (!install)
|
|
108
117
|
throw t3NotInstalled();
|
|
109
|
-
const conversation = await getConversation(ctx, conversationId);
|
|
110
118
|
if (install.mode === 'acp' && conversation.route === 'acp') {
|
|
111
119
|
let threadId;
|
|
112
120
|
try {
|
|
@@ -130,9 +138,7 @@ export async function openT3Conversation(ctx, conversationId, values = {}) {
|
|
|
130
138
|
let { ws } = await reopenConversation(ctx, conversation, values);
|
|
131
139
|
if (values.reachable !== undefined)
|
|
132
140
|
ws = await ctx.cloud().workspaces.open({ key: ws.key, ...agentReachability(values), wait: false });
|
|
133
|
-
|
|
134
|
-
if (!conversationPooled(conversation))
|
|
135
|
-
await ensureCloudClaude(ctx, ws);
|
|
141
|
+
await ensureCloudClaude(ctx, ws);
|
|
136
142
|
const { openDesktopWorkspace } = await import("./agent-target-commands.js");
|
|
137
143
|
return openDesktopWorkspace(ctx, ws, 't3', install.version ?? undefined, false, undefined, install, conversationId);
|
|
138
144
|
}
|
package/dist/t3-mode.d.ts
CHANGED
|
@@ -8,7 +8,10 @@ export declare const T3_BUNDLE_ID = "com.t3tools.t3code";
|
|
|
8
8
|
export type T3Mode = 'acp' | 'ssh';
|
|
9
9
|
export type AgentApp = 'claude' | 'codex';
|
|
10
10
|
export type OpenIn = 'terminal' | 'claude-desktop' | 'web' | 'codex-app' | 't3';
|
|
11
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* Where each app's new conversations can open: Claude in a terminal app, Claude Desktop, claude.ai or T3; Codex in the
|
|
13
|
+
* Codex app, a terminal app (0.21.1+ on macOS) or T3. `agents targets --json` reports them as `open_in_targets`.
|
|
14
|
+
*/
|
|
12
15
|
export declare const OPEN_IN_TARGETS: Record<AgentApp, readonly OpenIn[]>;
|
|
13
16
|
export declare const openInTargets: (app: AgentApp, platform?: NodeJS.Platform) => readonly OpenIn[];
|
|
14
17
|
/** The T3 Code `shard` uses: its .app bundle (macOS) or program (Linux, with how it was installed in `linux`). */
|
|
@@ -89,7 +92,10 @@ export declare function openInFor(ctx: Ctx, project: string | null, known?: {
|
|
|
89
92
|
export declare function openInChoices(ctx: Ctx, project: string | null, known?: {
|
|
90
93
|
clients?: readonly string[];
|
|
91
94
|
}): Record<AgentApp, OpenIn>;
|
|
92
|
-
/**
|
|
95
|
+
/**
|
|
96
|
+
* `--open-in claude=t3,codex=t3`: validated against what is installed, then saved in agents.json. A bare target
|
|
97
|
+
* (`--open-in terminal`, 0.20.1+) is the choice of every app that opens there (terminal: Claude and Codex).
|
|
98
|
+
*/
|
|
93
99
|
export declare function saveOpenIn(ctx: Ctx, spec: string, status: T3Status, clients: readonly string[]): void;
|
|
94
100
|
/** A T3 thread was started, then a later step failed: the error names the thread so a client opens it, never starts another. */
|
|
95
101
|
export declare function t3OpenFailed(error: unknown, ref: {
|
package/dist/t3-mode.js
CHANGED
|
@@ -35,8 +35,11 @@ import { T3_THREAD_SCRIPT } from "./t3-thread-script.js";
|
|
|
35
35
|
import { t3InstallScript } from "./t3-runtime.js";
|
|
36
36
|
import { uploadInitialImages } from "./initial-prompt.js";
|
|
37
37
|
export const T3_BUNDLE_ID = 'com.t3tools.t3code';
|
|
38
|
-
/**
|
|
39
|
-
|
|
38
|
+
/**
|
|
39
|
+
* Where each app's new conversations can open: Claude in a terminal app, Claude Desktop, claude.ai or T3; Codex in the
|
|
40
|
+
* Codex app, a terminal app (0.21.1+ on macOS) or T3. `agents targets --json` reports them as `open_in_targets`.
|
|
41
|
+
*/
|
|
42
|
+
export const OPEN_IN_TARGETS = { claude: ['terminal', 'claude-desktop', 'web', 't3'], codex: ['codex-app', 'terminal', 't3'] };
|
|
40
43
|
export const openInTargets = (app, platform = process.platform) => platform === 'linux' && app === 'codex' ? ['terminal', 't3'] : OPEN_IN_TARGETS[app];
|
|
41
44
|
/** T3 0.0.45's built-in defaults (DEFAULT_MODEL_BY_PROVIDER), used only when T3 in the VM has not described its models yet. */
|
|
42
45
|
const T3_DEFAULT_MODEL = { claude: 'claude-fable-5-1', codex: 'gpt-6-astra' };
|
|
@@ -173,10 +176,22 @@ export function openInChoices(ctx, project, known = {}) {
|
|
|
173
176
|
return { claude: choices.claude.target, codex: choices.codex.target };
|
|
174
177
|
}
|
|
175
178
|
const available = (target, status, clients) => target === 't3' ? status.installed : target === 'claude-desktop' ? clients.includes('claude-desktop') : true;
|
|
176
|
-
/**
|
|
179
|
+
/**
|
|
180
|
+
* `--open-in claude=t3,codex=t3`: validated against what is installed, then saved in agents.json. A bare target
|
|
181
|
+
* (`--open-in terminal`, 0.20.1+) is the choice of every app that opens there (terminal: Claude and Codex).
|
|
182
|
+
*/
|
|
177
183
|
export function saveOpenIn(ctx, spec, status, clients) {
|
|
178
184
|
const choices = {};
|
|
179
|
-
|
|
185
|
+
const apps = ['claude', 'codex'];
|
|
186
|
+
const parts = spec.split(',').map(s => s.trim()).filter(Boolean).flatMap(part => {
|
|
187
|
+
if (part.includes('='))
|
|
188
|
+
return [part];
|
|
189
|
+
const fits = apps.filter(app => openInTargets(app).includes(part));
|
|
190
|
+
if (!fits.length)
|
|
191
|
+
throw new UsageError(`--open-in: a target is one of ${[...new Set(apps.flatMap(app => openInTargets(app)))].join(', ')}, or app=target pairs such as claude=t3,codex=t3.`);
|
|
192
|
+
return fits.map(app => `${app}=${part}`);
|
|
193
|
+
});
|
|
194
|
+
for (const part of parts) {
|
|
180
195
|
const [app, target, extra] = part.split('=').map(s => s.trim());
|
|
181
196
|
if (app !== 'claude' && app !== 'codex' || !target || extra !== undefined)
|
|
182
197
|
throw new UsageError('--open-in takes app=target pairs, e.g. claude=t3,codex=t3.');
|
package/dist/t3-phone.d.ts
CHANGED
|
@@ -12,6 +12,8 @@ export interface T3PhoneLink {
|
|
|
12
12
|
}
|
|
13
13
|
/**
|
|
14
14
|
* Links a workspace for the T3 app on the phone (0.17.0+). An API key links with its creator's sign-in; starting that
|
|
15
|
-
* sign-in takes the person's own session, so the sign-in comes from `shard`'s session when the key has none yet.
|
|
15
|
+
* sign-in takes the person's own session, so the sign-in comes from `shard`'s session when the key has none yet. That
|
|
16
|
+
* sign-in is for the workspace's organization: `projectId` (the workspace's project) scopes it, which a person in more
|
|
17
|
+
* than one organization needs (0.20.2+; without it the API answers 422 project_id is required).
|
|
16
18
|
*/
|
|
17
|
-
export declare function t3PhoneLink(ctx: Ctx, workspaceId: string): Promise<T3PhoneLink>;
|
|
19
|
+
export declare function t3PhoneLink(ctx: Ctx, workspaceId: string, projectId?: string): Promise<T3PhoneLink>;
|
package/dist/t3-phone.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { t3NeedsProject } from "./account-scope.js";
|
|
1
2
|
/**
|
|
2
3
|
* Links a workspace for the T3 app on the phone (0.17.0+). An API key links with its creator's sign-in; starting that
|
|
3
|
-
* sign-in takes the person's own session, so the sign-in comes from `shard`'s session when the key has none yet.
|
|
4
|
+
* sign-in takes the person's own session, so the sign-in comes from `shard`'s session when the key has none yet. That
|
|
5
|
+
* sign-in is for the workspace's organization: `projectId` (the workspace's project) scopes it, which a person in more
|
|
6
|
+
* than one organization needs (0.20.2+; without it the API answers 422 project_id is required).
|
|
4
7
|
*/
|
|
5
|
-
export async function t3PhoneLink(ctx, workspaceId) {
|
|
8
|
+
export async function t3PhoneLink(ctx, workspaceId, projectId) {
|
|
6
9
|
const link = await ctx.cloud().t3.link(workspaceId);
|
|
7
10
|
if (link.status !== 'needs_sign_in')
|
|
8
11
|
return { link };
|
|
@@ -10,6 +13,11 @@ export async function t3PhoneLink(ctx, workspaceId) {
|
|
|
10
13
|
return { link, signIn: link.sign_in };
|
|
11
14
|
if (ctx.store.profile(ctx.apiUrl).session?.state !== 'active')
|
|
12
15
|
return { link, needsLogin: true };
|
|
13
|
-
|
|
14
|
-
|
|
16
|
+
try {
|
|
17
|
+
const flow = await ctx.account().t3.signIn(projectId ? { projectId } : {});
|
|
18
|
+
return { link, signIn: flow };
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
throw t3NeedsProject(error) ?? error;
|
|
22
|
+
}
|
|
15
23
|
}
|
|
@@ -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
|
+
}
|