@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.
- package/CHANGELOG.md +64 -1
- package/README.md +83 -15
- package/dist/account-args.js +2 -0
- package/dist/account-commands.js +39 -0
- package/dist/agent-commands.d.ts +8 -0
- package/dist/agent-commands.js +223 -0
- package/dist/agent-config-files.d.ts +23 -0
- package/dist/agent-config-files.js +203 -0
- package/dist/agent-config-mcp.d.ts +36 -0
- package/dist/agent-config-mcp.js +225 -0
- package/dist/agent-config-sync.d.ts +58 -0
- package/dist/agent-config-sync.js +366 -0
- package/dist/agent-credentials.d.ts +14 -0
- package/dist/agent-credentials.js +93 -0
- package/dist/agent-git-commands.d.ts +10 -0
- package/dist/agent-git-commands.js +294 -0
- package/dist/agent-git.d.ts +47 -0
- package/dist/agent-git.js +285 -0
- package/dist/agent-reachability.d.ts +4 -0
- package/dist/agent-reachability.js +13 -0
- package/dist/agent-repository.d.ts +13 -0
- package/dist/agent-repository.js +182 -0
- package/dist/agent-state.d.ts +16 -0
- package/dist/agent-state.js +63 -0
- package/dist/agent-target-commands.d.ts +10 -0
- package/dist/agent-target-commands.js +171 -0
- package/dist/agent-targets.d.ts +33 -0
- package/dist/agent-targets.js +267 -0
- package/dist/agent-terminal.d.ts +35 -0
- package/dist/agent-terminal.js +209 -0
- package/dist/args.d.ts +8 -4
- package/dist/args.js +120 -10
- package/dist/assisted-setup.d.ts +24 -0
- package/dist/assisted-setup.js +77 -0
- package/dist/browser-commands.d.ts +3 -0
- package/dist/browser-commands.js +83 -0
- package/dist/browser-profile-copy.d.ts +2 -0
- package/dist/browser-profile-copy.js +2 -0
- package/dist/browser-readers.d.ts +13 -0
- package/dist/browser-readers.js +249 -0
- package/dist/browser-sites.d.ts +1 -0
- package/dist/browser-sites.js +13 -0
- package/dist/claude-cloud-login.d.ts +9 -0
- package/dist/claude-cloud-login.js +143 -0
- package/dist/command-center-actions.d.ts +6 -0
- package/dist/command-center-actions.js +132 -0
- package/dist/command-center-keys.d.ts +6 -0
- package/dist/command-center-keys.js +38 -0
- package/dist/command-center-model.d.ts +29 -0
- package/dist/command-center-model.js +90 -0
- package/dist/command-center-renderer.d.ts +25 -0
- package/dist/command-center-renderer.js +100 -0
- package/dist/command-center.d.ts +18 -0
- package/dist/command-center.js +320 -0
- package/dist/commands.d.ts +8 -2
- package/dist/commands.js +123 -8
- package/dist/credential-prompts.d.ts +22 -0
- package/dist/credential-prompts.js +193 -0
- package/dist/filesystem-commands.d.ts +2 -0
- package/dist/filesystem-commands.js +63 -0
- package/dist/filesystem-state.d.ts +3 -0
- package/dist/filesystem-state.js +12 -0
- package/dist/git-remote-bin.d.ts +2 -0
- package/dist/git-remote-bin.js +37 -0
- package/dist/git-remote-helper.d.ts +8 -0
- package/dist/git-remote-helper.js +31 -0
- package/dist/git-remote.d.ts +14 -0
- package/dist/git-remote.js +136 -0
- package/dist/handlers.js +9 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/input.d.ts +5 -0
- package/dist/main.js +21 -5
- package/dist/menubar.d.ts +32 -0
- package/dist/menubar.js +180 -0
- package/dist/project-identity.d.ts +14 -0
- package/dist/project-identity.js +61 -0
- package/dist/project-sync-manifest.d.ts +27 -0
- package/dist/project-sync-manifest.js +225 -0
- package/dist/project-sync-transfer.d.ts +14 -0
- package/dist/project-sync-transfer.js +240 -0
- package/dist/project-sync.d.ts +38 -0
- package/dist/project-sync.js +243 -0
- package/dist/project-template-setup.d.ts +17 -0
- package/dist/project-template-setup.js +89 -0
- package/dist/vault.d.ts +16 -0
- package/dist/vault.js +153 -0
- package/package.json +9 -2
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { parse, stringify } from 'smol-toml';
|
|
4
|
+
import { ShardfluxApiError } from '@shardflux/sdk';
|
|
5
|
+
import { AgentStateStore } from "./agent-state.js";
|
|
6
|
+
import { UsageError } from "./errors.js";
|
|
7
|
+
import { collectPlugins, collectUserFiles, object, readObject, readOptional, rewritePaths } from "./agent-config-files.js";
|
|
8
|
+
import { digest, prepareMcp } from "./agent-config-mcp.js";
|
|
9
|
+
const missing = (e) => e instanceof ShardfluxApiError && e.status === 404;
|
|
10
|
+
const markerPath = '.config/shardflux/agent-config.json';
|
|
11
|
+
const safePath = (path) => !path.split('/').some(p => !p || p === '.' || p === '..') &&
|
|
12
|
+
(/^(?:\.claude\/(?:settings\.json|CLAUDE\.md)|\.codex\/(?:config\.toml|AGENTS\.md))$/.test(path) ||
|
|
13
|
+
/^\.claude\/(?:skills|commands|agents|output-styles|plugins)\/.+/.test(path) || /^\.codex\/(?:prompts|skills)\/.+/.test(path));
|
|
14
|
+
const key = (ctx, id) => `${ctx.apiUrl}:${id}`;
|
|
15
|
+
export function configManifests(ctx) {
|
|
16
|
+
const value = object(new AgentStateStore(ctx.store.dir).read().agent_config);
|
|
17
|
+
for (const [id, raw] of Object.entries(value)) {
|
|
18
|
+
const m = object(raw);
|
|
19
|
+
if (m.v !== 1 || typeof m.workspace_id !== 'string' || typeof m.api_url !== 'string' || !Array.isArray(m.mcp) || !Array.isArray(m.skipped))
|
|
20
|
+
throw new UsageError(`invalid agent config manifest ${id}; preserve agents.json and re-run config sync after repairing it`);
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
/** `like` must not inherit generated MCP secrets restricted to the source workspace. */
|
|
25
|
+
export async function agentConfigLikeSecrets(ctx, reference) {
|
|
26
|
+
const cloud = ctx.cloud();
|
|
27
|
+
const source = await cloud.workspaces.findByKey(reference, { includeDeleted: false, signal: ctx.signal }) ?? await cloud.workspaces.get(reference);
|
|
28
|
+
if (!source.labels.shard_project)
|
|
29
|
+
return undefined;
|
|
30
|
+
const bindings = await source.secrets.get();
|
|
31
|
+
const names = bindings.names.filter(name => !/^SF_MCP_[0-9A-F]{24}$/.test(name));
|
|
32
|
+
return names.length === bindings.names.length ? undefined : names;
|
|
33
|
+
}
|
|
34
|
+
async function optionalRemote(cell, path) {
|
|
35
|
+
try {
|
|
36
|
+
const value = JSON.parse(await cell.files.readText(path));
|
|
37
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
38
|
+
throw new Error(`invalid remote config ${path}`);
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
if (missing(error))
|
|
43
|
+
return undefined;
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Scope and consent are workspace-specific; never reuse an unrelated project secret with the same name. */
|
|
48
|
+
async function bindMcpSecret(ctx, ws, secrets, spec) {
|
|
49
|
+
ctx.addSecret(spec.value);
|
|
50
|
+
const hash = digest(spec.value);
|
|
51
|
+
const previous = secrets[spec.name];
|
|
52
|
+
if (previous?.hash === hash) {
|
|
53
|
+
const current = await ctx.cloud().secrets.get(previous.id);
|
|
54
|
+
if (!current.deleted_at && current.name === spec.name && current.current_version === previous.version && current.permissions.allowed_workspace_ids?.length === 1 && current.permissions.allowed_workspace_ids[0] === ws.id &&
|
|
55
|
+
current.permissions.allowed_tools.length === 2 && current.permissions.allowed_tools.includes('exec') && current.permissions.allowed_tools.includes('pty')) {
|
|
56
|
+
const bindings = await ws.secrets.get();
|
|
57
|
+
if (!bindings.names.includes(spec.name))
|
|
58
|
+
await ws.secrets.set([...bindings.names, spec.name]);
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (!ctx.input.interactive)
|
|
63
|
+
return false;
|
|
64
|
+
const answer = await ctx.input.answer(`Store ${spec.item} as a Shardflux secret for ${ws.key}? [y/N] `);
|
|
65
|
+
if (!/^y(?:es)?$/i.test(answer?.trim() ?? ''))
|
|
66
|
+
return false;
|
|
67
|
+
let id;
|
|
68
|
+
let version;
|
|
69
|
+
if (previous) {
|
|
70
|
+
const metadata = await ctx.cloud().secrets.get(previous.id);
|
|
71
|
+
if (metadata.deleted_at || metadata.name !== spec.name || metadata.permissions.allowed_workspace_ids?.length !== 1 || metadata.permissions.allowed_workspace_ids[0] !== ws.id)
|
|
72
|
+
throw new Error(`MCP secret ${spec.name} no longer has its original workspace scope`);
|
|
73
|
+
const rotated = (await ctx.cloud().secrets.rotate(previous.id, spec.value)).secret;
|
|
74
|
+
id = rotated.id;
|
|
75
|
+
version = rotated.current_version;
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
const created = await ctx.cloud().secrets.create(ws.data.project_id, { name: spec.name, value: spec.value,
|
|
79
|
+
description: 'Agent config MCP credential', allowedWorkspaceIds: [ws.id], allowedTools: ['exec', 'pty'] });
|
|
80
|
+
id = created.id;
|
|
81
|
+
version = created.current_version;
|
|
82
|
+
}
|
|
83
|
+
if (!Number.isInteger(version) || version < 1)
|
|
84
|
+
throw new Error('MCP secret response omitted its current version');
|
|
85
|
+
const bindings = await ws.secrets.get();
|
|
86
|
+
if (!bindings.names.includes(spec.name))
|
|
87
|
+
await ws.secrets.set([...bindings.names, spec.name]);
|
|
88
|
+
secrets[spec.name] = { id, hash, version };
|
|
89
|
+
// Persist consent immediately: a later file failure must not lose the secret's identity.
|
|
90
|
+
new AgentStateStore(ctx.store.dir).update(doc => {
|
|
91
|
+
const all = object(doc.agent_config_secrets);
|
|
92
|
+
all[key(ctx, ws.id)] = { ...object(all[key(ctx, ws.id)]), [spec.name]: { id, hash, version } };
|
|
93
|
+
doc.agent_config_secrets = all;
|
|
94
|
+
});
|
|
95
|
+
ctx.io.stderr.write(`MCP secret bound to ${ws.key}; an already running agent picks it up when restarted.\n`);
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
export async function applyConfigFiles(cell, home, files, previous, trustManifest, issues, signal, deleteMissing = true, progress = () => { }) {
|
|
99
|
+
const next = {};
|
|
100
|
+
const copied = [], removed = [];
|
|
101
|
+
for (const [path, file] of files) {
|
|
102
|
+
signal.throwIfAborted();
|
|
103
|
+
if (!safePath(path))
|
|
104
|
+
throw new Error('agent config attempted to write outside its allowlist');
|
|
105
|
+
const hash = digest(file.content);
|
|
106
|
+
const old = previous[path];
|
|
107
|
+
if (trustManifest && old?.hash === hash && old.mode === file.mode) {
|
|
108
|
+
next[path] = old;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
let exists = false, same = false;
|
|
112
|
+
try {
|
|
113
|
+
const info = await cell.files.stat(`${home}/${path}`, { revision: true });
|
|
114
|
+
exists = true;
|
|
115
|
+
same = info.revision === hash && info.mode === file.mode;
|
|
116
|
+
if (info.type !== 'file') {
|
|
117
|
+
issues.push({ item: path, reason: 'remote path is not a regular file' });
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
if (!missing(error))
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
if (!same) {
|
|
126
|
+
await cell.files.write(`${home}/${path}`, file.content, { createParents: true, mode: file.mode });
|
|
127
|
+
copied.push(path);
|
|
128
|
+
}
|
|
129
|
+
next[path] = { hash, mode: file.mode, created: old?.created ?? !exists };
|
|
130
|
+
progress(path, next[path]);
|
|
131
|
+
}
|
|
132
|
+
for (const [path, old] of Object.entries(deleteMissing ? previous : {})) {
|
|
133
|
+
signal.throwIfAborted();
|
|
134
|
+
if (files.has(path))
|
|
135
|
+
continue;
|
|
136
|
+
if (!safePath(path))
|
|
137
|
+
throw new Error('agent config manifest contains a path outside its allowlist');
|
|
138
|
+
if (!old.created)
|
|
139
|
+
continue;
|
|
140
|
+
try {
|
|
141
|
+
const info = await cell.files.stat(`${home}/${path}`, { revision: true });
|
|
142
|
+
if (info.type !== 'file' || info.revision !== old.hash) {
|
|
143
|
+
issues.push({ item: path, reason: 'remote file was changed; deletion preserved it' });
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
await cell.files.remove(`${home}/${path}`);
|
|
147
|
+
removed.push(path);
|
|
148
|
+
progress(path, undefined);
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
if (!missing(error))
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return { files: next, copied, removed };
|
|
156
|
+
}
|
|
157
|
+
export async function syncAgentConfig(ctx, ws) {
|
|
158
|
+
const home = ctx.io.env.HOME || homedir();
|
|
159
|
+
const store = new AgentStateStore(ctx.store.dir);
|
|
160
|
+
const previous = configManifests(ctx)[key(ctx, ws.id)];
|
|
161
|
+
const issues = [];
|
|
162
|
+
const files = await collectUserFiles(home, issues);
|
|
163
|
+
const codexBytes = await readOptional(join(home, '.codex/config.toml'));
|
|
164
|
+
const claude = await readObject(join(home, '.claude.json'));
|
|
165
|
+
const hasPlugins = await readOptional(join(home, '.claude/plugins/installed_plugins.json')) || await readOptional(join(home, '.claude/plugins/known_marketplaces.json'));
|
|
166
|
+
const empty = { workspace_id: ws.id, workspace_key: ws.key, copied: [], removed: [], skipped: issues, mcp: [], synced_at: null };
|
|
167
|
+
if (!files.size && !codexBytes && !hasPlugins && !Object.keys(object(claude?.mcpServers)).length && !previous)
|
|
168
|
+
return empty;
|
|
169
|
+
const cell = ws.cell({ agentLabel: ctx.agentLabel, transitionTimeoutMs: ctx.wakeTimeoutMs });
|
|
170
|
+
const run = await cell.exec.run(['sh', '-c', 'printf "%s\\n" "$HOME"'], { signal: ctx.signal, timeoutMs: 5000, maxOutputBytes: 4096 });
|
|
171
|
+
const remoteHome = run.stdout.trim();
|
|
172
|
+
if (run.exitCode !== 0 || !/^\/(?:[a-zA-Z0-9._-]+\/)*[a-zA-Z0-9._-]+$/.test(remoteHome) || remoteHome.split('/').some(p => p === '..' || p === '.'))
|
|
173
|
+
throw new Error('agent config could not resolve the VM user home');
|
|
174
|
+
const skippedPlugins = await collectPlugins(home, remoteHome, files, issues);
|
|
175
|
+
if (skippedPlugins.size && files.has('.claude/settings.json')) {
|
|
176
|
+
const file = files.get('.claude/settings.json');
|
|
177
|
+
const settings = object(JSON.parse(file.content.toString('utf8')));
|
|
178
|
+
const enabled = object(settings.enabledPlugins);
|
|
179
|
+
for (const id of skippedPlugins)
|
|
180
|
+
if (id in enabled)
|
|
181
|
+
enabled[id] = false;
|
|
182
|
+
settings.enabledPlugins = enabled;
|
|
183
|
+
file.content = Buffer.from(JSON.stringify(settings) + '\n');
|
|
184
|
+
}
|
|
185
|
+
// Claude settings stay the user's. sf-agent hooks are in /etc/claude-code/managed-settings.json.
|
|
186
|
+
const secrets = { ...previous?.secrets, ...object(object(store.read().agent_config_secrets)[key(ctx, ws.id)]) };
|
|
187
|
+
const commandCache = new Map();
|
|
188
|
+
const exists = async (command) => {
|
|
189
|
+
if (!commandCache.has(command)) {
|
|
190
|
+
if (!command || command.includes('\0'))
|
|
191
|
+
return false;
|
|
192
|
+
const probe = await cell.exec.run(['sh', '-c', 'command -v -- "$1" >/dev/null 2>&1', 'shard-config', command], { signal: ctx.signal, timeoutMs: 5000, maxOutputBytes: 4096 });
|
|
193
|
+
commandCache.set(command, probe.exitCode === 0);
|
|
194
|
+
}
|
|
195
|
+
return commandCache.get(command);
|
|
196
|
+
};
|
|
197
|
+
const mcp = [], tunnels = [];
|
|
198
|
+
const synced_at = new Date().toISOString();
|
|
199
|
+
const partial = { v: 1, workspace_id: ws.id, workspace_key: ws.key, api_url: ctx.apiUrl, home: remoteHome,
|
|
200
|
+
sync_state: 'applying', remote_hash: previous?.remote_hash, synced_at: previous?.synced_at ?? null, files: { ...previous?.files },
|
|
201
|
+
claude_mcp: previous?.claude_mcp ?? [], claude_mcp_owned: previous?.claude_mcp_owned, secrets, skipped: issues, mcp: previous?.mcp ?? [], tunnels: previous?.tunnels ?? [] };
|
|
202
|
+
const savePartial = (path, file) => {
|
|
203
|
+
if (file)
|
|
204
|
+
partial.files[path] = file;
|
|
205
|
+
else
|
|
206
|
+
delete partial.files[path];
|
|
207
|
+
store.update(doc => { const all = object(doc.agent_config); all[key(ctx, ws.id)] = partial; doc.agent_config = all; });
|
|
208
|
+
};
|
|
209
|
+
const marker = await optionalRemote(cell, `${remoteHome}/${markerPath}`);
|
|
210
|
+
const trust = marker?.workspace_id === ws.id && typeof previous?.remote_hash === 'string' && marker.manifest_hash === previous.remote_hash;
|
|
211
|
+
// Stage executable payloads before checking stdio paths. Structured configuration is prepared before transfer.
|
|
212
|
+
const payload = new Map([...files].filter(([path]) => !/\.(?:json|toml)$/.test(path) && path !== '.claude/settings.json'));
|
|
213
|
+
const staged = await applyConfigFiles(cell, remoteHome, payload, previous?.files ?? {}, trust, issues, ctx.signal, false, savePartial);
|
|
214
|
+
const mcpCtx = { home, remoteHome, workspaceId: ws.id, files, issues, env: ctx.io.env, exists, bind: (secret) => bindMcpSecret(ctx, ws, secrets, secret) };
|
|
215
|
+
// Installed plugins can declare MCP servers inline or in .mcp.json. Treat their env exactly like user MCP env.
|
|
216
|
+
for (const [path, file] of files) {
|
|
217
|
+
if (!path.startsWith('.claude/plugins/cache/') || !(path.endsWith('/.mcp.json') || path.endsWith('/.claude-plugin/plugin.json')))
|
|
218
|
+
continue;
|
|
219
|
+
const doc = object(JSON.parse(file.content.toString('utf8')));
|
|
220
|
+
const isManifest = path.endsWith('/plugin.json');
|
|
221
|
+
const inline = doc.mcpServers;
|
|
222
|
+
if (isManifest && (typeof inline === 'string' || Array.isArray(inline)))
|
|
223
|
+
continue; // Referenced .mcp.json files are handled in this same pass.
|
|
224
|
+
const servers = isManifest || inline ? object(inline) : doc;
|
|
225
|
+
if (!Object.keys(servers).length)
|
|
226
|
+
continue;
|
|
227
|
+
const root = `${remoteHome}/${path.slice(0, path.lastIndexOf(isManifest ? '/.claude-plugin/' : '/'))}`;
|
|
228
|
+
const pluginCtx = { ...mcpCtx, namespace: path, exists: (command) => exists(command.replaceAll('${CLAUDE_PLUGIN_ROOT}', root)) };
|
|
229
|
+
const portable = await prepareMcp('claude', servers, pluginCtx, mcp, tunnels);
|
|
230
|
+
file.content = Buffer.from(JSON.stringify(isManifest || inline ? { ...doc, mcpServers: portable } : portable) + '\n');
|
|
231
|
+
}
|
|
232
|
+
const claudeMcp = await prepareMcp('claude', object(claude?.mcpServers), mcpCtx, mcp, tunnels);
|
|
233
|
+
if (codexBytes) {
|
|
234
|
+
const config = parse(codexBytes.toString('utf8'));
|
|
235
|
+
const servers = await prepareMcp('codex', object(config.mcp_servers), mcpCtx, mcp, tunnels);
|
|
236
|
+
delete config.mcp_servers;
|
|
237
|
+
const portable = object(rewritePaths(config, home, remoteHome, files, issues, 'codex:config'));
|
|
238
|
+
if (Object.keys(servers).length || 'mcp_servers' in parse(codexBytes.toString('utf8')))
|
|
239
|
+
portable.mcp_servers = servers;
|
|
240
|
+
files.set('.codex/config.toml', { content: Buffer.from(stringify(portable)), mode: '0600' });
|
|
241
|
+
}
|
|
242
|
+
// Retain encrypted secret records for later re-use, but stop injecting credentials for removed/skipped servers.
|
|
243
|
+
const ownedSecrets = Object.keys(secrets);
|
|
244
|
+
if (ownedSecrets.length) {
|
|
245
|
+
const references = JSON.stringify(claudeMcp) + [...files.values()].map(f => f.content.toString('utf8')).join('\n');
|
|
246
|
+
const unused = new Set(ownedSecrets.filter(name => !references.includes(name)));
|
|
247
|
+
if (unused.size) {
|
|
248
|
+
const bindings = await ws.secrets.get();
|
|
249
|
+
const names = bindings.names.filter(name => !unused.has(name));
|
|
250
|
+
if (names.length !== bindings.names.length)
|
|
251
|
+
await ws.secrets.set(names);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const applied = await applyConfigFiles(cell, remoteHome, files, partial.files, trust, issues, ctx.signal, true, savePartial);
|
|
255
|
+
applied.copied.unshift(...staged.copied);
|
|
256
|
+
// L2 owns oauthAccount and credentials. Patch just mcpServers under a revision check; preserve every other field.
|
|
257
|
+
if (Object.keys(claudeMcp).length || previous?.claude_mcp.length) {
|
|
258
|
+
const path = `${remoteHome}/.claude.json`;
|
|
259
|
+
let document = {}, revision = 'absent';
|
|
260
|
+
try {
|
|
261
|
+
const info = await cell.files.readWithInfo(path);
|
|
262
|
+
const parsed = JSON.parse(Buffer.from(info.data).toString('utf8'));
|
|
263
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
264
|
+
throw new Error('Claude config is not an object; refusing an unsafe merge');
|
|
265
|
+
document = parsed;
|
|
266
|
+
if (!info.revision)
|
|
267
|
+
throw new Error('Claude config has no content revision; refusing an unsafe merge');
|
|
268
|
+
revision = info.revision;
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
if (!missing(error))
|
|
272
|
+
throw error;
|
|
273
|
+
}
|
|
274
|
+
const merged = { ...object(document.mcpServers) };
|
|
275
|
+
for (const name of previous?.claude_mcp ?? []) {
|
|
276
|
+
if (name in claudeMcp)
|
|
277
|
+
continue;
|
|
278
|
+
const old = previous?.claude_mcp_owned?.[name];
|
|
279
|
+
if (name in merged && old?.created && digest(JSON.stringify(merged[name])) === old.hash)
|
|
280
|
+
delete merged[name];
|
|
281
|
+
else if (name in merged)
|
|
282
|
+
issues.push({ item: `claude:mcp:${name}`, reason: 'remote MCP entry was pre-existing or changed; removal preserved it' });
|
|
283
|
+
}
|
|
284
|
+
const owned = {};
|
|
285
|
+
for (const [name, value] of Object.entries(claudeMcp))
|
|
286
|
+
owned[name] = { hash: digest(JSON.stringify(value)), created: previous?.claude_mcp_owned?.[name]?.created ?? !(name in merged) };
|
|
287
|
+
partial.claude_mcp_owned = owned;
|
|
288
|
+
Object.assign(merged, claudeMcp);
|
|
289
|
+
const content = JSON.stringify({ ...document, mcpServers: merged }) + '\n';
|
|
290
|
+
if (digest(content) !== revision) {
|
|
291
|
+
await cell.files.patch({ path, content, expectedRevision: revision, createParents: true, mode: '0600' }, { signal: ctx.signal });
|
|
292
|
+
applied.copied.push('.claude.json:mcpServers');
|
|
293
|
+
}
|
|
294
|
+
partial.claude_mcp = Object.keys(claudeMcp);
|
|
295
|
+
store.update(doc => { const all = object(doc.agent_config); all[key(ctx, ws.id)] = partial; doc.agent_config = all; });
|
|
296
|
+
}
|
|
297
|
+
const manifest = { v: 1, workspace_id: ws.id, workspace_key: ws.key, api_url: ctx.apiUrl, home: remoteHome,
|
|
298
|
+
sync_state: 'synced', synced_at, files: applied.files, claude_mcp: Object.keys(claudeMcp), claude_mcp_owned: partial.claude_mcp_owned, secrets, skipped: issues, mcp, tunnels };
|
|
299
|
+
manifest.remote_hash = digest(JSON.stringify({ files: applied.files, claude_mcp: claudeMcp, mcp, tunnels }));
|
|
300
|
+
if (marker?.workspace_id !== ws.id || marker.manifest_hash !== manifest.remote_hash)
|
|
301
|
+
await cell.files.write(`${remoteHome}/${markerPath}`, JSON.stringify({ workspace_id: ws.id, manifest_hash: manifest.remote_hash }) + '\n', { createParents: true, mode: '0600' });
|
|
302
|
+
store.update(doc => { const all = object(doc.agent_config); all[key(ctx, ws.id)] = manifest; doc.agent_config = all; });
|
|
303
|
+
const result = { ...empty, copied: applied.copied, removed: applied.removed, skipped: issues, mcp, synced_at };
|
|
304
|
+
if (!ctx.json) {
|
|
305
|
+
for (const issue of issues)
|
|
306
|
+
ctx.io.stderr.write(`Config skipped ${issue.item}: ${issue.reason}\n`);
|
|
307
|
+
for (const server of mcp)
|
|
308
|
+
if (server.status !== 'copied')
|
|
309
|
+
ctx.io.stderr.write(`MCP ${server.app}/${server.name}: ${server.status}${server.reason ? ` (${server.reason})` : ''}\n`);
|
|
310
|
+
}
|
|
311
|
+
return result;
|
|
312
|
+
}
|
|
313
|
+
/** Tunnels are owned by the attachment, and close even if the terminal fails or the user detaches. */
|
|
314
|
+
export async function startAgentConfigTunnels(ctx, ws) {
|
|
315
|
+
const owned = [];
|
|
316
|
+
const tunnels = configManifests(ctx)[key(ctx, ws.id)]?.tunnels ?? [];
|
|
317
|
+
const stop = async () => {
|
|
318
|
+
const closed = await Promise.allSettled(owned.map(t => t.close()));
|
|
319
|
+
const failure = closed.find(r => r.status === 'rejected');
|
|
320
|
+
if (failure?.status === 'rejected')
|
|
321
|
+
throw failure.reason;
|
|
322
|
+
};
|
|
323
|
+
try {
|
|
324
|
+
for (const tunnel of tunnels) {
|
|
325
|
+
const handle = await ws.tunnels.reverse({ remotePort: tunnel.remote_port, target: tunnel.target, bindAddress: '127.0.0.1', signal: ctx.signal });
|
|
326
|
+
// A failed tunnel is shown immediately; its rejection must never become an unhandled promise.
|
|
327
|
+
void handle.closed.catch(() => { ctx.io.stderr.write(`MCP tunnel to ${tunnel.target} closed; attach again to reconnect.\n`); });
|
|
328
|
+
owned.push(handle);
|
|
329
|
+
}
|
|
330
|
+
return { stop };
|
|
331
|
+
}
|
|
332
|
+
catch (error) {
|
|
333
|
+
await stop();
|
|
334
|
+
throw error;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
export const AGENT_CONFIG_HANDLERS = {
|
|
338
|
+
'agents config status'(ctx, values) {
|
|
339
|
+
const workspace = typeof values.workspace === 'string' ? values.workspace : undefined;
|
|
340
|
+
const manifests = Object.values(configManifests(ctx)).filter(m => m.api_url === ctx.apiUrl && (!workspace || m.workspace_id === workspace || m.workspace_key === workspace));
|
|
341
|
+
const report = { observation: 'last sync on this machine', workspaces: manifests };
|
|
342
|
+
ctx.io.stdout.write(ctx.json ? JSON.stringify(report, null, 2) + '\n' : manifests.length ? manifests.map(m => `${m.workspace_key}: ${Object.keys(m.files).length} files; ${m.sync_state === 'synced' ? `last sync ${m.synced_at}` : 'sync interrupted; run config sync again'}\n` +
|
|
343
|
+
m.skipped.map(s => ` skipped ${s.item}: ${s.reason}\n`).join('') +
|
|
344
|
+
m.mcp.map(s => ` MCP ${s.app}/${s.name}: ${s.status}${s.reason ? ` (${s.reason})` : ''}\n`).join('')).join('') : 'No agent config has been synced on this machine.\n');
|
|
345
|
+
return Promise.resolve(0);
|
|
346
|
+
},
|
|
347
|
+
async 'agents config sync'(ctx, values) {
|
|
348
|
+
const workspace = typeof values.workspace === 'string' ? values.workspace : undefined;
|
|
349
|
+
const cloud = ctx.cloud();
|
|
350
|
+
const targets = [];
|
|
351
|
+
if (workspace) {
|
|
352
|
+
const ws = await cloud.workspaces.findByKey(workspace, { includeDeleted: false, signal: ctx.signal });
|
|
353
|
+
targets.push(ws ?? await cloud.workspaces.get(workspace));
|
|
354
|
+
}
|
|
355
|
+
else
|
|
356
|
+
for await (const ws of cloud.workspaces.listAll()) {
|
|
357
|
+
if (ws.labels.shard_project && ws.mode === 'processful')
|
|
358
|
+
targets.push(ws);
|
|
359
|
+
}
|
|
360
|
+
const results = [];
|
|
361
|
+
for (const ws of targets)
|
|
362
|
+
results.push(await syncAgentConfig(ctx, ws));
|
|
363
|
+
ctx.io.stdout.write(ctx.json ? JSON.stringify({ workspaces: results }, null, 2) + '\n' : results.length ? results.map(r => r.synced_at === null ? `${r.workspace_key}: no local agent config found\n` : `${r.workspace_key}: ${r.copied.length} files copied, ${r.removed.length} removed\n`).join('') : 'No agent workspaces found.\n');
|
|
364
|
+
return 0;
|
|
365
|
+
},
|
|
366
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AgentCredentialApp, AgentCredentialFile, Shardflux } from '@shardflux/sdk';
|
|
2
|
+
import type { Handler } from './commands.js';
|
|
3
|
+
export interface CredentialSources {
|
|
4
|
+
home?: string;
|
|
5
|
+
redact?: (value: string) => void;
|
|
6
|
+
}
|
|
7
|
+
/** Codex copies refresh independently. Claude uses its account's cloud login. */
|
|
8
|
+
export declare function readAgentCredentials(opts?: CredentialSources): Promise<Array<{
|
|
9
|
+
app: AgentCredentialApp;
|
|
10
|
+
files: AgentCredentialFile[];
|
|
11
|
+
}>>;
|
|
12
|
+
/** Reusable by the path launcher (L4). Returns metadata only. */
|
|
13
|
+
export declare function syncAgentCredentials(cloud: Shardflux, opts?: CredentialSources): Promise<import("@shardflux/sdk").AgentCredentialMetadata[]>;
|
|
14
|
+
export declare const AGENT_CREDENTIAL_HANDLERS: Record<string, Handler>;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { json } from "./format.js";
|
|
5
|
+
import { UsageError } from "./errors.js";
|
|
6
|
+
import { loginClaudeInWorkspace, requireClaudeTerminal, cloudClaudeLogin } from "./claude-cloud-login.js";
|
|
7
|
+
import { resolveWorkspace } from "./commands.js";
|
|
8
|
+
import { projectIdentity, projectLabels } from "./project-identity.js";
|
|
9
|
+
async function optionalFile(file) {
|
|
10
|
+
try {
|
|
11
|
+
return await readFile(file, 'utf8');
|
|
12
|
+
}
|
|
13
|
+
catch (err) {
|
|
14
|
+
if (err.code === 'ENOENT')
|
|
15
|
+
return null;
|
|
16
|
+
throw new UsageError('Could not read local agent credentials.');
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function object(raw, redact) {
|
|
20
|
+
redact?.(raw);
|
|
21
|
+
let value;
|
|
22
|
+
try {
|
|
23
|
+
value = JSON.parse(raw);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
throw new UsageError('Local agent credentials contain invalid JSON.');
|
|
27
|
+
}
|
|
28
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
29
|
+
throw new UsageError('Local agent credentials must be a JSON object.');
|
|
30
|
+
const hide = (v) => {
|
|
31
|
+
if (typeof v === 'string')
|
|
32
|
+
redact?.(v);
|
|
33
|
+
else if (Array.isArray(v))
|
|
34
|
+
v.forEach(hide);
|
|
35
|
+
else if (v && typeof v === 'object')
|
|
36
|
+
Object.values(v).forEach(hide);
|
|
37
|
+
};
|
|
38
|
+
hide(value);
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
function file(path, value, redact) {
|
|
42
|
+
const content = JSON.stringify(value);
|
|
43
|
+
const content_b64 = Buffer.from(content).toString('base64');
|
|
44
|
+
redact?.(content);
|
|
45
|
+
redact?.(content_b64);
|
|
46
|
+
return { path, content_b64, mode: '0600' };
|
|
47
|
+
}
|
|
48
|
+
/** Codex copies refresh independently. Claude uses its account's cloud login. */
|
|
49
|
+
export async function readAgentCredentials(opts = {}) {
|
|
50
|
+
const home = opts.home ?? homedir();
|
|
51
|
+
const out = [];
|
|
52
|
+
const codex = await optionalFile(join(home, '.codex', 'auth.json'));
|
|
53
|
+
if (codex)
|
|
54
|
+
out.push({ app: 'codex', files: [file('~/.codex/auth.json', object(codex, opts.redact), opts.redact)] });
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
/** Reusable by the path launcher (L4). Returns metadata only. */
|
|
58
|
+
export async function syncAgentCredentials(cloud, opts = {}) {
|
|
59
|
+
const sessions = await readAgentCredentials(opts);
|
|
60
|
+
const metadata = [];
|
|
61
|
+
for (const session of sessions)
|
|
62
|
+
metadata.push(await cloud.agentCredentials.put(session.app, session.files));
|
|
63
|
+
return metadata;
|
|
64
|
+
}
|
|
65
|
+
function output(ctx, value) {
|
|
66
|
+
ctx.io.stdout.write(ctx.json ? json(value) : `${JSON.stringify(value, null, 2)}\n`);
|
|
67
|
+
}
|
|
68
|
+
export const AGENT_CREDENTIAL_HANDLERS = {
|
|
69
|
+
'agents credentials login': async (ctx, values, [app]) => {
|
|
70
|
+
if (app !== 'claude')
|
|
71
|
+
throw new UsageError('Cloud sign-in supports claude; use agents credentials sync for Codex.');
|
|
72
|
+
const existing = await cloudClaudeLogin(ctx);
|
|
73
|
+
if (existing) {
|
|
74
|
+
output(ctx, existing);
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
requireClaudeTerminal(ctx);
|
|
78
|
+
const project = projectIdentity('./');
|
|
79
|
+
const ws = typeof values.workspace === 'string' ? await resolveWorkspace(ctx, values.workspace)
|
|
80
|
+
: await ctx.cloud().workspaces.open({ key: `${project.slug}/main`, template: 'coding', labels: projectLabels(project, 'main', 'claude'), wait: { signal: ctx.signal, timeoutMs: ctx.wakeTimeoutMs } });
|
|
81
|
+
output(ctx, await loginClaudeInWorkspace(ctx, ws));
|
|
82
|
+
return 0;
|
|
83
|
+
},
|
|
84
|
+
'agents credentials sync': async (ctx) => { output(ctx, await syncAgentCredentials(ctx.cloud(), { home: ctx.io.env.HOME ?? ctx.io.env.USERPROFILE, redact: (value) => ctx.addSecret(value) })); return 0; },
|
|
85
|
+
'agents credentials status': async (ctx) => { output(ctx, await ctx.cloud().agentCredentials.list()); return 0; },
|
|
86
|
+
'agents credentials forget': async (ctx) => {
|
|
87
|
+
const cloud = ctx.cloud();
|
|
88
|
+
for (const app of ['claude', 'codex'])
|
|
89
|
+
await cloud.agentCredentials.forget(app);
|
|
90
|
+
output(ctx, { forgotten: ['claude', 'codex'] });
|
|
91
|
+
return 0;
|
|
92
|
+
},
|
|
93
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Workspace } from '@shardflux/sdk';
|
|
2
|
+
import type { Ctx, Handler } from './commands.js';
|
|
3
|
+
type HousekeepingCtx = Pick<Ctx, 'io' | 'apiUrl' | 'signal' | 'store' | 'cloud'>;
|
|
4
|
+
export { needsGitSeed, seedAgentRepository } from './agent-repository.js';
|
|
5
|
+
export declare function rememberGitAgent(ctx: Ctx, ws: Workspace, folder: string): Promise<void>;
|
|
6
|
+
export declare const AGENT_GIT_HANDLERS: Record<string, Handler>;
|
|
7
|
+
/** No Git or network work when no records are due. Active passes are bounded and never infer deletion from an error. */
|
|
8
|
+
export declare function housekeeping(ctx: HousekeepingCtx, now?: () => number, budgetMs?: number): Promise<void>;
|
|
9
|
+
/** Help/version starts also clean saved work, without requiring credentials when nothing is due. */
|
|
10
|
+
export declare function startupHousekeeping(ctx: Pick<Ctx, 'io' | 'store' | 'signal'>): Promise<void>;
|