@assethub/cli 0.1.32 → 0.1.35
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/README.md +53 -26
- package/dist/agentSetup.d.ts +35 -40
- package/dist/agentSetup.d.ts.map +1 -1
- package/dist/agentSetup.js +64 -110
- package/dist/hooks/command.d.ts +1 -0
- package/dist/hooks/command.d.ts.map +1 -1
- package/dist/hooks/command.js +2 -1
- package/dist/hooks/save.d.ts +14 -0
- package/dist/hooks/save.d.ts.map +1 -1
- package/dist/hooks/save.js +20 -0
- package/dist/hooks/types.d.ts +2 -0
- package/dist/hooks/types.d.ts.map +1 -1
- package/dist/hooks/upload.d.ts +6 -0
- package/dist/hooks/upload.d.ts.map +1 -1
- package/dist/hooks/upload.js +16 -2
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +121 -43
- package/dist/productionBatch.d.ts +2 -0
- package/dist/productionBatch.d.ts.map +1 -1
- package/dist/productionBatch.js +17 -1
- package/dist/progressReport.d.ts +42 -0
- package/dist/progressReport.d.ts.map +1 -0
- package/dist/progressReport.js +201 -0
- package/dist/setupCommand.d.ts +44 -2
- package/dist/setupCommand.d.ts.map +1 -1
- package/dist/setupCommand.js +313 -131
- package/package.json +1 -1
- package/skills/assethub/SKILL.md +3 -3
package/dist/setupCommand.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import { constants } from 'node:fs';
|
|
3
|
-
import { access, copyFile, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
4
|
-
import { homedir } from 'node:os';
|
|
3
|
+
import { access, copyFile, mkdir, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
5
4
|
import { delimiter, dirname, join } from 'node:path';
|
|
6
5
|
import { API_KEY_ENV, launchAgentPath, launchAgentPlist, loadKeyIntoLaunchd, readLaunchdKey } from './appEnv.js';
|
|
7
6
|
import { hookScopeError, sessionSaveDisclosure } from './hooks/install.js';
|
|
8
|
-
import {
|
|
7
|
+
import { agentSpec, cliHome, detectAgents, installAgentSkills, mcpServerEntry, mergeJsonServer, readJsonServer, } from './agentSetup.js';
|
|
8
|
+
import { cliVersion, mcpConfig, validatedBaseUrl } from './setup.js';
|
|
9
|
+
// The steps `--only` and `--skip` name, in the order setup runs them. `agents`
|
|
10
|
+
// is not one: the mcp and skills steps need it.
|
|
11
|
+
export const SETUP_STEPS = ['login', 'workspace', 'mcp', 'skills', 'app-env', 'hook', 'doctor'];
|
|
9
12
|
export const redactKey = (key) => `ah_…${key.slice(-4)}`;
|
|
10
13
|
const scrub = (text, key) => key ? text.split(key).join(redactKey(key)) : text;
|
|
11
14
|
const TOML_HEADER = /^\s*\[\[?\s*([^\]]+?)\s*\]\]?\s*(#.*)?$/;
|
|
@@ -107,15 +110,48 @@ export const claudeServerConfig = (baseUrl, workspaceId) => ({
|
|
|
107
110
|
...(workspaceId ? { 'X-AssetHub-Workspace': workspaceId } : {}),
|
|
108
111
|
},
|
|
109
112
|
});
|
|
110
|
-
export const claudeAddArgs = (baseUrl, workspaceId) => [
|
|
111
|
-
'mcp', 'add-json', 'assethub', JSON.stringify(claudeServerConfig(baseUrl, workspaceId)), '--scope',
|
|
113
|
+
export const claudeAddArgs = (baseUrl, workspaceId, scope = 'user') => [
|
|
114
|
+
'mcp', 'add-json', 'assethub', JSON.stringify(claudeServerConfig(baseUrl, workspaceId)), '--scope', scope,
|
|
112
115
|
];
|
|
113
116
|
const shellQuoteSingle = (value) => `'${value.replace(/'/g, `'\\''`)}'`;
|
|
117
|
+
/** "A", "A and B", "A, B and C". */
|
|
118
|
+
const listJoin = (items) => items.length <= 1 ? (items[0] ?? '') : `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`;
|
|
119
|
+
// How each agent's desktop app is named in restart instructions.
|
|
120
|
+
const APP_NAMES = { 'claude-code': 'Claude (Cmd+Q)', codex: 'Codex', cursor: 'Cursor' };
|
|
114
121
|
export const runSetup = async (options, deps) => {
|
|
115
122
|
const steps = [];
|
|
116
123
|
const say = (line) => deps.log(line);
|
|
117
|
-
const
|
|
118
|
-
const
|
|
124
|
+
const skipped = (step) => options.skip?.includes(step) ?? false;
|
|
125
|
+
const scope = options.project ? 'project' : 'global';
|
|
126
|
+
const root = options.project ? deps.cwd : deps.home;
|
|
127
|
+
const needsAgents = !skipped('mcp') || !skipped('skills') || !skipped('hook');
|
|
128
|
+
// 0. Agents: the ones asked for, or every one found on this machine. Nothing
|
|
129
|
+
// else is changed when there is none to configure.
|
|
130
|
+
const version = await deps.version();
|
|
131
|
+
const detected = needsAgents ? await deps.detectAgents() : [];
|
|
132
|
+
const agents = options.agents ?? detected;
|
|
133
|
+
const agentNames = agents.map(agent => agentSpec(agent).name);
|
|
134
|
+
const failEarly = (step, nextStep) => ({
|
|
135
|
+
ok: false,
|
|
136
|
+
dryRun: options.dryRun,
|
|
137
|
+
profile: '',
|
|
138
|
+
baseUrl: '',
|
|
139
|
+
version,
|
|
140
|
+
scope,
|
|
141
|
+
agents,
|
|
142
|
+
detected,
|
|
143
|
+
steps: [step],
|
|
144
|
+
nextStep,
|
|
145
|
+
});
|
|
146
|
+
// MCP needs an agent to configure; the skill copy alone does not.
|
|
147
|
+
if (!skipped('mcp') && agents.length === 0)
|
|
148
|
+
return failEarly({ name: 'agents', status: 'fail', detail: `no Claude Code, Codex or Cursor found under ${deps.home} or on PATH` }, 'Install a coding agent, or pass --agent claude-code|codex|cursor to configure one anyway.');
|
|
149
|
+
if (needsAgents && agents.length === 0)
|
|
150
|
+
steps.push({ name: 'agents', status: 'skip', detail: 'no coding agent found; the skill is installed without agent links' });
|
|
151
|
+
else if (needsAgents)
|
|
152
|
+
steps.push({ name: 'agents', status: 'ok', detail: `${listJoin(agentNames)}${options.agents ? '' : ' (detected)'}` });
|
|
153
|
+
const wantClaude = agents.includes('claude-code');
|
|
154
|
+
const wantCodex = agents.includes('codex');
|
|
119
155
|
const hookClients = [...(wantClaude ? ['claude'] : []), ...(wantCodex ? ['codex'] : [])];
|
|
120
156
|
const hookNames = hookClients.map(client => (client === 'claude' ? 'Claude Code' : 'Codex')).join(' and ');
|
|
121
157
|
let hookRefusal;
|
|
@@ -126,59 +162,72 @@ export const runSetup = async (options, deps) => {
|
|
|
126
162
|
break;
|
|
127
163
|
}
|
|
128
164
|
}
|
|
129
|
-
//
|
|
165
|
+
// --save-sessions asks for something the home folder cannot have, so stop
|
|
130
166
|
// before login, MCP or app-env changes rather than half-applying setup.
|
|
131
|
-
if (hookRefusal && options.saveSessions && !options.noHook)
|
|
132
|
-
return {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
};
|
|
140
|
-
}
|
|
141
|
-
// 1. Login (reuses the existing `auth login` path through deps.login)
|
|
142
|
-
const explicitKey = options.apiKeySource === 'flag' ? options.apiKey : undefined;
|
|
143
|
-
const hadKey = explicitKey ? false : await deps.hasWorkingKey();
|
|
144
|
-
const givenKey = explicitKey ?? (options.apiKeySource === 'env' ? options.apiKey : undefined);
|
|
145
|
-
const givenFrom = explicitKey ? '--api-key' : 'ASSETHUB_API_KEY';
|
|
146
|
-
if (hadKey) {
|
|
147
|
-
steps.push({ name: 'login', status: 'ok', detail: 'reusing the saved API key' });
|
|
148
|
-
}
|
|
149
|
-
else if (options.dryRun) {
|
|
150
|
-
steps.push({
|
|
151
|
-
name: 'login',
|
|
152
|
-
status: 'skip',
|
|
153
|
-
detail: givenKey
|
|
154
|
-
? `would verify and save the API key from ${givenFrom}`
|
|
155
|
-
: options.apiKeyStdin
|
|
156
|
-
? 'would read the API key from stdin'
|
|
157
|
-
: 'would prompt for an API key',
|
|
158
|
-
});
|
|
167
|
+
if (hookRefusal && options.saveSessions && !options.noHook && !skipped('hook'))
|
|
168
|
+
return failEarly({ name: 'hook', status: 'fail', detail: hookRefusal }, 'Run `assethub setup --save-sessions` inside the project folder whose sessions should be saved, or run it without --save-sessions.');
|
|
169
|
+
// 1. Login (reuses the existing `auth login` path through deps.login). With
|
|
170
|
+
// login skipped, a saved key is still used for app-env and doctor if there is one.
|
|
171
|
+
let auth;
|
|
172
|
+
let hadKey = false;
|
|
173
|
+
if (skipped('login')) {
|
|
174
|
+
auth = await deps.resolveAuth().catch(() => undefined);
|
|
159
175
|
}
|
|
160
176
|
else {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
177
|
+
const explicitKey = options.apiKeySource === 'flag' ? options.apiKey : undefined;
|
|
178
|
+
hadKey = explicitKey ? false : await deps.hasWorkingKey();
|
|
179
|
+
const givenKey = explicitKey ?? (options.apiKeySource === 'env' ? options.apiKey : undefined);
|
|
180
|
+
const givenFrom = explicitKey ? '--api-key' : 'ASSETHUB_API_KEY';
|
|
181
|
+
if (hadKey) {
|
|
182
|
+
steps.push({ name: 'login', status: 'ok', detail: 'reusing the saved API key' });
|
|
166
183
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
184
|
+
else if (options.dryRun) {
|
|
185
|
+
steps.push({
|
|
186
|
+
name: 'login',
|
|
187
|
+
status: 'skip',
|
|
188
|
+
detail: givenKey
|
|
189
|
+
? `would verify and save the API key from ${givenFrom}`
|
|
190
|
+
: options.apiKeyStdin
|
|
191
|
+
? 'would read the API key from stdin'
|
|
192
|
+
: 'would prompt for an API key',
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
let apiKey = givenKey;
|
|
197
|
+
if (!apiKey && !options.apiKeyStdin) {
|
|
198
|
+
if (!deps.interactive)
|
|
199
|
+
throw new Error('No saved API key. Re-run with --api-key-stdin, set ASSETHUB_API_KEY, or run interactively to be prompted.');
|
|
200
|
+
apiKey = await deps.promptApiKey();
|
|
201
|
+
}
|
|
202
|
+
await deps.login({ apiKey });
|
|
203
|
+
steps.push({
|
|
204
|
+
name: 'login',
|
|
205
|
+
status: 'ok',
|
|
206
|
+
detail: givenKey ? `API key from ${givenFrom} verified and saved` : 'API key verified and saved',
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
if (!options.dryRun || hadKey)
|
|
210
|
+
auth = await deps.resolveAuth();
|
|
173
211
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
auth = await deps.resolveAuth();
|
|
212
|
+
// Without a login, the saved profile still names the server and workspace.
|
|
213
|
+
const target = auth ?? (skipped('login') ? await deps.resolveTarget() : undefined);
|
|
177
214
|
const key = auth?.apiKey ?? '';
|
|
178
|
-
const baseUrl = (
|
|
215
|
+
const baseUrl = (target?.baseUrl ?? '').replace(/\/+$/, '');
|
|
216
|
+
// Every agent entry is built from this URL: refuse a bad one before writing any of them.
|
|
217
|
+
if (baseUrl) {
|
|
218
|
+
try {
|
|
219
|
+
validatedBaseUrl(baseUrl);
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
return failEarly({ name: 'mcp', status: 'fail', detail: `${baseUrl}: ${error instanceof Error ? error.message : String(error)}` }, 'Pass --base-url with an HTTPS origin (HTTP only for localhost), then re-run setup.');
|
|
223
|
+
}
|
|
224
|
+
}
|
|
179
225
|
// 2. Workspace
|
|
180
|
-
let workspaceId =
|
|
181
|
-
if (
|
|
226
|
+
let workspaceId = target?.workspaceId;
|
|
227
|
+
if (skipped('workspace')) {
|
|
228
|
+
workspaceId = options.workspace ?? workspaceId;
|
|
229
|
+
}
|
|
230
|
+
else if (options.workspace) {
|
|
182
231
|
workspaceId = options.workspace;
|
|
183
232
|
if (options.dryRun) {
|
|
184
233
|
steps.push({ name: 'workspace', status: 'skip', detail: `would select workspace ${workspaceId}` });
|
|
@@ -209,114 +258,198 @@ export const runSetup = async (options, deps) => {
|
|
|
209
258
|
auth = await deps.resolveAuth();
|
|
210
259
|
steps.push({ name: 'workspace', status: 'ok', detail: `selected ${workspaceId}` });
|
|
211
260
|
}
|
|
212
|
-
|
|
261
|
+
// A placeholder is shown only in a dry run that would still ask for the workspace.
|
|
262
|
+
const workspaceForConfig = workspaceId ?? (options.dryRun && !skipped('workspace') ? '<workspace-id>' : undefined);
|
|
213
263
|
const shownKey = key ? redactKey(key) : 'ah_…<key>';
|
|
214
|
-
|
|
264
|
+
const writesConfig = !skipped('mcp') || !skipped('skills');
|
|
265
|
+
if (writesConfig &&
|
|
266
|
+
!options.dryRun &&
|
|
215
267
|
deps.interactive &&
|
|
216
268
|
!options.yes &&
|
|
217
|
-
!(await deps.confirm(
|
|
218
|
-
throw new Error('Cancelled before changing any
|
|
219
|
-
//
|
|
220
|
-
|
|
269
|
+
!(await deps.confirm(`Write AssetHub configuration for ${listJoin(agentNames)}${options.project ? ` in ${deps.cwd}` : ''}?`)))
|
|
270
|
+
throw new Error('Cancelled before changing any agent configuration.');
|
|
271
|
+
// Writes a merged config file, backing up the previous one; reports "unchanged" when nothing differs.
|
|
272
|
+
const writeConfig = async (name, path, existing, merged, what, diff) => {
|
|
273
|
+
if (merged === existing) {
|
|
274
|
+
steps.push({ name, status: 'ok', detail: `${path} already up to date`, change: 'unchanged', path });
|
|
275
|
+
}
|
|
276
|
+
else if (options.dryRun) {
|
|
277
|
+
say(`[dry-run] would ${existing === undefined ? 'create' : 'update'} ${path}`);
|
|
278
|
+
if (existing !== undefined)
|
|
279
|
+
say(`[dry-run] would back up to ${path}.bak-<timestamp>`);
|
|
280
|
+
for (const line of diff)
|
|
281
|
+
say(` ${line}`);
|
|
282
|
+
steps.push({ name, status: 'skip', detail: `would write ${what} to ${path} (dry run)`, change: 'would-update', path });
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
let backup = '';
|
|
286
|
+
if (existing !== undefined) {
|
|
287
|
+
// Never overwrite an earlier backup made in the same instant, even by
|
|
288
|
+
// another setup running at the same time: the copy refuses an existing name.
|
|
289
|
+
const base = `${path}.bak-${timestamp(deps.now())}`;
|
|
290
|
+
for (let n = 0;; n++) {
|
|
291
|
+
backup = n === 0 ? base : `${base}-${n}`;
|
|
292
|
+
if ((await deps.readFileIfExists(backup)) !== undefined)
|
|
293
|
+
continue;
|
|
294
|
+
try {
|
|
295
|
+
await deps.backupFile(path, backup);
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
if (error.code !== 'EEXIST')
|
|
300
|
+
throw error;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
await deps.writeFileEnsuringDir(path, merged);
|
|
305
|
+
steps.push({ name, status: 'ok', detail: `wrote ${what} to ${path}${backup ? ` (backup: ${backup})` : ''}`, change: 'updated', path });
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
// 3. MCP: one writer per agent. Claude Code owns ~/.claude.json, so it is
|
|
309
|
+
// changed through `claude mcp` and read only to skip an identical entry. When
|
|
310
|
+
// `claude` is not on PATH (an IDE extension bundles its own), the same entry is
|
|
311
|
+
// written into the same file directly, as `claude mcp add-json` would.
|
|
312
|
+
if (!skipped('mcp') && wantClaude) {
|
|
313
|
+
const claudeScope = options.project ? 'project' : 'user';
|
|
314
|
+
const configPath = options.project ? join(deps.cwd, '.mcp.json') : join(deps.home, '.claude.json');
|
|
315
|
+
const desired = claudeServerConfig(baseUrl || '<base-url>', workspaceForConfig);
|
|
316
|
+
const configText = await deps.readFileIfExists(configPath);
|
|
317
|
+
const current = readJsonServer(configText);
|
|
221
318
|
const claude = await deps.findBinary('claude');
|
|
222
|
-
const args = claudeAddArgs(baseUrl || '<base-url>', workspaceForConfig);
|
|
319
|
+
const args = claudeAddArgs(baseUrl || '<base-url>', workspaceForConfig, claudeScope);
|
|
223
320
|
const shown = `claude ${args.slice(0, 3).join(' ')} ${shellQuoteSingle(args[3])} ${args.slice(4).join(' ')}`;
|
|
224
|
-
if (
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
321
|
+
if (JSON.stringify(current) === JSON.stringify(desired)) {
|
|
322
|
+
steps.push({ name: 'claude-mcp', status: 'ok', detail: `assethub is already registered in Claude Code (${claudeScope} scope)`, change: 'unchanged', path: configPath });
|
|
323
|
+
}
|
|
324
|
+
else if (!claude && !baseUrl) {
|
|
325
|
+
// Nothing real to write yet (a dry run before login): show the command instead.
|
|
326
|
+
say(`claude not found on PATH. Setup will write ${configPath} directly, or run:\n ${shown}`);
|
|
327
|
+
steps.push({ name: 'claude-mcp', status: 'skip', detail: `would write mcpServers.assethub to ${configPath} (dry run)`, change: 'would-update', path: configPath });
|
|
328
|
+
}
|
|
329
|
+
else if (!claude) {
|
|
330
|
+
let merged;
|
|
331
|
+
try {
|
|
332
|
+
merged = mergeJsonServer(configPath, configText, desired);
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
say(`Run this once ${configPath} is fixed:\n ${shown}`);
|
|
336
|
+
steps.push({ name: 'claude-mcp', status: 'fail', detail: `${configPath} left unchanged: ${error instanceof Error ? error.message : String(error)}`, path: configPath });
|
|
337
|
+
}
|
|
338
|
+
if (merged !== undefined)
|
|
339
|
+
await writeConfig('claude-mcp', configPath, configText, merged, 'mcpServers.assethub directly (claude CLI not on PATH)', sectionDiff(current === undefined ? '' : JSON.stringify(current, null, 2), JSON.stringify(desired, null, 2)));
|
|
233
340
|
}
|
|
234
341
|
else if (options.dryRun) {
|
|
235
342
|
say(`[dry-run] ${shown}`);
|
|
236
|
-
|
|
237
|
-
|
|
343
|
+
if (current !== undefined)
|
|
344
|
+
say(`[dry-run] the existing assethub entry would be replaced: claude mcp remove assethub --scope ${claudeScope}, then add again`);
|
|
345
|
+
steps.push({ name: 'claude-mcp', status: 'skip', detail: 'would register the assethub MCP server (dry run)', change: 'would-update', path: configPath });
|
|
238
346
|
}
|
|
239
347
|
else {
|
|
240
348
|
// Add first, and replace only an entry that already exists, so a failing
|
|
241
349
|
// add never costs the user a working one.
|
|
242
|
-
|
|
243
|
-
let added = await deps.run(claude, addArgs);
|
|
350
|
+
let added = await deps.run(claude, args);
|
|
244
351
|
let replaced = false;
|
|
352
|
+
let restored;
|
|
353
|
+
let removeFailure;
|
|
245
354
|
if (added.code !== 0 && /already exists/i.test(added.output)) {
|
|
246
|
-
await deps.run(claude, ['mcp', 'remove', 'assethub', '--scope',
|
|
247
|
-
|
|
248
|
-
|
|
355
|
+
const removed = await deps.run(claude, ['mcp', 'remove', 'assethub', '--scope', claudeScope]);
|
|
356
|
+
if (removed.code !== 0) {
|
|
357
|
+
removeFailure = scrub(removed.output, key).trim().slice(0, 300);
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
replaced = true;
|
|
361
|
+
added = await deps.run(claude, args);
|
|
362
|
+
// Put the previous entry back rather than leave Claude Code without one.
|
|
363
|
+
if (added.code !== 0 && current !== undefined)
|
|
364
|
+
restored =
|
|
365
|
+
(await deps.run(claude, ['mcp', 'add-json', 'assethub', JSON.stringify(current), '--scope', claudeScope]))
|
|
366
|
+
.code === 0;
|
|
367
|
+
}
|
|
249
368
|
}
|
|
250
369
|
const failure = scrub(added.output, key).trim().slice(0, 300);
|
|
251
|
-
steps.push(
|
|
370
|
+
steps.push(removeFailure !== undefined
|
|
252
371
|
? {
|
|
253
|
-
name: 'claude-mcp',
|
|
254
|
-
status: 'ok',
|
|
255
|
-
detail: `registered assethub in Claude Code (user scope; it reads the key from \$${API_KEY_ENV}, the key is not stored in Claude Code)`,
|
|
256
|
-
}
|
|
257
|
-
: {
|
|
258
372
|
name: 'claude-mcp',
|
|
259
373
|
status: 'fail',
|
|
260
|
-
detail:
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
374
|
+
detail: `the existing assethub entry was left as it is: claude mcp remove failed: ${removeFailure}`,
|
|
375
|
+
path: configPath,
|
|
376
|
+
}
|
|
377
|
+
: added.code === 0
|
|
378
|
+
? {
|
|
379
|
+
name: 'claude-mcp',
|
|
380
|
+
status: 'ok',
|
|
381
|
+
detail: `registered assethub in Claude Code (${claudeScope} scope; it reads the key from \$${API_KEY_ENV}, the key is not stored in Claude Code)`,
|
|
382
|
+
change: 'updated',
|
|
383
|
+
path: configPath,
|
|
384
|
+
}
|
|
385
|
+
: {
|
|
386
|
+
name: 'claude-mcp',
|
|
387
|
+
status: 'fail',
|
|
388
|
+
detail: !replaced
|
|
389
|
+
? `claude mcp add failed: ${failure}`
|
|
390
|
+
: restored
|
|
391
|
+
? `claude mcp add failed, so the previous assethub entry was put back: ${failure}`
|
|
392
|
+
: `claude mcp add failed after the old assethub entry was removed; run \`${shown}\` to add it again: ${failure}`,
|
|
393
|
+
path: configPath,
|
|
394
|
+
});
|
|
264
395
|
}
|
|
265
396
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
if (wantCodex) {
|
|
269
|
-
const path = join(deps.codexHome, 'config.toml');
|
|
397
|
+
if (!skipped('mcp') && wantCodex) {
|
|
398
|
+
const path = options.project ? join(deps.cwd, '.codex', 'config.toml') : join(deps.codexHome, 'config.toml');
|
|
270
399
|
const section = mcpConfig('codex', baseUrl || 'https://app.assethub.io', false, workspaceId);
|
|
271
400
|
const existing = await deps.readFileIfExists(path);
|
|
272
401
|
let merged;
|
|
273
|
-
let mergeError;
|
|
274
402
|
try {
|
|
275
403
|
merged = mergeCodexSection(existing ?? '', section);
|
|
276
404
|
}
|
|
277
405
|
catch (error) {
|
|
278
|
-
|
|
279
|
-
}
|
|
280
|
-
if (merged === undefined) {
|
|
281
|
-
steps.push({ name: 'codex-mcp', status: 'fail', detail: `${path} left unchanged: ${mergeError}` });
|
|
406
|
+
steps.push({ name: 'codex-mcp', status: 'fail', detail: `${path} left unchanged: ${error instanceof Error ? error.message : String(error)}`, path });
|
|
282
407
|
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
408
|
+
if (merged !== undefined)
|
|
409
|
+
await writeConfig('codex-mcp', path, existing, merged, '[mcp_servers.assethub]', sectionDiff(extractCodexSection(existing ?? ''), extractCodexSection(merged)));
|
|
410
|
+
}
|
|
411
|
+
if (!skipped('mcp') && agents.includes('cursor')) {
|
|
412
|
+
const path = join(root, '.cursor', 'mcp.json');
|
|
413
|
+
const existing = await deps.readFileIfExists(path);
|
|
414
|
+
const entry = mcpServerEntry('cursor', baseUrl || 'https://app.assethub.io', workspaceId);
|
|
415
|
+
let merged;
|
|
416
|
+
try {
|
|
417
|
+
merged = mergeJsonServer(path, existing, entry);
|
|
290
418
|
}
|
|
291
|
-
|
|
292
|
-
steps.push({ name: '
|
|
419
|
+
catch (error) {
|
|
420
|
+
steps.push({ name: 'cursor-mcp', status: 'fail', detail: `${path} left unchanged: ${error instanceof Error ? error.message : String(error)}`, path });
|
|
293
421
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
// Never overwrite an earlier backup made in the same instant.
|
|
298
|
-
const base = `${path}.bak-${timestamp(deps.now())}`;
|
|
299
|
-
backup = base;
|
|
300
|
-
for (let n = 1; (await deps.readFileIfExists(backup)) !== undefined; n++)
|
|
301
|
-
backup = `${base}-${n}`;
|
|
302
|
-
await deps.backupFile(path, backup);
|
|
303
|
-
}
|
|
304
|
-
await deps.writeFileEnsuringDir(path, merged);
|
|
305
|
-
steps.push({ name: 'codex-mcp', status: 'ok', detail: `wrote [mcp_servers.assethub] to ${path}${backup ? ` (backup: ${backup})` : ''}` });
|
|
422
|
+
if (merged !== undefined) {
|
|
423
|
+
const before = readJsonServer(existing);
|
|
424
|
+
await writeConfig('cursor-mcp', path, existing, merged, 'mcpServers.assethub', sectionDiff(before === undefined ? '' : JSON.stringify(before, null, 2), JSON.stringify(entry, null, 2)));
|
|
306
425
|
}
|
|
307
426
|
}
|
|
308
|
-
//
|
|
309
|
-
|
|
427
|
+
// Every agent reads the key from the environment; the MCP server needs nothing else.
|
|
428
|
+
if (!skipped('mcp'))
|
|
429
|
+
say(`${listJoin(agentNames)} ${agents.length > 1 ? 'read' : 'reads'} the key from the ${API_KEY_ENV} environment variable: export it in your shell (run \`assethub setup --print-env\` to print the line). Setup never writes it to a shell profile.`);
|
|
430
|
+
let exportLine;
|
|
310
431
|
if (options.printEnv)
|
|
311
432
|
exportLine = `export ${API_KEY_ENV}=${options.dryRun || !key ? shownKey : key}`;
|
|
433
|
+
// 4. Skill: one copy under <root>/.agents/skills, linked into each agent.
|
|
434
|
+
let skill;
|
|
435
|
+
if (!skipped('skills')) {
|
|
436
|
+
try {
|
|
437
|
+
skill = await deps.installSkills({ root, agents, dryRun: options.dryRun });
|
|
438
|
+
steps.push(skillsStep(skill, options.dryRun));
|
|
439
|
+
}
|
|
440
|
+
catch (error) {
|
|
441
|
+
steps.push({ name: 'skills', status: 'fail', detail: error instanceof Error ? error.message : String(error) });
|
|
442
|
+
}
|
|
443
|
+
}
|
|
312
444
|
// 5. Make the key visible to apps started outside a shell (macOS).
|
|
313
|
-
const appEnv = await ensureAppEnv(options, deps, key);
|
|
314
|
-
|
|
445
|
+
const appEnv = skipped('app-env') ? undefined : await ensureAppEnv(options, deps, key);
|
|
446
|
+
if (appEnv)
|
|
447
|
+
steps.push(appEnv);
|
|
315
448
|
// 6. Session saving: opt-in, and offered only to accounts that may upload
|
|
316
449
|
// sessions (internal for now). Anyone else gets no question and no step.
|
|
317
|
-
const sessionsAvailable = auth != null && (await deps.canSaveSessions(auth));
|
|
450
|
+
const sessionsAvailable = !skipped('hook') && hookClients.length > 0 && auth != null && (await deps.canSaveSessions(auth));
|
|
318
451
|
if (!sessionsAvailable) {
|
|
319
|
-
if (options.saveSessions)
|
|
452
|
+
if (options.saveSessions && !skipped('hook'))
|
|
320
453
|
steps.push({ name: 'hook', status: 'skip', detail: 'session saving is not available for this account' });
|
|
321
454
|
}
|
|
322
455
|
else if (options.noHook) {
|
|
@@ -383,7 +516,10 @@ export const runSetup = async (options, deps) => {
|
|
|
383
516
|
}
|
|
384
517
|
}
|
|
385
518
|
// 7. Doctor
|
|
386
|
-
if (
|
|
519
|
+
if (skipped('doctor')) {
|
|
520
|
+
// left out on request
|
|
521
|
+
}
|
|
522
|
+
else if (options.dryRun) {
|
|
387
523
|
steps.push({ name: 'doctor', status: 'skip', detail: 'skipped (dry run)' });
|
|
388
524
|
}
|
|
389
525
|
else {
|
|
@@ -403,24 +539,56 @@ export const runSetup = async (options, deps) => {
|
|
|
403
539
|
}
|
|
404
540
|
}
|
|
405
541
|
const ok = steps.every(step => step.status !== 'fail');
|
|
542
|
+
const apps = listJoin(agents.map(agent => APP_NAMES[agent]));
|
|
406
543
|
const nextStep = options.dryRun
|
|
407
544
|
? 'Re-run without --dry-run to apply these changes.'
|
|
408
545
|
: !ok
|
|
409
546
|
? 'Fix the failed steps above, then re-run `assethub setup` (it is safe to repeat).'
|
|
410
|
-
:
|
|
411
|
-
?
|
|
412
|
-
|
|
547
|
+
: skipped('mcp')
|
|
548
|
+
? agents.length > 0
|
|
549
|
+
? `Restart ${listJoin(agentNames)} so ${agents.length > 1 ? 'they reload their' : 'it reloads its'} skills.`
|
|
550
|
+
: 'Done.'
|
|
551
|
+
: appEnv?.status === 'ok'
|
|
552
|
+
? `Quit and reopen ${apps} so it reads ${API_KEY_ENV}, then ask it to use the AssetHub tools. Terminal tabs opened before setup still need \`export ${API_KEY_ENV}=…\`.`
|
|
553
|
+
: `Export ${API_KEY_ENV} in the shell that starts ${agentNames.join(' / ')}, restart it, then ask it to use the AssetHub tools.`;
|
|
413
554
|
return {
|
|
414
555
|
ok,
|
|
415
556
|
dryRun: options.dryRun,
|
|
416
557
|
profile: auth?.profile ?? '',
|
|
417
558
|
baseUrl,
|
|
418
559
|
workspaceId,
|
|
560
|
+
version,
|
|
561
|
+
scope,
|
|
562
|
+
agents,
|
|
563
|
+
detected,
|
|
564
|
+
...(skill ? { skill } : {}),
|
|
419
565
|
steps,
|
|
420
566
|
nextStep,
|
|
421
567
|
...(exportLine ? { exportLine } : {}),
|
|
422
568
|
};
|
|
423
569
|
};
|
|
570
|
+
const skillsStep = (report, dryRun) => {
|
|
571
|
+
const changedLinks = report.links.filter(link => link.status === 'linked' || link.status === 'relinked');
|
|
572
|
+
const keptLinks = report.links.filter(link => link.status === 'kept-existing');
|
|
573
|
+
const copyChanged = report.status === 'installed' || report.status === 'updated';
|
|
574
|
+
const changed = copyChanged || changedLinks.length > 0;
|
|
575
|
+
const parts = [
|
|
576
|
+
report.status === 'kept-existing'
|
|
577
|
+
? `kept ${report.path} (not created by AssetHub)`
|
|
578
|
+
: `${dryRun && copyChanged ? `would ${report.status === 'installed' ? 'install' : 'update'}` : report.status} ${report.path}${report.version ? ` (${report.version})` : ''}`,
|
|
579
|
+
...(changedLinks.length > 0
|
|
580
|
+
? [`${dryRun ? 'would link' : 'linked'} into ${listJoin(changedLinks.map(link => agentSpec(link.agent).name))}`]
|
|
581
|
+
: []),
|
|
582
|
+
...keptLinks.map(link => `kept ${link.path} (not created by AssetHub)`),
|
|
583
|
+
];
|
|
584
|
+
return {
|
|
585
|
+
name: 'skills',
|
|
586
|
+
path: report.path,
|
|
587
|
+
status: dryRun && changed ? 'skip' : 'ok',
|
|
588
|
+
detail: changed || report.links.length === 0 ? parts.join('; ') : `${parts.join('; ')}; links already in place`,
|
|
589
|
+
change: !changed ? 'unchanged' : dryRun ? 'would-update' : 'updated',
|
|
590
|
+
};
|
|
591
|
+
};
|
|
424
592
|
const APP_ENV_MISSING = `Claude and Codex apps cannot see ${API_KEY_ENV} yet, so their MCP calls get HTTP 401`;
|
|
425
593
|
const ensureAppEnv = async (options, deps, key) => {
|
|
426
594
|
const name = 'app-env';
|
|
@@ -492,11 +660,14 @@ export const runProcess = (file, args) => new Promise(done => {
|
|
|
492
660
|
});
|
|
493
661
|
export const defaultFileDeps = () => ({
|
|
494
662
|
platform: process.platform,
|
|
495
|
-
home:
|
|
663
|
+
home: cliHome(),
|
|
496
664
|
cwd: process.cwd(),
|
|
497
|
-
codexHome: process.env.CODEX_HOME?.trim() || join(
|
|
665
|
+
codexHome: process.env.CODEX_HOME?.trim() || join(cliHome(), '.codex'),
|
|
498
666
|
now: () => new Date(),
|
|
499
667
|
findBinary: (name) => findOnPath(name),
|
|
668
|
+
detectAgents: () => detectAgents(cliHome(), name => findOnPath(name), process.env.CODEX_HOME?.trim() || undefined),
|
|
669
|
+
version: cliVersion,
|
|
670
|
+
installSkills: installAgentSkills,
|
|
500
671
|
run: runProcess,
|
|
501
672
|
readFileIfExists: async (path) => {
|
|
502
673
|
try {
|
|
@@ -508,11 +679,22 @@ export const defaultFileDeps = () => ({
|
|
|
508
679
|
throw error;
|
|
509
680
|
}
|
|
510
681
|
},
|
|
682
|
+
// Written beside the target and renamed over it, so an interrupted setup never
|
|
683
|
+
// leaves a truncated config. A symlinked config (dotfiles) is replaced at its target.
|
|
511
684
|
writeFileEnsuringDir: async (path, content) => {
|
|
512
|
-
await
|
|
513
|
-
await
|
|
685
|
+
const target = await realpath(path).catch(() => path);
|
|
686
|
+
await mkdir(dirname(target), { recursive: true });
|
|
687
|
+
const temporary = `${target}.assethub-${process.pid}-${Date.now()}.tmp`;
|
|
688
|
+
try {
|
|
689
|
+
await writeFile(temporary, content, { mode: await stat(target).then(info => info.mode & 0o777, () => 0o644) });
|
|
690
|
+
await rename(temporary, target);
|
|
691
|
+
}
|
|
692
|
+
catch (error) {
|
|
693
|
+
await rm(temporary, { force: true });
|
|
694
|
+
throw error;
|
|
695
|
+
}
|
|
514
696
|
},
|
|
515
|
-
backupFile: (from, to) => copyFile(from, to),
|
|
697
|
+
backupFile: (from, to) => copyFile(from, to, constants.COPYFILE_EXCL),
|
|
516
698
|
});
|
|
517
699
|
// ---- interactive prompts (TTY only; prompts go to stderr so stdout stays clean) ----
|
|
518
700
|
export const promptLine = async (question) => {
|
package/package.json
CHANGED
package/skills/assethub/SKILL.md
CHANGED
|
@@ -12,7 +12,7 @@ description: |
|
|
|
12
12
|
- Recording whether a generated asset is acceptable
|
|
13
13
|
|
|
14
14
|
DO NOT TRIGGER for:
|
|
15
|
-
- Installing or configuring the CLI (use `assethub
|
|
15
|
+
- Installing or configuring the CLI (use `assethub setup`, `assethub doctor --setup`)
|
|
16
16
|
- Managing workspace members (use `assethub workspace ...`)
|
|
17
17
|
---
|
|
18
18
|
|
|
@@ -115,7 +115,7 @@ assethub evaluations submit --canvas <canvas-id> --artifact <asset-id> \
|
|
|
115
115
|
|
|
116
116
|
- `production run` defaults to `full_auto`, which never pauses for a human. **Always pass `--max-cost-credits <n>`.** The server caps `--max-iterations` at 5.
|
|
117
117
|
- Over MCP the same rule applies: always set `maxCostCredits` on `production_run`.
|
|
118
|
-
- `
|
|
118
|
+
- Before a batch, run `production run --image <file> --estimate` to see the cost. The CLI has no balance command; find the credit routes with `assethub api search credit`.
|
|
119
119
|
- Prefer a dedicated, scoped API key for agent work rather than a full-access one.
|
|
120
120
|
|
|
121
121
|
## Rules learned the hard way
|
|
@@ -124,7 +124,7 @@ assethub evaluations submit --canvas <canvas-id> --artifact <asset-id> \
|
|
|
124
124
|
- Part extractor names are the public product names: `V1.5`, `V2.0 alpha`, `V2.1 alpha`. Internal IDs are not accepted.
|
|
125
125
|
- `parts split` and `parts compare` with `--preprocess-prompt` create a second production order; use `--all-ready`, not `--task-id`.
|
|
126
126
|
- Every generation should land on a canvas. Pass `--canvas <id>` to keep a job's steps together; without it the CLI makes one canvas per working directory. `assethub canvas open <id>` shows the user what happened.
|
|
127
|
-
- A complaint like "the mesh has extra limbs" is usually an input problem: stray lines, shadows, or inconsistent views. Clean the image
|
|
127
|
+
- A complaint like "the mesh has extra limbs" is usually an input problem: stray lines, shadows, or inconsistent views. Clean the image before switching models: `image generate --file <image> --prompt "remove stray lines and shadows, plain background"`, or `parts compare --preprocess-prompt <text>`.
|
|
128
128
|
- Input can come from a file, a URL, stdin bytes, base64, a data URI, an OpenAI- or Anthropic-style JSON attachment (`--stdin-json`), or the clipboard. You never need to write a temporary file first.
|
|
129
129
|
- Large or complex requests: `--input-json @request.json` with the schema from `api describe`.
|
|
130
130
|
|