@shardflux/cli 0.11.1 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/CHANGELOG.md +64 -1
  2. package/README.md +83 -15
  3. package/dist/account-args.js +2 -0
  4. package/dist/account-commands.js +39 -0
  5. package/dist/agent-commands.d.ts +8 -0
  6. package/dist/agent-commands.js +223 -0
  7. package/dist/agent-config-files.d.ts +23 -0
  8. package/dist/agent-config-files.js +203 -0
  9. package/dist/agent-config-mcp.d.ts +36 -0
  10. package/dist/agent-config-mcp.js +225 -0
  11. package/dist/agent-config-sync.d.ts +58 -0
  12. package/dist/agent-config-sync.js +366 -0
  13. package/dist/agent-credentials.d.ts +14 -0
  14. package/dist/agent-credentials.js +93 -0
  15. package/dist/agent-git-commands.d.ts +10 -0
  16. package/dist/agent-git-commands.js +294 -0
  17. package/dist/agent-git.d.ts +47 -0
  18. package/dist/agent-git.js +285 -0
  19. package/dist/agent-reachability.d.ts +4 -0
  20. package/dist/agent-reachability.js +13 -0
  21. package/dist/agent-repository.d.ts +13 -0
  22. package/dist/agent-repository.js +182 -0
  23. package/dist/agent-state.d.ts +16 -0
  24. package/dist/agent-state.js +63 -0
  25. package/dist/agent-target-commands.d.ts +10 -0
  26. package/dist/agent-target-commands.js +171 -0
  27. package/dist/agent-targets.d.ts +33 -0
  28. package/dist/agent-targets.js +267 -0
  29. package/dist/agent-terminal.d.ts +35 -0
  30. package/dist/agent-terminal.js +209 -0
  31. package/dist/args.d.ts +8 -4
  32. package/dist/args.js +120 -10
  33. package/dist/assisted-setup.d.ts +24 -0
  34. package/dist/assisted-setup.js +77 -0
  35. package/dist/browser-commands.d.ts +3 -0
  36. package/dist/browser-commands.js +83 -0
  37. package/dist/browser-profile-copy.d.ts +2 -0
  38. package/dist/browser-profile-copy.js +2 -0
  39. package/dist/browser-readers.d.ts +13 -0
  40. package/dist/browser-readers.js +249 -0
  41. package/dist/browser-sites.d.ts +1 -0
  42. package/dist/browser-sites.js +13 -0
  43. package/dist/claude-cloud-login.d.ts +9 -0
  44. package/dist/claude-cloud-login.js +143 -0
  45. package/dist/command-center-actions.d.ts +6 -0
  46. package/dist/command-center-actions.js +132 -0
  47. package/dist/command-center-keys.d.ts +6 -0
  48. package/dist/command-center-keys.js +38 -0
  49. package/dist/command-center-model.d.ts +29 -0
  50. package/dist/command-center-model.js +90 -0
  51. package/dist/command-center-renderer.d.ts +25 -0
  52. package/dist/command-center-renderer.js +100 -0
  53. package/dist/command-center.d.ts +18 -0
  54. package/dist/command-center.js +320 -0
  55. package/dist/commands.d.ts +8 -2
  56. package/dist/commands.js +123 -8
  57. package/dist/credential-prompts.d.ts +22 -0
  58. package/dist/credential-prompts.js +193 -0
  59. package/dist/filesystem-commands.d.ts +2 -0
  60. package/dist/filesystem-commands.js +63 -0
  61. package/dist/filesystem-state.d.ts +3 -0
  62. package/dist/filesystem-state.js +12 -0
  63. package/dist/git-remote-bin.d.ts +2 -0
  64. package/dist/git-remote-bin.js +37 -0
  65. package/dist/git-remote-helper.d.ts +8 -0
  66. package/dist/git-remote-helper.js +31 -0
  67. package/dist/git-remote.d.ts +14 -0
  68. package/dist/git-remote.js +136 -0
  69. package/dist/handlers.js +9 -1
  70. package/dist/index.d.ts +2 -0
  71. package/dist/index.js +1 -0
  72. package/dist/input.d.ts +5 -0
  73. package/dist/main.js +21 -5
  74. package/dist/menubar.d.ts +32 -0
  75. package/dist/menubar.js +180 -0
  76. package/dist/project-identity.d.ts +14 -0
  77. package/dist/project-identity.js +61 -0
  78. package/dist/project-sync-manifest.d.ts +27 -0
  79. package/dist/project-sync-manifest.js +225 -0
  80. package/dist/project-sync-transfer.d.ts +14 -0
  81. package/dist/project-sync-transfer.js +240 -0
  82. package/dist/project-sync.d.ts +38 -0
  83. package/dist/project-sync.js +243 -0
  84. package/dist/project-template-setup.d.ts +17 -0
  85. package/dist/project-template-setup.js +89 -0
  86. package/dist/vault.d.ts +16 -0
  87. package/dist/vault.js +153 -0
  88. package/package.json +9 -2
@@ -0,0 +1,203 @@
1
+ import { readFile, readdir, realpath, stat } from 'node:fs/promises';
2
+ import { join, relative, resolve } from 'node:path';
3
+ export const object = (v) => v && typeof v === 'object' && !Array.isArray(v) ? v : {};
4
+ export const text = (v) => typeof v === 'string' ? v : undefined;
5
+ export const absent = (e) => e.code === 'ENOENT';
6
+ // These are code/config trees, never agent runtime state. Plugin cache is handled by its install records below.
7
+ const excluded = new Set(['.git', '.in_use', 'node_modules', '__pycache__', '.DS_Store', '.credentials.json', 'auth.json',
8
+ 'projects', 'transcripts', 'history.jsonl', 'logs', 'cache', 'caches', '.env']);
9
+ const maxFileBytes = 16 * 1024 * 1024;
10
+ export async function readOptional(file) {
11
+ try {
12
+ return await readFile(file);
13
+ }
14
+ catch (error) {
15
+ if (absent(error))
16
+ return undefined;
17
+ throw error;
18
+ }
19
+ }
20
+ export async function readObject(file) {
21
+ const bytes = await readOptional(file);
22
+ if (!bytes)
23
+ return undefined;
24
+ const parsed = JSON.parse(bytes.toString('utf8'));
25
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
26
+ throw new Error(`expected an object in ${file}`);
27
+ return parsed;
28
+ }
29
+ /** Dereference skill symlinks, including ones shared from another local directory, but never traverse a cycle. */
30
+ export async function collectTree(source, dest, files, issues, parents = new Set()) {
31
+ let resolved;
32
+ try {
33
+ resolved = await realpath(source);
34
+ }
35
+ catch (error) {
36
+ if (absent(error)) {
37
+ issues.push({ item: dest, reason: 'source is missing or a broken symlink' });
38
+ return;
39
+ }
40
+ throw error;
41
+ }
42
+ if (parents.has(resolved)) {
43
+ issues.push({ item: dest, reason: 'symlink cycle' });
44
+ return;
45
+ }
46
+ const info = await stat(resolved);
47
+ if (info.isDirectory()) {
48
+ const next = new Set(parents).add(resolved);
49
+ for (const name of (await readdir(resolved)).sort()) {
50
+ if (excluded.has(name) || name.startsWith('.env.'))
51
+ continue;
52
+ await collectTree(join(resolved, name), `${dest}/${name}`, files, issues, next);
53
+ }
54
+ }
55
+ else if (info.isFile()) {
56
+ if (info.size > maxFileBytes) {
57
+ issues.push({ item: dest, reason: 'file exceeds the 16 MiB config sync limit' });
58
+ return;
59
+ }
60
+ const content = await readFile(resolved);
61
+ if (/^\.(?:claude\/(?:skills|commands)|codex\/(?:skills|prompts))\//.test(dest) &&
62
+ /(?:\b(?:sk-[a-zA-Z0-9_-]{10,}|gh[pousr]_[a-zA-Z0-9]{10,}|github_pat_[a-zA-Z0-9_]{10,}|sfk_[a-zA-Z0-9_]{10,})|\b(?:[A-Z_]*(?:API_KEY|ACCESS_TOKEN|SECRET|PASSWORD))\s*[=:]\s*["']?(?!\$|\s|["']?\$)[a-zA-Z0-9_-]{8,})/.test(content.toString('utf8'))) {
63
+ issues.push({ item: dest, reason: 'contains a credential literal in reusable tooling; use a bound environment secret' });
64
+ return;
65
+ }
66
+ files.set(dest, { content, mode: info.mode & 0o111 ? '0700' : '0600' });
67
+ }
68
+ else
69
+ issues.push({ item: dest, reason: 'not a regular file or directory' });
70
+ }
71
+ export async function collectUserFiles(home, issues) {
72
+ const files = new Map();
73
+ for (const path of ['.claude/settings.json', '.claude/CLAUDE.md', '.claude/skills', '.claude/commands',
74
+ '.claude/agents', '.claude/output-styles', '.codex/AGENTS.md', '.codex/prompts', '.codex/skills']) {
75
+ try {
76
+ await stat(join(home, path));
77
+ }
78
+ catch (error) {
79
+ if (absent(error))
80
+ continue;
81
+ throw error;
82
+ }
83
+ await collectTree(join(home, path), path, files, issues);
84
+ }
85
+ return files;
86
+ }
87
+ function under(path, root) { const rel = relative(root, path); return rel === '' || !rel.startsWith('../') && rel !== '..' && !rel.startsWith('/'); }
88
+ export function macBinary(bytes) {
89
+ if (bytes.length < 4)
90
+ return false;
91
+ const magic = bytes.readUInt32BE(0);
92
+ // Java class files share FAT_MAGIC; a fat Mach-O has a small, nonzero architecture count.
93
+ if ([0xcafebabe, 0xcafebabf].includes(magic))
94
+ return bytes.length >= 8 && bytes.readUInt32BE(4) > 0 && bytes.readUInt32BE(4) <= 20;
95
+ return new Set([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe, 0xbebafeca, 0xbfbafeca]).has(magic);
96
+ }
97
+ /** Preserve v2 install metadata and user-scope records, and copy just the trees needed to load them on Linux. */
98
+ export async function collectPlugins(home, remoteHome, files, issues) {
99
+ const skipped = new Set();
100
+ const root = join(home, '.claude/plugins');
101
+ const installed = await readObject(join(root, 'installed_plugins.json'));
102
+ if (installed) {
103
+ const plugins = {};
104
+ for (const [id, records] of Object.entries(object(installed.plugins))) {
105
+ const kept = [];
106
+ for (const raw of Array.isArray(records) ? records : [records]) {
107
+ const record = object(raw);
108
+ if (record.scope !== 'user') {
109
+ issues.push({ item: `plugin:${id}`, reason: 'project/local scope travels with its repository' });
110
+ continue;
111
+ }
112
+ const path = text(record.installPath);
113
+ if (!path || !under(resolve(path), join(root, 'cache'))) {
114
+ issues.push({ item: `plugin:${id}`, reason: 'install path is outside the plugin cache' });
115
+ continue;
116
+ }
117
+ const dest = relative(home, resolve(path));
118
+ const tree = new Map();
119
+ const before = issues.length;
120
+ await collectTree(path, dest, tree, issues);
121
+ if (issues.length !== before || [...tree.values()].some(f => macBinary(f.content))) {
122
+ issues.push({ item: `plugin:${id}`, reason: issues.length !== before ? 'plugin tree is incomplete' : 'contains macOS native binaries; install a Linux version' });
123
+ continue;
124
+ }
125
+ for (const [name, file] of tree)
126
+ files.set(name, file);
127
+ kept.push({ ...record, installPath: `${remoteHome}/${dest}` });
128
+ }
129
+ if (kept.length)
130
+ plugins[id] = kept;
131
+ else
132
+ skipped.add(id);
133
+ }
134
+ files.set('.claude/plugins/installed_plugins.json', { content: Buffer.from(JSON.stringify({ ...installed, plugins }) + '\n'), mode: '0600' });
135
+ }
136
+ const known = await readObject(join(root, 'known_marketplaces.json'));
137
+ if (known) {
138
+ const portable = {};
139
+ for (const [id, raw] of Object.entries(known)) {
140
+ const entry = object(raw);
141
+ const location = text(entry.installLocation);
142
+ if (!location || !under(resolve(location), join(root, 'marketplaces'))) {
143
+ issues.push({ item: `marketplace:${id}`, reason: 'marketplace is outside the marketplace directory' });
144
+ continue;
145
+ }
146
+ const dest = relative(home, resolve(location));
147
+ const tree = new Map();
148
+ const before = issues.length;
149
+ await collectTree(location, dest, tree, issues);
150
+ if (issues.length !== before)
151
+ continue;
152
+ // Marketplace source can contain binary packages for other platforms; the installed version is checked above.
153
+ for (const [name, file] of tree)
154
+ if (!macBinary(file.content))
155
+ files.set(name, file);
156
+ portable[id] = { ...entry, installLocation: `${remoteHome}/${dest}` };
157
+ }
158
+ files.set('.claude/plugins/known_marketplaces.json', { content: Buffer.from(JSON.stringify(portable) + '\n'), mode: '0600' });
159
+ }
160
+ return skipped;
161
+ }
162
+ /** Only rewrite paths to files we actually send. Missing laptop paths are never made to look portable. */
163
+ export function rewritePaths(value, home, remoteHome, files, issues, item) {
164
+ if (typeof value === 'string') {
165
+ if (/^https?:\/\//.test(value))
166
+ return value;
167
+ const expanded = value.startsWith('~/') ? join(home, value.slice(2)) : value;
168
+ const whole = relative(home, expanded);
169
+ if (under(expanded, home) && (files.has(whole) || [...files.keys()].some(p => p.startsWith(whole + '/'))))
170
+ return `${remoteHome}/${whole}`;
171
+ const escapedHome = home.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
172
+ const pattern = new RegExp(`(?:${escapedHome}/|~/|/Users/|/Applications/|/opt/homebrew/|/home/)[^\\s"'\\],;]+`, 'g');
173
+ let failed = false;
174
+ const result = value.replace(pattern, path => {
175
+ const local = path.startsWith('~/') ? join(home, path.slice(2)) : path;
176
+ const rel = relative(home, local);
177
+ if (under(local, home) && (files.has(rel) || [...files.keys()].some(p => p.startsWith(rel + '/'))))
178
+ return `${remoteHome}/${rel}`;
179
+ issues.push({ item, reason: 'references a local path outside the synced config' });
180
+ failed = true;
181
+ return path;
182
+ });
183
+ return failed ? undefined : result;
184
+ }
185
+ if (Array.isArray(value)) {
186
+ const mapped = value.map(v => rewritePaths(v, home, remoteHome, files, issues, item));
187
+ return mapped.some(v => v === undefined) ? undefined : mapped;
188
+ }
189
+ if (value && typeof value === 'object') {
190
+ const result = {};
191
+ for (const [key, v] of Object.entries(value)) {
192
+ if (key.startsWith('/') || key.startsWith('~/')) {
193
+ issues.push({ item: `${item}.${key}`, reason: 'local project path is not synced by agent config' });
194
+ continue;
195
+ }
196
+ const mapped = rewritePaths(v, home, remoteHome, files, issues, `${item}.${key}`);
197
+ if (mapped !== undefined)
198
+ result[key] = mapped;
199
+ }
200
+ return result;
201
+ }
202
+ return value;
203
+ }
@@ -0,0 +1,36 @@
1
+ import type { ConfigFiles, ConfigIssue, ObjectValue } from './agent-config-files.js';
2
+ export type McpStatus = {
3
+ app: 'claude' | 'codex';
4
+ name: string;
5
+ status: 'copied' | 'reachable while attached' | 'skipped';
6
+ reason?: string;
7
+ };
8
+ export type ConfigTunnel = {
9
+ remote_port: number;
10
+ target: string;
11
+ };
12
+ export type McpSecret = {
13
+ name: string;
14
+ value: string;
15
+ item: string;
16
+ };
17
+ export type McpContext = {
18
+ home: string;
19
+ remoteHome: string;
20
+ workspaceId: string;
21
+ files: ConfigFiles;
22
+ issues: ConfigIssue[];
23
+ namespace?: string;
24
+ env: Record<string, string | undefined>;
25
+ exists(command: string): Promise<boolean>;
26
+ bind(secret: McpSecret): Promise<boolean>;
27
+ };
28
+ export declare const digest: (value: string | Uint8Array) => string;
29
+ export declare const credential: (key: string, value: string) => boolean;
30
+ /** Commands are checked through exec; a successful laptop command lookup would say nothing about the VM. */
31
+ export declare function classifyMcp(server: ObjectValue, exists: McpContext['exists']): Promise<{
32
+ status: McpStatus['status'];
33
+ reason?: string;
34
+ url?: URL;
35
+ }>;
36
+ export declare function prepareMcp(app: 'claude' | 'codex', input: ObjectValue, ctx: McpContext, statuses: McpStatus[], tunnels: ConfigTunnel[]): Promise<ObjectValue>;
@@ -0,0 +1,225 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { object, rewritePaths, text } from "./agent-config-files.js";
3
+ export const digest = (value) => createHash('sha256').update(value).digest('hex');
4
+ export const credential = (key, value) => /(?:^|[_-])(?:api[_-]?key|key|token|secret|password|passwd|auth|authorization|credential|cookie)(?:$|[_-])/i.test(key) || /^(?:sk-|gh[pousr]_|github_pat_|sfk_|Bearer\s|eyJ[A-Za-z0-9_-]+\.)/.test(value);
5
+ const envReference = /^\$\{([A-Z_][A-Z0-9_]*)(?::-([^}]*))?\}$/;
6
+ /** Commands are checked through exec; a successful laptop command lookup would say nothing about the VM. */
7
+ export async function classifyMcp(server, exists) {
8
+ if (server.enabled === false)
9
+ return { status: 'skipped', reason: 'disabled on the laptop' };
10
+ if (typeof server.url === 'string') {
11
+ let url;
12
+ try {
13
+ url = new URL(server.url);
14
+ }
15
+ catch {
16
+ return { status: 'skipped', reason: 'URL is invalid or contains unresolved environment references' };
17
+ }
18
+ if (!['http:', 'https:'].includes(url.protocol))
19
+ return { status: 'skipped', reason: 'transport is not HTTP or SSE' };
20
+ if (url.username || url.password || [...url.searchParams.keys()].some(k => credential(k, '')))
21
+ return { status: 'skipped', reason: 'URL contains credentials; use header/environment authentication' };
22
+ return { status: ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) ? 'reachable while attached' : 'copied', url };
23
+ }
24
+ const command = text(server.command);
25
+ if (!command || server.type && server.type !== 'stdio')
26
+ return { status: 'skipped', reason: 'unsupported MCP transport' };
27
+ if (server.args !== undefined && (!Array.isArray(server.args) || server.args.some((arg) => typeof arg !== 'string')))
28
+ return { status: 'skipped', reason: 'stdio args must be strings' };
29
+ if (!(await exists(command)))
30
+ return { status: 'skipped', reason: 'stdio command is absent in the VM' };
31
+ return { status: 'copied' };
32
+ }
33
+ export async function prepareMcp(app, input, ctx, statuses, tunnels) {
34
+ const result = {};
35
+ for (const [name, raw] of Object.entries(input)) {
36
+ const item = `${app}:mcp:${ctx.namespace ? ctx.namespace + ':' : ''}${name}`;
37
+ const issueCount = ctx.issues.length;
38
+ const server = object(rewritePaths(raw, ctx.home, ctx.remoteHome, ctx.files, ctx.issues, item));
39
+ let classification = await classifyMcp(server, command => ctx.exists(command));
40
+ if (ctx.issues.length > issueCount)
41
+ classification = { status: 'skipped', reason: 'references laptop files that are not synced' };
42
+ if (classification.status === 'skipped') {
43
+ statuses.push({ app, name, ...classification });
44
+ continue;
45
+ }
46
+ const env = object(server.env);
47
+ const explicitEnv = new Set(Object.keys(env));
48
+ if (server.url && explicitEnv.size) {
49
+ statuses.push({ app, name, status: 'skipped', reason: 'HTTP transport cannot map stdio environment values' });
50
+ continue;
51
+ }
52
+ const exports = [];
53
+ let denied = false;
54
+ const route = async (key, value) => {
55
+ const secretName = `SF_MCP_${digest(`${ctx.workspaceId}:${item}:${key}`).slice(0, 24).toUpperCase()}`;
56
+ if (!(await ctx.bind({ name: secretName, value, item: `${item}:${key}` }))) {
57
+ denied = true;
58
+ return undefined;
59
+ }
60
+ return secretName;
61
+ };
62
+ for (const [key, rawValue] of Object.entries(env)) {
63
+ const value = text(rawValue);
64
+ if (value === undefined) {
65
+ denied = true;
66
+ continue;
67
+ }
68
+ const ref = envReference.exec(value);
69
+ if (ref && ['CLAUDE_PLUGIN_ROOT', 'CLAUDE_PLUGIN_DATA', 'CLAUDE_PROJECT_DIR'].includes(ref[1]))
70
+ continue;
71
+ const resolved = ref ? ctx.env[ref[1]] || ref[2] : value;
72
+ if (ref && resolved === undefined) {
73
+ denied = true;
74
+ continue;
75
+ }
76
+ if (credential(key, resolved) || ref) {
77
+ const secretName = await route(key, resolved);
78
+ delete env[key];
79
+ if (secretName)
80
+ exports.push({ key, name: secretName });
81
+ }
82
+ }
83
+ // Codex forwards env_vars verbatim; bind laptop values too, rather than writing them into TOML.
84
+ if (app === 'codex' && Array.isArray(server.env_vars)) {
85
+ for (const key of server.env_vars) {
86
+ if (typeof key !== 'string' || !/^[A-Z_][A-Z0-9_]*$/.test(key)) {
87
+ denied = true;
88
+ continue;
89
+ }
90
+ if (explicitEnv.has(key))
91
+ continue;
92
+ const value = ctx.env[key];
93
+ if (value === undefined) {
94
+ denied = true;
95
+ continue;
96
+ }
97
+ const secretName = await route(key, value);
98
+ if (secretName)
99
+ exports.push({ key, name: secretName });
100
+ }
101
+ delete server.env_vars;
102
+ }
103
+ const headersKey = app === 'claude' ? 'headers' : 'http_headers';
104
+ const headers = object(server[headersKey]);
105
+ const envHeaders = object(server.env_http_headers);
106
+ for (const [key, rawValue] of Object.entries(headers)) {
107
+ const value = text(rawValue);
108
+ if (value === undefined) {
109
+ denied = true;
110
+ continue;
111
+ }
112
+ let unresolved = false;
113
+ const expanded = value.replace(/\$\{([A-Z_][A-Z0-9_]*)(?::-([^}]*))?\}/g, (_all, variable, fallback) => {
114
+ const replacement = ctx.env[variable] || fallback;
115
+ if (replacement === undefined) {
116
+ unresolved = true;
117
+ return '';
118
+ }
119
+ return replacement;
120
+ });
121
+ if (unresolved) {
122
+ denied = true;
123
+ continue;
124
+ }
125
+ if (credential(key, expanded) || expanded !== value) {
126
+ const secretName = await route(`header:${key}`, expanded);
127
+ delete headers[key];
128
+ if (secretName) {
129
+ if (app === 'codex')
130
+ envHeaders[key] = secretName;
131
+ else
132
+ headers[key] = '${' + secretName + '}';
133
+ }
134
+ }
135
+ }
136
+ if (app === 'codex') {
137
+ for (const [key, rawValue] of Object.entries(object(server.env_http_headers))) {
138
+ const variable = text(rawValue);
139
+ const value = variable ? ctx.env[variable] : undefined;
140
+ if (value === undefined) {
141
+ denied = true;
142
+ continue;
143
+ }
144
+ const secretName = await route(`header:${key}`, value);
145
+ if (secretName)
146
+ envHeaders[key] = secretName;
147
+ }
148
+ if (typeof server.bearer_token_env_var === 'string') {
149
+ const value = ctx.env[server.bearer_token_env_var];
150
+ if (value === undefined)
151
+ denied = true;
152
+ else
153
+ server.bearer_token_env_var = await route('bearer', value);
154
+ }
155
+ if (Object.keys(envHeaders).length)
156
+ server.env_http_headers = envHeaders;
157
+ }
158
+ if (denied) {
159
+ statuses.push({ app, name, status: 'skipped', reason: 'secret consent declined/unavailable, or referenced environment value is missing' });
160
+ continue;
161
+ }
162
+ if (Object.keys(env).length)
163
+ server.env = env;
164
+ else
165
+ delete server.env;
166
+ if (Object.keys(headers).length)
167
+ server[headersKey] = headers;
168
+ else
169
+ delete server[headersKey];
170
+ // Known browser MCPs join the same profile only after the customer imports a session.
171
+ // The shell delegates the original command unchanged when no browser profile is enabled.
172
+ if (!server.url) {
173
+ const args = Array.isArray(server.args) ? server.args : [];
174
+ const command = text(server.command);
175
+ const names = [command, ...args].map(a => a.replaceAll(String.fromCharCode(92), '/'));
176
+ const hasPackage = (pkg) => names.some(a => a === pkg || a.startsWith(`${pkg}@`) || a.endsWith(`/${pkg}`) || a.includes(`/${pkg}/`));
177
+ const browser = hasPackage('@playwright/mcp') || hasPackage('playwright-mcp') ? 'playwright'
178
+ : hasPackage('chrome-devtools-mcp') ? 'chrome' : undefined;
179
+ if (browser && await ctx.exists('sf-guest-agent') && await ctx.exists('sh')) {
180
+ server.command = 'sh';
181
+ server.args = ['-c', `if [ -f "$HOME/.local/share/shardflux/browser/enabled" ]; then exec sf-guest-agent browser-profile mcp ${browser} "$@"; else exec "$@"; fi`, 'shard-browser-mcp', command, ...args];
182
+ }
183
+ }
184
+ if (exports.length) {
185
+ if (server.url) {
186
+ statuses.push({ app, name, status: 'skipped', reason: 'HTTP transport cannot map stdio environment values' });
187
+ continue;
188
+ }
189
+ if (!(await ctx.exists('sh'))) {
190
+ statuses.push({ app, name, status: 'skipped', reason: 'secret environment forwarding needs sh in the VM' });
191
+ continue;
192
+ }
193
+ if (exports.some(e => !/^[A-Z_][A-Z0-9_]*$/.test(e.key))) {
194
+ statuses.push({ app, name, status: 'skipped', reason: 'invalid environment variable name' });
195
+ continue;
196
+ }
197
+ const command = text(server.command);
198
+ const args = Array.isArray(server.args) ? server.args : [];
199
+ server.command = 'sh';
200
+ server.args = ['-c', exports.map(e => `export ${e.key}="$${e.name}";`).join(' ') + ' exec "$@"', 'shard-mcp', command, ...args];
201
+ if (app === 'codex')
202
+ server.env_vars = [...new Set(exports.map(e => e.name))];
203
+ }
204
+ if (classification.status === 'reachable while attached') {
205
+ const url = classification.url;
206
+ const port = Number(url.port || (url.protocol === 'https:' ? 443 : 80));
207
+ const target = `${url.hostname}:${port}`;
208
+ let tunnel = tunnels.find(t => t.target === target);
209
+ if (!tunnel) {
210
+ let remote = 20000 + Number.parseInt(digest(target).slice(0, 8), 16) % 40000;
211
+ while (tunnels.some(t => t.remote_port === remote))
212
+ remote = 20000 + (remote - 20000 + 1) % 40000;
213
+ tunnel = { remote_port: remote, target };
214
+ tunnels.push(tunnel);
215
+ }
216
+ if (url.hostname === '[::1]')
217
+ url.hostname = 'localhost';
218
+ url.port = String(tunnel.remote_port);
219
+ server.url = url.toString();
220
+ }
221
+ result[name] = server;
222
+ statuses.push({ app, name, status: classification.status });
223
+ }
224
+ return result;
225
+ }
@@ -0,0 +1,58 @@
1
+ import type { CellClient, Workspace } from '@shardflux/sdk';
2
+ import type { Ctx, Handler } from './commands.js';
3
+ import type { ConfigFiles, ConfigIssue } from './agent-config-files.js';
4
+ import type { ConfigTunnel, McpStatus } from './agent-config-mcp.js';
5
+ type ManifestFile = {
6
+ hash: string;
7
+ mode: string;
8
+ created: boolean;
9
+ };
10
+ type SecretRecord = {
11
+ id: string;
12
+ hash: string;
13
+ version: number;
14
+ };
15
+ export type ConfigManifest = {
16
+ v: 1;
17
+ workspace_id: string;
18
+ workspace_key: string;
19
+ api_url: string;
20
+ home: string;
21
+ synced_at: string | null;
22
+ sync_state: 'applying' | 'synced';
23
+ remote_hash?: string;
24
+ files: Record<string, ManifestFile>;
25
+ claude_mcp: string[];
26
+ secrets: Record<string, SecretRecord>;
27
+ claude_mcp_owned?: Record<string, {
28
+ hash: string;
29
+ created: boolean;
30
+ }>;
31
+ skipped: ConfigIssue[];
32
+ mcp: McpStatus[];
33
+ tunnels: ConfigTunnel[];
34
+ };
35
+ export type ConfigSyncResult = {
36
+ workspace_id: string;
37
+ workspace_key: string;
38
+ copied: string[];
39
+ removed: string[];
40
+ skipped: ConfigIssue[];
41
+ mcp: McpStatus[];
42
+ synced_at: string | null;
43
+ };
44
+ export declare function configManifests(ctx: Pick<Ctx, 'store'>): Record<string, ConfigManifest>;
45
+ /** `like` must not inherit generated MCP secrets restricted to the source workspace. */
46
+ export declare function agentConfigLikeSecrets(ctx: Ctx, reference: string): Promise<string[] | undefined>;
47
+ export declare function applyConfigFiles(cell: CellClient, home: string, files: ConfigFiles, previous: Record<string, ManifestFile>, trustManifest: boolean, issues: ConfigIssue[], signal: AbortSignal, deleteMissing?: boolean, progress?: (path: string, file: ManifestFile | undefined) => void): Promise<{
48
+ files: Record<string, ManifestFile>;
49
+ copied: string[];
50
+ removed: string[];
51
+ }>;
52
+ export declare function syncAgentConfig(ctx: Ctx, ws: Workspace): Promise<ConfigSyncResult>;
53
+ /** Tunnels are owned by the attachment, and close even if the terminal fails or the user detaches. */
54
+ export declare function startAgentConfigTunnels(ctx: Ctx, ws: Workspace): Promise<{
55
+ stop(): Promise<void>;
56
+ }>;
57
+ export declare const AGENT_CONFIG_HANDLERS: Record<string, Handler>;
58
+ export {};