@nocobase/app-cli-client 0.1.0-beta.2 → 0.1.0-beta.20
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/LICENSE.txt +129 -0
- package/README.md +12 -0
- package/dist/acp-service.d.ts +52 -0
- package/dist/acp-service.d.ts.map +1 -0
- package/dist/acp-service.js +329 -0
- package/dist/acp-service.js.map +1 -0
- package/dist/acp.d.ts +28 -0
- package/dist/acp.d.ts.map +1 -0
- package/dist/acp.js +414 -0
- package/dist/acp.js.map +1 -0
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +10 -0
- package/dist/commands.js.map +1 -1
- package/dist/dynamic/index.d.ts +3 -1
- package/dist/dynamic/index.d.ts.map +1 -1
- package/dist/dynamic/index.js +48 -12
- package/dist/dynamic/index.js.map +1 -1
- package/dist/dynamic/manifest.d.ts.map +1 -1
- package/dist/dynamic/manifest.js +10 -0
- package/dist/dynamic/manifest.js.map +1 -1
- package/dist/dynamic/output.d.ts +2 -2
- package/dist/dynamic/output.d.ts.map +1 -1
- package/dist/dynamic/output.js +23 -8
- package/dist/dynamic/output.js.map +1 -1
- package/dist/dynamic/rest.d.ts.map +1 -1
- package/dist/dynamic/rest.js +24 -0
- package/dist/dynamic/rest.js.map +1 -1
- package/dist/dynamic/session.d.ts +1 -0
- package/dist/dynamic/session.d.ts.map +1 -1
- package/dist/dynamic/session.js +29 -1
- package/dist/dynamic/session.js.map +1 -1
- package/dist/lib/command.d.ts.map +1 -1
- package/dist/lib/command.js +14 -4
- package/dist/lib/command.js.map +1 -1
- package/dist/lib/credentials.d.ts +2 -0
- package/dist/lib/credentials.d.ts.map +1 -1
- package/dist/lib/credentials.js +7 -3
- package/dist/lib/credentials.js.map +1 -1
- package/dist/lib/envelope.d.ts +2 -0
- package/dist/lib/envelope.d.ts.map +1 -1
- package/dist/lib/envelope.js +16 -2
- package/dist/lib/envelope.js.map +1 -1
- package/dist/lib/http.d.ts.map +1 -1
- package/dist/lib/http.js +1 -0
- package/dist/lib/http.js.map +1 -1
- package/dist/parse/arguments.d.ts.map +1 -1
- package/dist/parse/arguments.js +9 -0
- package/dist/parse/arguments.js.map +1 -1
- package/dist/parse/distance.d.ts +22 -0
- package/dist/parse/distance.d.ts.map +1 -0
- package/dist/parse/distance.js +82 -0
- package/dist/parse/distance.js.map +1 -0
- package/dist/parse/manifest.d.ts +10 -0
- package/dist/parse/manifest.d.ts.map +1 -1
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +36 -5
- package/dist/run.js.map +1 -1
- package/dist/skills-lock.d.ts +2 -0
- package/dist/skills-lock.d.ts.map +1 -0
- package/dist/skills-lock.js +151 -0
- package/dist/skills-lock.js.map +1 -0
- package/dist/skills.d.ts +73 -0
- package/dist/skills.d.ts.map +1 -0
- package/dist/skills.js +728 -0
- package/dist/skills.js.map +1 -0
- package/dist/update.d.ts +4 -0
- package/dist/update.d.ts.map +1 -1
- package/dist/update.js +36 -1
- package/dist/update.js.map +1 -1
- package/dist/whoami.d.ts +2 -0
- package/dist/whoami.d.ts.map +1 -1
- package/dist/whoami.js +10 -3
- package/dist/whoami.js.map +1 -1
- package/package.json +5 -4
package/dist/skills.js
ADDED
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
// Local installation of the Skills shipped by a branded CLI (every `skills/<slug>/SKILL.md` in its package). No session, credential, or network is read.
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { constants, lstatSync, readdirSync } from 'node:fs';
|
|
4
|
+
import { access, lstat, stat as followStat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, writeFile, } from 'node:fs/promises';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { Flags } from '@oclif/core';
|
|
8
|
+
import { EXIT_CODES } from '@nocobase/agent-protocol';
|
|
9
|
+
import { appCliHome } from "./config.js";
|
|
10
|
+
import { AppCommand } from "./lib/command.js";
|
|
11
|
+
import { findRunCredentials } from "./lib/credentials.js";
|
|
12
|
+
import { CliCommandError } from "./lib/envelope.js";
|
|
13
|
+
import { withSkillLock } from "./skills-lock.js";
|
|
14
|
+
// The Skill every Studio CLI has shipped since the commands existed. It alone has a legacy Codex location and feeds ACP sessions.
|
|
15
|
+
const SKILL = 'nb-studio-cli';
|
|
16
|
+
// A slug names a directory below a tool's skills root, so it may not hold a separator and may not start with a dot.
|
|
17
|
+
const SLUG = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
18
|
+
const TOOLS = ['claude', 'codex'];
|
|
19
|
+
/** `skills`, and the earlier result's `skill` and `hash` for the primary Skill. */
|
|
20
|
+
function shippedSummary(sources) {
|
|
21
|
+
const skills = [...sources].map(([skill, source]) => ({
|
|
22
|
+
skill,
|
|
23
|
+
hash: source.hash,
|
|
24
|
+
}));
|
|
25
|
+
const primary = skills.find(({ skill }) => skill === 'nb-studio-cli') ?? skills[0];
|
|
26
|
+
return { skills, skill: primary?.skill ?? '', hash: primary?.hash ?? '' };
|
|
27
|
+
}
|
|
28
|
+
function sourceOf(environment, skill) {
|
|
29
|
+
return path.resolve(environment.packageRoot, 'skills', skill);
|
|
30
|
+
}
|
|
31
|
+
/** The Skills a package ships: directories of `<packageRoot>/skills` holding SKILL.md, sorted by slug. */
|
|
32
|
+
export function shippedSkills(packageRoot) {
|
|
33
|
+
const root = path.join(packageRoot, 'skills');
|
|
34
|
+
try {
|
|
35
|
+
return readdirSync(root, { withFileTypes: true })
|
|
36
|
+
.filter((entry) => {
|
|
37
|
+
if (!entry.isDirectory() || !SLUG.test(entry.name))
|
|
38
|
+
return false;
|
|
39
|
+
try {
|
|
40
|
+
return lstatSync(path.join(root, entry.name, 'SKILL.md')).isFile();
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
.map((entry) => entry.name)
|
|
47
|
+
.sort();
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Only packages shipping at least one Skill expose the two commands. */
|
|
54
|
+
export function hasStudioSkill(config) {
|
|
55
|
+
return (config.selfUpdate !== undefined &&
|
|
56
|
+
shippedSkills(config.selfUpdate.packageRoot).length > 0);
|
|
57
|
+
}
|
|
58
|
+
export function skillsEnvironment(config) {
|
|
59
|
+
if (config.selfUpdate === undefined)
|
|
60
|
+
throw new Error('This CLI has no packaged Skill.');
|
|
61
|
+
return {
|
|
62
|
+
packageRoot: config.selfUpdate.packageRoot,
|
|
63
|
+
cliVersion: config.version ?? 'unknown',
|
|
64
|
+
bin: config.bin,
|
|
65
|
+
stateHome: appCliHome(config),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function locations(environment, skills = shippedSkills(environment.packageRoot)) {
|
|
69
|
+
const home = environment.home ?? os.homedir();
|
|
70
|
+
const env = environment.env ?? process.env;
|
|
71
|
+
const configs = {
|
|
72
|
+
claude: path.resolve(env.CLAUDE_CONFIG_DIR || path.join(home, '.claude')),
|
|
73
|
+
codex: path.resolve(env.CODEX_HOME || path.join(home, '.codex')),
|
|
74
|
+
};
|
|
75
|
+
const roots = {
|
|
76
|
+
claude: path.join(configs.claude, 'skills'),
|
|
77
|
+
codex: path.resolve(home, '.agents', 'skills'),
|
|
78
|
+
};
|
|
79
|
+
return {
|
|
80
|
+
configs,
|
|
81
|
+
roots,
|
|
82
|
+
defaults: skills.flatMap((skill) => TOOLS.map((tool) => ({
|
|
83
|
+
skill,
|
|
84
|
+
tool,
|
|
85
|
+
path: path.join(roots[tool], skill),
|
|
86
|
+
}))),
|
|
87
|
+
// Only nb-studio-cli was ever installed below CODEX_HOME; newer Skills have no legacy location.
|
|
88
|
+
legacy: skills.includes(SKILL)
|
|
89
|
+
? [
|
|
90
|
+
{
|
|
91
|
+
skill: SKILL,
|
|
92
|
+
tool: 'codex',
|
|
93
|
+
path: path.join(configs.codex, 'skills', SKILL),
|
|
94
|
+
},
|
|
95
|
+
]
|
|
96
|
+
: [],
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
async function detected(tool, environment) {
|
|
100
|
+
const env = environment.env ?? process.env;
|
|
101
|
+
if (env[tool === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME'])
|
|
102
|
+
return true;
|
|
103
|
+
if ((await stat(locations(environment).configs[tool]))?.isDirectory())
|
|
104
|
+
return true;
|
|
105
|
+
const names = process.platform === 'win32' ? ['', '.exe', '.cmd', '.bat'] : [''];
|
|
106
|
+
for (const dir of (env.PATH ?? '').split(path.delimiter).filter(Boolean)) {
|
|
107
|
+
for (const suffix of names) {
|
|
108
|
+
const executable = path.join(dir, `${tool}${suffix}`);
|
|
109
|
+
try {
|
|
110
|
+
if (!(await followStat(executable)).isFile())
|
|
111
|
+
continue;
|
|
112
|
+
await access(executable, constants.X_OK);
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
/* An inaccessible executable is not evidence of a usable tool. */
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
function selection(tools) {
|
|
123
|
+
if (tools.some((tool) => ![...TOOLS, 'all'].includes(tool)))
|
|
124
|
+
throw new CliCommandError('SKILLS_INVALID_TOOL', 'Choose --tool claude, codex, or all.', { exit: EXIT_CODES.validation });
|
|
125
|
+
return tools.includes('all')
|
|
126
|
+
? [...TOOLS]
|
|
127
|
+
: TOOLS.filter((tool) => tools.includes(tool));
|
|
128
|
+
}
|
|
129
|
+
async function stat(file) {
|
|
130
|
+
try {
|
|
131
|
+
return await lstat(file);
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
if (error.code === 'ENOENT')
|
|
135
|
+
return undefined;
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// Reject links at every writable component, including a dangling link. We never follow a link to replace its target.
|
|
140
|
+
async function safePath(file, directory = true) {
|
|
141
|
+
const absolute = path.resolve(file);
|
|
142
|
+
let current = path.parse(absolute).root;
|
|
143
|
+
for (const part of absolute
|
|
144
|
+
.slice(current.length)
|
|
145
|
+
.split(path.sep)
|
|
146
|
+
.filter(Boolean)) {
|
|
147
|
+
current = path.join(current, part);
|
|
148
|
+
const info = await stat(current);
|
|
149
|
+
if (info === undefined)
|
|
150
|
+
continue;
|
|
151
|
+
if (info.isSymbolicLink() ||
|
|
152
|
+
(directory || current !== absolute ? !info.isDirectory() : !info.isFile()))
|
|
153
|
+
throw new CliCommandError('SKILLS_UNSAFE_PATH', `Refusing a symbolic link or unexpected file type: ${current}`, { exit: EXIT_CODES.validation });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
async function snapshot(root) {
|
|
157
|
+
await safePath(root);
|
|
158
|
+
const entries = [];
|
|
159
|
+
async function visit(dir, relative) {
|
|
160
|
+
for (const name of (await readdir(dir)).sort()) {
|
|
161
|
+
const child = path.join(dir, name);
|
|
162
|
+
const rel = relative ? `${relative}/${name}` : name;
|
|
163
|
+
const info = await lstat(child);
|
|
164
|
+
if (info.isDirectory()) {
|
|
165
|
+
entries.push({ name: rel });
|
|
166
|
+
await visit(child, rel);
|
|
167
|
+
}
|
|
168
|
+
else if (info.isFile()) {
|
|
169
|
+
// O_NOFOLLOW also closes the file-link race between lstat and open on supported platforms.
|
|
170
|
+
const handle = await open(child, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
171
|
+
try {
|
|
172
|
+
entries.push({ name: rel, bytes: await handle.readFile() });
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
await handle.close();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
else
|
|
179
|
+
throw new CliCommandError('SKILLS_UNSAFE_PATH', `Refusing a symbolic link or special file inside the Skill: ${child}`, { exit: EXIT_CODES.validation });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
await visit(root, '');
|
|
183
|
+
const hash = createHash('sha256')
|
|
184
|
+
.update(JSON.stringify(entries.map((entry) => [
|
|
185
|
+
entry.name,
|
|
186
|
+
entry.bytes === undefined
|
|
187
|
+
? 'directory'
|
|
188
|
+
: createHash('sha256').update(entry.bytes).digest('hex'),
|
|
189
|
+
])))
|
|
190
|
+
.digest('hex');
|
|
191
|
+
return { entries, hash };
|
|
192
|
+
}
|
|
193
|
+
async function bundled(environment, skill) {
|
|
194
|
+
const result = await snapshot(sourceOf(environment, skill));
|
|
195
|
+
if (!result.entries.some((entry) => entry.name === 'SKILL.md' && entry.bytes !== undefined))
|
|
196
|
+
throw new Error(`The packaged ${skill} Skill has no SKILL.md.`);
|
|
197
|
+
return result;
|
|
198
|
+
}
|
|
199
|
+
async function bundledAll(environment) {
|
|
200
|
+
const slugs = shippedSkills(environment.packageRoot);
|
|
201
|
+
if (slugs.length === 0)
|
|
202
|
+
throw new Error('This CLI package ships no Skills.');
|
|
203
|
+
return new Map(await Promise.all(slugs.map(async (slug) => [slug, await bundled(environment, slug)])));
|
|
204
|
+
}
|
|
205
|
+
function registryFile(environment) {
|
|
206
|
+
return path.resolve(environment.stateHome, 'skills.json');
|
|
207
|
+
}
|
|
208
|
+
async function readRegistry(environment) {
|
|
209
|
+
const file = registryFile(environment);
|
|
210
|
+
await safePath(file, false);
|
|
211
|
+
if ((await stat(file)) === undefined)
|
|
212
|
+
return { version: 2, targets: [] };
|
|
213
|
+
const invalid = () => new CliCommandError('SKILLS_INVALID_REGISTRY', `Invalid Skill installation record: ${file}`, { exit: EXIT_CODES.validation });
|
|
214
|
+
const data = JSON.parse(await readFile(file, 'utf8'));
|
|
215
|
+
if (data === null || typeof data !== 'object')
|
|
216
|
+
throw invalid();
|
|
217
|
+
// A newer format may not even have targets. Never reinterpret or rewrite it.
|
|
218
|
+
if (typeof data.version === 'number' && data.version > 2)
|
|
219
|
+
return { version: 2, targets: [], newer: true };
|
|
220
|
+
if (!Array.isArray(data.targets))
|
|
221
|
+
throw invalid();
|
|
222
|
+
// The original file has no version and one top-level `skill`; its targets inherit it.
|
|
223
|
+
const legacy = data.version === undefined;
|
|
224
|
+
if (legacy ? data.skill !== SKILL : data.version !== 2)
|
|
225
|
+
throw invalid();
|
|
226
|
+
const targets = data.targets.map((target) => ({
|
|
227
|
+
...target,
|
|
228
|
+
skill: legacy ? SKILL : target?.skill,
|
|
229
|
+
}));
|
|
230
|
+
if (targets.some((target) => !target ||
|
|
231
|
+
typeof target.skill !== 'string' ||
|
|
232
|
+
!SLUG.test(target.skill) ||
|
|
233
|
+
!TOOLS.includes(target.tool) ||
|
|
234
|
+
typeof target.path !== 'string' ||
|
|
235
|
+
!path.isAbsolute(target.path) ||
|
|
236
|
+
path.basename(target.path) !== target.skill ||
|
|
237
|
+
path.basename(path.dirname(target.path)) !== 'skills' ||
|
|
238
|
+
typeof target.cliVersion !== 'string' ||
|
|
239
|
+
typeof target.hash !== 'string'))
|
|
240
|
+
throw invalid();
|
|
241
|
+
return { version: 2, targets };
|
|
242
|
+
}
|
|
243
|
+
function requireWritableRegistry(registry, environment) {
|
|
244
|
+
if (registry.newer)
|
|
245
|
+
throw new CliCommandError('SKILLS_NEWER_REGISTRY', `The Skill installation record uses a newer format. Update ${environment.bin} before installing Skills.`, { exit: EXIT_CODES.validation });
|
|
246
|
+
}
|
|
247
|
+
async function writeRegistry(environment, registry) {
|
|
248
|
+
requireWritableRegistry(registry, environment);
|
|
249
|
+
const file = registryFile(environment);
|
|
250
|
+
await safePath(file, false);
|
|
251
|
+
const temporary = await mkdtemp(path.join(environment.stateHome, '.skills-record-'));
|
|
252
|
+
try {
|
|
253
|
+
const staged = path.join(temporary, 'skills.json');
|
|
254
|
+
await writeFile(staged, `${JSON.stringify(registry, null, 2)}\n`, {
|
|
255
|
+
mode: 0o600,
|
|
256
|
+
});
|
|
257
|
+
await rename(staged, file);
|
|
258
|
+
}
|
|
259
|
+
finally {
|
|
260
|
+
await rm(temporary, { recursive: true, force: true });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
async function lock(file, work) {
|
|
264
|
+
await safePath(path.dirname(file));
|
|
265
|
+
return withSkillLock(file, work);
|
|
266
|
+
}
|
|
267
|
+
async function statusOf(target, environment, source, registry) {
|
|
268
|
+
const record = registry.targets.find((record) => path.resolve(record.path) === path.resolve(target.path));
|
|
269
|
+
const base = {
|
|
270
|
+
...target,
|
|
271
|
+
legacy: target.tool === 'codex' &&
|
|
272
|
+
target.path !==
|
|
273
|
+
path.join(locations(environment).roots.codex, target.skill),
|
|
274
|
+
...(source === undefined ? { dropped: true } : {}),
|
|
275
|
+
registered: record !== undefined,
|
|
276
|
+
...(record ? { installedCliVersion: record.cliVersion } : {}),
|
|
277
|
+
};
|
|
278
|
+
try {
|
|
279
|
+
await safePath(target.path);
|
|
280
|
+
const present = (await stat(target.path)) !== undefined;
|
|
281
|
+
return {
|
|
282
|
+
...base,
|
|
283
|
+
present,
|
|
284
|
+
upToDate: source !== undefined &&
|
|
285
|
+
present &&
|
|
286
|
+
(await snapshot(target.path)).hash === source.hash,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
return {
|
|
291
|
+
...base,
|
|
292
|
+
present: (await stat(target.path).catch(() => undefined)) !== undefined,
|
|
293
|
+
upToDate: false,
|
|
294
|
+
error: error instanceof Error ? error.message : String(error),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
function unique(targets) {
|
|
299
|
+
return [
|
|
300
|
+
...new Map(targets.map((target) => {
|
|
301
|
+
const normalized = path.resolve(target.path);
|
|
302
|
+
return [normalized, { ...target, path: normalized }];
|
|
303
|
+
})).values(),
|
|
304
|
+
];
|
|
305
|
+
}
|
|
306
|
+
/** Includes manual and legacy copies, and previously registered paths after environment variables change. */
|
|
307
|
+
export async function skillsStatus(environment, tools = []) {
|
|
308
|
+
const selected = selection(tools);
|
|
309
|
+
const sources = await bundledAll(environment);
|
|
310
|
+
const registry = await readRegistry(environment);
|
|
311
|
+
const { defaults, legacy } = locations(environment);
|
|
312
|
+
const targets = unique([...defaults, ...legacy, ...registry.targets]).filter((target) => selected.length === 0 || selected.includes(target.tool));
|
|
313
|
+
return {
|
|
314
|
+
...shippedSummary(sources),
|
|
315
|
+
cliVersion: environment.cliVersion,
|
|
316
|
+
targets: await Promise.all(targets.map((target) => statusOf(target, environment, sources.get(target.skill), registry))),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
function overlaps(first, second) {
|
|
320
|
+
const contains = (a, b) => b === a || b.startsWith(`${a}${path.sep}`);
|
|
321
|
+
return (contains(path.resolve(first), path.resolve(second)) ||
|
|
322
|
+
contains(path.resolve(second), path.resolve(first)));
|
|
323
|
+
}
|
|
324
|
+
// Check the complete plan before replacing any directory: replacing a parent would delete a child Skill.
|
|
325
|
+
function targetConflicts(targets) {
|
|
326
|
+
const destinations = unique(targets);
|
|
327
|
+
const conflicts = new Map();
|
|
328
|
+
for (let index = 0; index < destinations.length; index++) {
|
|
329
|
+
const first = destinations[index];
|
|
330
|
+
for (const second of destinations.slice(index + 1)) {
|
|
331
|
+
if (!overlaps(first.path, second.path))
|
|
332
|
+
continue;
|
|
333
|
+
const error = new CliCommandError('SKILLS_TARGET_OVERLAP', `Skill destinations contain one another: ${first.path} and ${second.path}. Choose separate tool configuration directories before installing.`, {
|
|
334
|
+
exit: EXIT_CODES.validation,
|
|
335
|
+
details: { paths: [first.path, second.path] },
|
|
336
|
+
});
|
|
337
|
+
conflicts.set(first.path, error);
|
|
338
|
+
conflicts.set(second.path, error);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return conflicts;
|
|
342
|
+
}
|
|
343
|
+
async function replace(target, environment, source, registry) {
|
|
344
|
+
requireWritableRegistry(registry, environment);
|
|
345
|
+
if (overlaps(target.path, environment.packageRoot) ||
|
|
346
|
+
overlaps(target.path, environment.stateHome))
|
|
347
|
+
throw new CliCommandError('SKILLS_UNSAFE_PATH', `The Skill destination overlaps its package or CLI state: ${target.path}`, { exit: EXIT_CODES.validation });
|
|
348
|
+
await safePath(target.path);
|
|
349
|
+
const ready = await reusable(target, environment, source, registry);
|
|
350
|
+
if (ready !== undefined)
|
|
351
|
+
return ready;
|
|
352
|
+
return lock(path.join(path.dirname(target.path), '.nb-studio-cli.lock'), async () => {
|
|
353
|
+
await safePath(target.path);
|
|
354
|
+
const ready = await reusable(target, environment, source, registry);
|
|
355
|
+
if (ready !== undefined)
|
|
356
|
+
return ready;
|
|
357
|
+
const before = await statusOf(target, environment, source, registry);
|
|
358
|
+
if (before.error)
|
|
359
|
+
throw new CliCommandError('SKILLS_UNSAFE_PATH', before.error, {
|
|
360
|
+
exit: EXIT_CODES.validation,
|
|
361
|
+
});
|
|
362
|
+
const action = before.upToDate
|
|
363
|
+
? 'unchanged'
|
|
364
|
+
: before.present
|
|
365
|
+
? 'update'
|
|
366
|
+
: 'install';
|
|
367
|
+
const record = {
|
|
368
|
+
...target,
|
|
369
|
+
cliVersion: environment.cliVersion,
|
|
370
|
+
hash: source.hash,
|
|
371
|
+
};
|
|
372
|
+
const next = {
|
|
373
|
+
version: 2,
|
|
374
|
+
targets: [
|
|
375
|
+
...registry.targets.filter((item) => path.resolve(item.path) !== path.resolve(target.path)),
|
|
376
|
+
record,
|
|
377
|
+
],
|
|
378
|
+
};
|
|
379
|
+
if (action === 'unchanged')
|
|
380
|
+
await writeRegistry(environment, next);
|
|
381
|
+
else {
|
|
382
|
+
const temporary = await mkdtemp(path.join(path.dirname(target.path), '.nb-studio-cli-stage-'));
|
|
383
|
+
const staged = path.join(temporary, target.skill);
|
|
384
|
+
const backup = path.join(temporary, 'previous');
|
|
385
|
+
let moved = false;
|
|
386
|
+
let activated = false;
|
|
387
|
+
let committed = false;
|
|
388
|
+
try {
|
|
389
|
+
await mkdir(staged);
|
|
390
|
+
for (const entry of source.entries) {
|
|
391
|
+
const file = path.join(staged, ...entry.name.split('/'));
|
|
392
|
+
if (entry.bytes === undefined)
|
|
393
|
+
await mkdir(file);
|
|
394
|
+
else
|
|
395
|
+
await writeFile(file, entry.bytes);
|
|
396
|
+
}
|
|
397
|
+
await safePath(target.path);
|
|
398
|
+
if (before.present) {
|
|
399
|
+
await rename(target.path, backup);
|
|
400
|
+
moved = true;
|
|
401
|
+
}
|
|
402
|
+
await rename(staged, target.path);
|
|
403
|
+
activated = true;
|
|
404
|
+
await writeRegistry(environment, next);
|
|
405
|
+
committed = true;
|
|
406
|
+
}
|
|
407
|
+
catch (error) {
|
|
408
|
+
try {
|
|
409
|
+
if (activated)
|
|
410
|
+
await rm(target.path, { recursive: true, force: true });
|
|
411
|
+
if (moved) {
|
|
412
|
+
await rename(backup, target.path);
|
|
413
|
+
moved = false;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
catch (recovery) {
|
|
417
|
+
throw new CliCommandError('SKILLS_RECOVERY_FAILED', `Skill replacement failed and recovery needs help. The previous copy is at ${backup}: ${recovery instanceof Error ? recovery.message : String(recovery)}`, {
|
|
418
|
+
details: {
|
|
419
|
+
cause: error instanceof Error ? error.message : String(error),
|
|
420
|
+
backup,
|
|
421
|
+
},
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
throw error;
|
|
425
|
+
}
|
|
426
|
+
finally {
|
|
427
|
+
// If rollback itself fails, keep the backup for recovery rather than deleting the old copy.
|
|
428
|
+
if (committed || !moved)
|
|
429
|
+
await rm(temporary, { recursive: true, force: true }).catch(() => undefined);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
registry.targets = next.targets;
|
|
433
|
+
return {
|
|
434
|
+
...before,
|
|
435
|
+
action,
|
|
436
|
+
present: true,
|
|
437
|
+
upToDate: true,
|
|
438
|
+
registered: true,
|
|
439
|
+
installedCliVersion: environment.cliVersion,
|
|
440
|
+
};
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
async function reusable(target, environment, source, registry) {
|
|
444
|
+
const record = registry.targets.find((record) => path.resolve(record.path) === path.resolve(target.path));
|
|
445
|
+
if (record?.tool !== target.tool ||
|
|
446
|
+
record.hash !== source.hash ||
|
|
447
|
+
record.cliVersion !== environment.cliVersion)
|
|
448
|
+
return undefined;
|
|
449
|
+
const status = await statusOf(target, environment, source, registry);
|
|
450
|
+
if (!status.upToDate || status.error)
|
|
451
|
+
return undefined;
|
|
452
|
+
return { ...status, action: 'unchanged' };
|
|
453
|
+
}
|
|
454
|
+
/** Replaces only the Skills the package ships and registers successful installations for self-update. */
|
|
455
|
+
export async function installSkills(environment, tools = [], dryRun = false) {
|
|
456
|
+
const sources = await bundledAll(environment);
|
|
457
|
+
let selected = selection(tools);
|
|
458
|
+
if (selected.length === 0)
|
|
459
|
+
selected = (await Promise.all(TOOLS.map(async (tool) => (await detected(tool, environment)) ? tool : undefined))).filter((tool) => tool !== undefined);
|
|
460
|
+
if (selected.length === 0)
|
|
461
|
+
throw new CliCommandError('SKILLS_NO_TOOL', 'No Claude Code or Codex installation was detected. Choose a tool explicitly.', {
|
|
462
|
+
exit: EXIT_CODES.validation,
|
|
463
|
+
suggestions: TOOLS.map((tool) => ({
|
|
464
|
+
message: `Install for ${tool}.`,
|
|
465
|
+
run: {
|
|
466
|
+
command: environment.bin,
|
|
467
|
+
args: ['skills', 'install', '--tool', tool],
|
|
468
|
+
},
|
|
469
|
+
})),
|
|
470
|
+
});
|
|
471
|
+
const targets = unique(locations(environment).defaults.filter((target) => selected.includes(target.tool)));
|
|
472
|
+
const result = (targets) => ({
|
|
473
|
+
...shippedSummary(sources),
|
|
474
|
+
cliVersion: environment.cliVersion,
|
|
475
|
+
dryRun,
|
|
476
|
+
targets,
|
|
477
|
+
});
|
|
478
|
+
const perform = async () => {
|
|
479
|
+
const registry = await readRegistry(environment);
|
|
480
|
+
requireWritableRegistry(registry, environment);
|
|
481
|
+
const conflict = targetConflicts([...targets, ...registry.targets])
|
|
482
|
+
.values()
|
|
483
|
+
.next().value;
|
|
484
|
+
if (conflict !== undefined)
|
|
485
|
+
throw conflict;
|
|
486
|
+
const results = [];
|
|
487
|
+
for (const target of targets) {
|
|
488
|
+
if (overlaps(target.path, environment.packageRoot) ||
|
|
489
|
+
overlaps(target.path, environment.stateHome))
|
|
490
|
+
throw new CliCommandError('SKILLS_UNSAFE_PATH', `The Skill destination overlaps its package or CLI state: ${target.path}`, { exit: EXIT_CODES.validation });
|
|
491
|
+
if (dryRun) {
|
|
492
|
+
const status = await statusOf(target, environment, sources.get(target.skill), registry);
|
|
493
|
+
if (status.error)
|
|
494
|
+
throw new CliCommandError('SKILLS_UNSAFE_PATH', status.error, {
|
|
495
|
+
exit: EXIT_CODES.validation,
|
|
496
|
+
});
|
|
497
|
+
results.push({
|
|
498
|
+
...status,
|
|
499
|
+
action: status.upToDate
|
|
500
|
+
? 'unchanged'
|
|
501
|
+
: status.present
|
|
502
|
+
? 'update'
|
|
503
|
+
: 'install',
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
else
|
|
507
|
+
results.push(await replace(target, environment, sources.get(target.skill), registry));
|
|
508
|
+
}
|
|
509
|
+
return result(results);
|
|
510
|
+
};
|
|
511
|
+
if (!dryRun) {
|
|
512
|
+
const registry = await readRegistry(environment);
|
|
513
|
+
requireWritableRegistry(registry, environment);
|
|
514
|
+
const conflict = targetConflicts([...targets, ...registry.targets])
|
|
515
|
+
.values()
|
|
516
|
+
.next().value;
|
|
517
|
+
if (conflict !== undefined)
|
|
518
|
+
throw conflict;
|
|
519
|
+
const ready = await Promise.all(targets.map(async (target) => {
|
|
520
|
+
if (overlaps(target.path, environment.packageRoot) ||
|
|
521
|
+
overlaps(target.path, environment.stateHome))
|
|
522
|
+
return undefined;
|
|
523
|
+
return reusable(target, environment, sources.get(target.skill), registry);
|
|
524
|
+
}));
|
|
525
|
+
if (ready.every((target) => target !== undefined))
|
|
526
|
+
return result(ready);
|
|
527
|
+
}
|
|
528
|
+
return dryRun
|
|
529
|
+
? perform()
|
|
530
|
+
: lock(path.join(environment.stateHome, '.skills.lock'), perform);
|
|
531
|
+
}
|
|
532
|
+
/** Preserve native Skill installation for the tools with a managed destination. */
|
|
533
|
+
export async function prepareSessionSkill(environment, agentId) {
|
|
534
|
+
if (TOOLS.some((tool) => tool === agentId)) {
|
|
535
|
+
try {
|
|
536
|
+
await installSkills(environment, [agentId]);
|
|
537
|
+
}
|
|
538
|
+
catch (error) {
|
|
539
|
+
if (!(error instanceof CliCommandError) ||
|
|
540
|
+
error.code !== 'SKILLS_NEWER_REGISTRY')
|
|
541
|
+
throw error;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
/** ACP text is the common fallback; it needs no agent-specific install directory. */
|
|
546
|
+
export async function sessionSkillInstructions(environment, agentId) {
|
|
547
|
+
if (TOOLS.some((tool) => tool === agentId))
|
|
548
|
+
return undefined;
|
|
549
|
+
try {
|
|
550
|
+
const source = await bundled(environment, SKILL);
|
|
551
|
+
const skill = source.entries.find((entry) => entry.name === 'SKILL.md');
|
|
552
|
+
const text = skill.bytes.toString('utf8');
|
|
553
|
+
if (!text.trim())
|
|
554
|
+
throw new Error('The packaged Skill is empty.');
|
|
555
|
+
return [
|
|
556
|
+
'# Studio session instructions',
|
|
557
|
+
'The shared nb-studio-cli Skill is supplied below for this session. Follow it directly; no Skill installation is needed.',
|
|
558
|
+
`Resolve any relative Skill references from ${sourceOf(environment, SKILL)}.`,
|
|
559
|
+
'Keep the bridge-selected profile and agent session environment. Do not switch identity or remove session context to bypass a refusal.',
|
|
560
|
+
text,
|
|
561
|
+
'# Current message',
|
|
562
|
+
].join('\n\n');
|
|
563
|
+
}
|
|
564
|
+
catch (cause) {
|
|
565
|
+
throw new Error(`Could not load the packaged nb-studio-cli instructions. Update or reinstall ${environment.bin}, then restart the bridge and resend the message.`, { cause });
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
// A Skill the new package no longer ships. Only a copy we registered whose content still matches what we wrote is
|
|
569
|
+
// removed; an edited copy is kept with a warning, and either way the record is dropped so the warning is not repeated.
|
|
570
|
+
async function retire(record, environment) {
|
|
571
|
+
await safePath(record.path);
|
|
572
|
+
if ((await stat(record.path)) === undefined)
|
|
573
|
+
return { action: 'removed' };
|
|
574
|
+
if (overlaps(record.path, environment.packageRoot) ||
|
|
575
|
+
overlaps(record.path, environment.stateHome) ||
|
|
576
|
+
(await snapshot(record.path)).hash !== record.hash)
|
|
577
|
+
return {
|
|
578
|
+
action: 'kept',
|
|
579
|
+
message: `The ${record.skill} Skill is no longer shipped; its copy at ${record.path} was changed or cannot be removed safely, so it was left in place.`,
|
|
580
|
+
};
|
|
581
|
+
await rm(record.path, { recursive: true });
|
|
582
|
+
return { action: 'removed' };
|
|
583
|
+
}
|
|
584
|
+
/** Called after CLI activation, reading the new package. Failures are warnings, never CLI update failures. */
|
|
585
|
+
export async function syncInstalledSkills(environment) {
|
|
586
|
+
const warnings = [];
|
|
587
|
+
const targets = [];
|
|
588
|
+
const advise = (message) => {
|
|
589
|
+
warnings.push(`${message} Run \`${environment.bin} skills install\` (use --tool claude or --tool codex to select explicitly).`);
|
|
590
|
+
};
|
|
591
|
+
try {
|
|
592
|
+
// Do not create state or tool directories on machines that never installed this Skill.
|
|
593
|
+
const registry = await readRegistry(environment);
|
|
594
|
+
requireWritableRegistry(registry, environment);
|
|
595
|
+
const shipped = shippedSkills(environment.packageRoot);
|
|
596
|
+
const { defaults, legacy } = locations(environment, shipped);
|
|
597
|
+
for (const target of unique([...defaults, ...legacy])) {
|
|
598
|
+
try {
|
|
599
|
+
if ((await stat(target.path)) !== undefined &&
|
|
600
|
+
!registry.targets.some((record) => path.resolve(record.path) === path.resolve(target.path)))
|
|
601
|
+
advise(`An unregistered ${target.skill} copy at ${target.path} was left unchanged.`);
|
|
602
|
+
}
|
|
603
|
+
catch (error) {
|
|
604
|
+
advise(`Could not inspect the Skill location ${target.path}: ${error instanceof Error ? error.message : String(error)}.`);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
if (registry.targets.length > 0)
|
|
608
|
+
await lock(path.join(environment.stateHome, '.skills.lock'), async () => {
|
|
609
|
+
const current = await readRegistry(environment);
|
|
610
|
+
requireWritableRegistry(current, environment);
|
|
611
|
+
const kept = current.targets.filter((target) => shipped.includes(target.skill));
|
|
612
|
+
const dropped = current.targets.filter((target) => !shipped.includes(target.skill));
|
|
613
|
+
const destinations = unique(kept);
|
|
614
|
+
const conflicts = targetConflicts(destinations);
|
|
615
|
+
// Read each source inside its attempt so every registered target has a failure result if it is missing.
|
|
616
|
+
for (const target of destinations) {
|
|
617
|
+
try {
|
|
618
|
+
const conflict = conflicts.get(target.path);
|
|
619
|
+
if (conflict !== undefined)
|
|
620
|
+
throw conflict;
|
|
621
|
+
const result = await replace(target, environment, await bundled(environment, target.skill), current);
|
|
622
|
+
targets.push({
|
|
623
|
+
skill: target.skill,
|
|
624
|
+
tool: target.tool,
|
|
625
|
+
path: target.path,
|
|
626
|
+
action: result.action === 'unchanged' ? 'unchanged' : 'updated',
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
catch (error) {
|
|
630
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
631
|
+
targets.push({
|
|
632
|
+
skill: target.skill,
|
|
633
|
+
tool: target.tool,
|
|
634
|
+
path: target.path,
|
|
635
|
+
action: 'failed',
|
|
636
|
+
error: message,
|
|
637
|
+
});
|
|
638
|
+
advise(`Could not update ${target.skill} at ${target.path}: ${message}.`);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
if (dropped.length === 0)
|
|
642
|
+
return;
|
|
643
|
+
const retired = new Set();
|
|
644
|
+
for (const record of dropped) {
|
|
645
|
+
try {
|
|
646
|
+
const outcome = await retire(record, environment);
|
|
647
|
+
retired.add(record);
|
|
648
|
+
targets.push({
|
|
649
|
+
skill: record.skill,
|
|
650
|
+
tool: record.tool,
|
|
651
|
+
path: record.path,
|
|
652
|
+
action: outcome.action,
|
|
653
|
+
});
|
|
654
|
+
if (outcome.message !== undefined)
|
|
655
|
+
warnings.push(outcome.message);
|
|
656
|
+
}
|
|
657
|
+
catch (error) {
|
|
658
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
659
|
+
targets.push({
|
|
660
|
+
skill: record.skill,
|
|
661
|
+
tool: record.tool,
|
|
662
|
+
path: record.path,
|
|
663
|
+
action: 'failed',
|
|
664
|
+
error: message,
|
|
665
|
+
});
|
|
666
|
+
warnings.push(`Could not remove the retired ${record.skill} Skill at ${record.path}: ${message}.`);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
// Re-read: replace() rewrote the record for each updated target.
|
|
670
|
+
const latest = await readRegistry(environment);
|
|
671
|
+
requireWritableRegistry(latest, environment);
|
|
672
|
+
await writeRegistry(environment, {
|
|
673
|
+
version: 2,
|
|
674
|
+
targets: latest.targets.filter((target) => ![...retired].some((gone) => gone.path === target.path)),
|
|
675
|
+
});
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
catch (error) {
|
|
679
|
+
if (error instanceof CliCommandError &&
|
|
680
|
+
error.code === 'SKILLS_NEWER_REGISTRY')
|
|
681
|
+
warnings.push(error.message);
|
|
682
|
+
else
|
|
683
|
+
advise(`Could not synchronize installed Skills: ${error instanceof Error ? error.message : String(error)}.`);
|
|
684
|
+
}
|
|
685
|
+
return { targets, warnings };
|
|
686
|
+
}
|
|
687
|
+
export function skillsCommands(app) {
|
|
688
|
+
const flags = {
|
|
689
|
+
tool: Flags.string({
|
|
690
|
+
description: 'Choose claude, codex, or all; repeat to choose multiple tools.',
|
|
691
|
+
options: ['claude', 'codex', 'all'],
|
|
692
|
+
multiple: true,
|
|
693
|
+
}),
|
|
694
|
+
};
|
|
695
|
+
class Install extends AppCommand {
|
|
696
|
+
toSuccessJson(result) {
|
|
697
|
+
return super.toSuccessJson({ data: result });
|
|
698
|
+
}
|
|
699
|
+
static summary = 'Install the Skills packaged with this CLI in local agent tools.';
|
|
700
|
+
static supportsDryRun = true;
|
|
701
|
+
static flags = flags;
|
|
702
|
+
async run() {
|
|
703
|
+
const { flags } = await this.parse(Install);
|
|
704
|
+
if (findRunCredentials(process.cwd(), app) !== undefined)
|
|
705
|
+
throw new CliCommandError('SKILLS_RUN_FORBIDDEN', 'The runner already supplies these Skills. Install it outside an agent run.', { exit: EXIT_CODES.auth });
|
|
706
|
+
const result = await installSkills(skillsEnvironment(app), flags.tool, this.dryRun);
|
|
707
|
+
for (const target of result.targets)
|
|
708
|
+
this.log(`${this.dryRun ? 'Would ' : ''}${target.action}: ${target.skill} ${target.tool} ${target.path}`);
|
|
709
|
+
return result;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
class Status extends AppCommand {
|
|
713
|
+
toSuccessJson(result) {
|
|
714
|
+
return super.toSuccessJson({ data: result });
|
|
715
|
+
}
|
|
716
|
+
static summary = 'Compare local copies with the Skills packaged with this CLI.';
|
|
717
|
+
static flags = flags;
|
|
718
|
+
async run() {
|
|
719
|
+
const { flags } = await this.parse(Status);
|
|
720
|
+
const result = await skillsStatus(skillsEnvironment(app), flags.tool);
|
|
721
|
+
for (const target of result.targets)
|
|
722
|
+
this.log(`${target.skill} ${target.tool} ${target.path}: ${target.error ?? (target.present ? (target.dropped ? 'no longer shipped' : target.upToDate ? 'up to date' : 'outdated') : 'not installed')}${target.legacy ? ' (legacy)' : ''}${target.registered ? ' (registered)' : ''}`);
|
|
723
|
+
return result;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
return { 'skills:install': Install, 'skills:status': Status };
|
|
727
|
+
}
|
|
728
|
+
//# sourceMappingURL=skills.js.map
|