@controllerai/cli 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +180 -0
- package/dist/api/client.js +330 -0
- package/dist/api/client.js.map +1 -0
- package/dist/appLinks.js +99 -0
- package/dist/appLinks.js.map +1 -0
- package/dist/argv.js +124 -0
- package/dist/argv.js.map +1 -0
- package/dist/commands/agent.js +813 -0
- package/dist/commands/agent.js.map +1 -0
- package/dist/commands/auth.js +327 -0
- package/dist/commands/auth.js.map +1 -0
- package/dist/commands/branch.js +441 -0
- package/dist/commands/branch.js.map +1 -0
- package/dist/commands/connect.js +148 -0
- package/dist/commands/connect.js.map +1 -0
- package/dist/commands/connection.js +148 -0
- package/dist/commands/connection.js.map +1 -0
- package/dist/commands/data.js +441 -0
- package/dist/commands/data.js.map +1 -0
- package/dist/commands/deps.js +63 -0
- package/dist/commands/deps.js.map +1 -0
- package/dist/commands/doctor.js +102 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/events.js +72 -0
- package/dist/commands/events.js.map +1 -0
- package/dist/commands/exec.js +306 -0
- package/dist/commands/exec.js.map +1 -0
- package/dist/commands/expr.js +346 -0
- package/dist/commands/expr.js.map +1 -0
- package/dist/commands/flow.js +447 -0
- package/dist/commands/flow.js.map +1 -0
- package/dist/commands/integration.js +102 -0
- package/dist/commands/integration.js.map +1 -0
- package/dist/commands/node.js +885 -0
- package/dist/commands/node.js.map +1 -0
- package/dist/commands/organize.js +234 -0
- package/dist/commands/organize.js.map +1 -0
- package/dist/commands/run.js +437 -0
- package/dist/commands/run.js.map +1 -0
- package/dist/commands/schema.js +269 -0
- package/dist/commands/schema.js.map +1 -0
- package/dist/commands/skills.js +1156 -0
- package/dist/commands/skills.js.map +1 -0
- package/dist/commands/trigger.js +992 -0
- package/dist/commands/trigger.js.map +1 -0
- package/dist/commands/workflow.js +503 -0
- package/dist/commands/workflow.js.map +1 -0
- package/dist/config.js +74 -0
- package/dist/config.js.map +1 -0
- package/dist/context.js +39 -0
- package/dist/context.js.map +1 -0
- package/dist/errors.js +59 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.js +125 -0
- package/dist/index.js.map +1 -0
- package/dist/jsSource.js +32 -0
- package/dist/jsSource.js.map +1 -0
- package/dist/nodeProps.js +177 -0
- package/dist/nodeProps.js.map +1 -0
- package/dist/output.js +85 -0
- package/dist/output.js.map +1 -0
- package/dist/version.js +7 -0
- package/dist/version.js.map +1 -0
- package/package.json +41 -0
- package/skills/controllerai-agents/SKILL.md +169 -0
- package/skills/controllerai-agents/agents/openai.yaml +4 -0
- package/skills/controllerai-agents/references/instructions-and-versioning.md +76 -0
- package/skills/controllerai-ai-nodes/SKILL.md +83 -0
- package/skills/controllerai-ai-nodes/agents/openai.yaml +4 -0
- package/skills/controllerai-ai-nodes/references/selection-and-testing.md +46 -0
- package/skills/controllerai-data/SKILL.md +121 -0
- package/skills/controllerai-data/agents/openai.yaml +4 -0
- package/skills/controllerai-data/references/data-rails.md +46 -0
- package/skills/controllerai-expressions/SKILL.md +115 -0
- package/skills/controllerai-expressions/agents/openai.yaml +4 -0
- package/skills/controllerai-expressions/references/dsl-and-recipes.md +120 -0
- package/skills/controllerai-flow-control/SKILL.md +132 -0
- package/skills/controllerai-flow-control/agents/openai.yaml +4 -0
- package/skills/controllerai-flow-control/references/patterns.md +61 -0
- package/skills/controllerai-integration-actions/SKILL.md +95 -0
- package/skills/controllerai-integration-actions/agents/openai.yaml +4 -0
- package/skills/controllerai-node-config/SKILL.md +138 -0
- package/skills/controllerai-node-config/agents/openai.yaml +4 -0
- package/skills/controllerai-node-config/references/prop-and-readiness.md +72 -0
- package/skills/controllerai-runs/SKILL.md +109 -0
- package/skills/controllerai-runs/agents/openai.yaml +4 -0
- package/skills/controllerai-runs/references/debugging.md +53 -0
- package/skills/controllerai-start/SKILL.md +44 -0
- package/skills/controllerai-start/agents/openai.yaml +4 -0
- package/skills/controllerai-triggers/SKILL.md +174 -0
- package/skills/controllerai-triggers/agents/openai.yaml +4 -0
- package/skills/controllerai-triggers/references/lifecycle.md +54 -0
- package/skills/controllerai-workflows/SKILL.md +86 -0
- package/skills/controllerai-workflows/agents/openai.yaml +4 -0
- package/skills/controllerai-workflows/references/command-map.md +64 -0
- package/skills/manifest.json +248 -0
|
@@ -0,0 +1,1156 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, readdirSync, renameSync, rmdirSync, rmSync, writeFileSync, } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { CaiError, networkError, userError } from '../errors.js';
|
|
7
|
+
import { emit, printKeyValues, printTable, warn } from '../output.js';
|
|
8
|
+
import { CLI_VERSION } from '../version.js';
|
|
9
|
+
export const PUBLIC_SKILLS = [
|
|
10
|
+
'controllerai-start',
|
|
11
|
+
'controllerai-workflows',
|
|
12
|
+
'controllerai-node-config',
|
|
13
|
+
'controllerai-expressions',
|
|
14
|
+
'controllerai-flow-control',
|
|
15
|
+
'controllerai-runs',
|
|
16
|
+
'controllerai-triggers',
|
|
17
|
+
'controllerai-data',
|
|
18
|
+
'controllerai-ai-nodes',
|
|
19
|
+
'controllerai-integration-actions',
|
|
20
|
+
'controllerai-agents',
|
|
21
|
+
];
|
|
22
|
+
export const DEFAULT_SKILLS_MANIFEST_URL = 'https://getcontroller.ai/skills/manifest.json';
|
|
23
|
+
export const SKILL_UPDATE_FALLBACK_CODES = [
|
|
24
|
+
'SKILLS_NETWORK_UNAVAILABLE',
|
|
25
|
+
'SKILLS_HTTP_UNAVAILABLE',
|
|
26
|
+
];
|
|
27
|
+
const skillUpdateFallbackCodeSet = new Set(SKILL_UPDATE_FALLBACK_CODES);
|
|
28
|
+
const MARKER = '.controllerai-managed';
|
|
29
|
+
const MAX_MANIFEST_SIZE = 256 * 1024;
|
|
30
|
+
const MAX_SKILL_FILE_SIZE = 1024 * 1024;
|
|
31
|
+
const MAX_SKILL_SET_SIZE = 5 * 1024 * 1024;
|
|
32
|
+
const FETCH_TIMEOUT_MS = 5_000;
|
|
33
|
+
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
34
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
35
|
+
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
36
|
+
const safeRelativePath = (value, label) => {
|
|
37
|
+
if (typeof value !== 'string' || !value || value.includes('\\') || value.startsWith('/')) {
|
|
38
|
+
throw userError(`${label} must be a non-empty relative POSIX path.`, 'INVALID_SKILLS_MANIFEST');
|
|
39
|
+
}
|
|
40
|
+
const parts = value.split('/');
|
|
41
|
+
if (parts.some(part => !part ||
|
|
42
|
+
part === '.' ||
|
|
43
|
+
part === '..' ||
|
|
44
|
+
part.includes('\0') ||
|
|
45
|
+
!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(part))) {
|
|
46
|
+
throw userError(`${label} contains an unsafe path segment.`, 'INVALID_SKILLS_MANIFEST');
|
|
47
|
+
}
|
|
48
|
+
return value;
|
|
49
|
+
};
|
|
50
|
+
export const validateSkillsUrl = (value, label = 'Skills URL') => {
|
|
51
|
+
let url;
|
|
52
|
+
try {
|
|
53
|
+
url = new URL(value);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
throw userError(`${label} must be an absolute URL.`, 'UNSAFE_SKILLS_URL');
|
|
57
|
+
}
|
|
58
|
+
const hostname = url.hostname.toLowerCase();
|
|
59
|
+
const loopback = ['localhost', '127.0.0.1', '[::1]', '::1'].includes(hostname);
|
|
60
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) {
|
|
61
|
+
throw userError(`${label} must use HTTPS (loopback HTTP is allowed for tests).`, 'UNSAFE_SKILLS_URL');
|
|
62
|
+
}
|
|
63
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
64
|
+
throw userError(`${label} must not contain credentials, a query, or a fragment.`, 'UNSAFE_SKILLS_URL');
|
|
65
|
+
}
|
|
66
|
+
return url.toString();
|
|
67
|
+
};
|
|
68
|
+
const parseSemver = (value, label) => {
|
|
69
|
+
const match = value.match(/^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/);
|
|
70
|
+
if (!match) {
|
|
71
|
+
throw userError(`${label} must be a semantic version.`, 'INVALID_SKILLS_MANIFEST');
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
numbers: [Number(match[1]), Number(match[2]), Number(match[3])],
|
|
75
|
+
prerelease: match[4]?.split('.') ?? [],
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
const compareSemver = (left, right) => {
|
|
79
|
+
const a = parseSemver(left, 'CLI version');
|
|
80
|
+
const b = parseSemver(right, 'Minimum CLI version');
|
|
81
|
+
for (let index = 0; index < 3; index += 1) {
|
|
82
|
+
if (a.numbers[index] !== b.numbers[index])
|
|
83
|
+
return a.numbers[index] - b.numbers[index];
|
|
84
|
+
}
|
|
85
|
+
if (a.prerelease.length === 0 && b.prerelease.length > 0)
|
|
86
|
+
return 1;
|
|
87
|
+
if (b.prerelease.length === 0 && a.prerelease.length > 0)
|
|
88
|
+
return -1;
|
|
89
|
+
const count = Math.max(a.prerelease.length, b.prerelease.length);
|
|
90
|
+
for (let index = 0; index < count; index += 1) {
|
|
91
|
+
const av = a.prerelease[index];
|
|
92
|
+
const bv = b.prerelease[index];
|
|
93
|
+
if (av === undefined)
|
|
94
|
+
return -1;
|
|
95
|
+
if (bv === undefined)
|
|
96
|
+
return 1;
|
|
97
|
+
if (av === bv)
|
|
98
|
+
continue;
|
|
99
|
+
const an = /^\d+$/.test(av) ? Number(av) : undefined;
|
|
100
|
+
const bn = /^\d+$/.test(bv) ? Number(bv) : undefined;
|
|
101
|
+
if (an !== undefined && bn !== undefined)
|
|
102
|
+
return an - bn;
|
|
103
|
+
if (an !== undefined)
|
|
104
|
+
return -1;
|
|
105
|
+
if (bn !== undefined)
|
|
106
|
+
return 1;
|
|
107
|
+
return av.localeCompare(bv);
|
|
108
|
+
}
|
|
109
|
+
return 0;
|
|
110
|
+
};
|
|
111
|
+
const compareSkillSetVersions = (left, right) => {
|
|
112
|
+
const parse = (value) => {
|
|
113
|
+
if (!/^\d+(?:\.\d+)*$/.test(value))
|
|
114
|
+
return undefined;
|
|
115
|
+
return value.split('.').map(Number);
|
|
116
|
+
};
|
|
117
|
+
const a = parse(left);
|
|
118
|
+
const b = parse(right);
|
|
119
|
+
if (!a || !b)
|
|
120
|
+
return undefined;
|
|
121
|
+
const count = Math.max(a.length, b.length);
|
|
122
|
+
for (let index = 0; index < count; index += 1) {
|
|
123
|
+
const difference = (a[index] ?? 0) - (b[index] ?? 0);
|
|
124
|
+
if (difference !== 0)
|
|
125
|
+
return difference;
|
|
126
|
+
}
|
|
127
|
+
return 0;
|
|
128
|
+
};
|
|
129
|
+
const skillsManifestContentDigest = (manifest) => sha256(`${manifest.schemaVersion}\0${manifest.skillSetVersion}\0${manifest.minCliVersion}\n` +
|
|
130
|
+
manifest.skills
|
|
131
|
+
.flatMap(skill => skill.files.map(file => `${skill.name}\0${file.path}\0${file.sha256}\0${file.size}\n`))
|
|
132
|
+
.sort()
|
|
133
|
+
.join(''));
|
|
134
|
+
/** A published version is a content address: changing bytes requires a new version. */
|
|
135
|
+
export const assertImmutableSkillSetVersion = (candidate, trusted) => {
|
|
136
|
+
if (candidate.skillSetVersion === trusted.skillSetVersion &&
|
|
137
|
+
skillsManifestContentDigest(candidate) !== skillsManifestContentDigest(trusted)) {
|
|
138
|
+
throw userError(`Skill set ${candidate.skillSetVersion} was published with different contents. ` +
|
|
139
|
+
'Controller AI skill-set versions are immutable; publish a new version instead.', 'SKILL_SET_VERSION_CONFLICT');
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
export const validateSkillsManifest = (value, cliVersion = CLI_VERSION) => {
|
|
143
|
+
if (!isRecord(value) || value.schemaVersion !== 1) {
|
|
144
|
+
throw userError('Skills manifest schemaVersion must be 1.', 'INVALID_SKILLS_MANIFEST');
|
|
145
|
+
}
|
|
146
|
+
if (typeof value.skillSetVersion !== 'string' ||
|
|
147
|
+
value.skillSetVersion.length > 64 ||
|
|
148
|
+
!/^[A-Za-z0-9][A-Za-z0-9_-]*(?:\.[A-Za-z0-9][A-Za-z0-9_-]*)*$/.test(value.skillSetVersion)) {
|
|
149
|
+
throw userError('Skills manifest has an invalid skillSetVersion.', 'INVALID_SKILLS_MANIFEST');
|
|
150
|
+
}
|
|
151
|
+
if (typeof value.minCliVersion !== 'string') {
|
|
152
|
+
throw userError('Skills manifest has no minCliVersion.', 'INVALID_SKILLS_MANIFEST');
|
|
153
|
+
}
|
|
154
|
+
parseSemver(value.minCliVersion, 'minCliVersion');
|
|
155
|
+
if (compareSemver(cliVersion, value.minCliVersion) < 0) {
|
|
156
|
+
throw userError(`Skill set ${value.skillSetVersion} requires cai ${value.minCliVersion} or newer; ` +
|
|
157
|
+
`this is cai ${cliVersion}. Upgrade the CLI first.`, 'CLI_TOO_OLD_FOR_SKILLS');
|
|
158
|
+
}
|
|
159
|
+
if (!Array.isArray(value.skills)) {
|
|
160
|
+
throw userError('Skills manifest skills must be an array.', 'INVALID_SKILLS_MANIFEST');
|
|
161
|
+
}
|
|
162
|
+
const expected = new Set(PUBLIC_SKILLS);
|
|
163
|
+
const seenSkills = new Set();
|
|
164
|
+
const seenPaths = new Set();
|
|
165
|
+
const seenUrls = new Set();
|
|
166
|
+
let totalSize = 0;
|
|
167
|
+
const skills = value.skills.map((candidate, skillIndex) => {
|
|
168
|
+
if (!isRecord(candidate) || typeof candidate.name !== 'string' || !expected.has(candidate.name)) {
|
|
169
|
+
throw userError(`Skills manifest entry ${skillIndex} has an unknown skill name.`, 'INVALID_SKILLS_MANIFEST');
|
|
170
|
+
}
|
|
171
|
+
if (seenSkills.has(candidate.name)) {
|
|
172
|
+
throw userError(`Skills manifest repeats ${candidate.name}.`, 'INVALID_SKILLS_MANIFEST');
|
|
173
|
+
}
|
|
174
|
+
seenSkills.add(candidate.name);
|
|
175
|
+
if (!Array.isArray(candidate.files) || candidate.files.length === 0) {
|
|
176
|
+
throw userError(`${candidate.name} has no files.`, 'INVALID_SKILLS_MANIFEST');
|
|
177
|
+
}
|
|
178
|
+
const files = candidate.files.map((file, fileIndex) => {
|
|
179
|
+
if (!isRecord(file)) {
|
|
180
|
+
throw userError(`${candidate.name} file ${fileIndex} is invalid.`, 'INVALID_SKILLS_MANIFEST');
|
|
181
|
+
}
|
|
182
|
+
const path = safeRelativePath(file.path, `${candidate.name} file path`);
|
|
183
|
+
if (!path.startsWith(`${candidate.name}/`) || path === `${candidate.name}/`) {
|
|
184
|
+
throw userError(`${path} must be rooted beneath ${candidate.name}/.`, 'INVALID_SKILLS_MANIFEST');
|
|
185
|
+
}
|
|
186
|
+
if (seenPaths.has(path)) {
|
|
187
|
+
throw userError(`Skills manifest repeats ${path}.`, 'INVALID_SKILLS_MANIFEST');
|
|
188
|
+
}
|
|
189
|
+
seenPaths.add(path);
|
|
190
|
+
if (typeof file.url !== 'string') {
|
|
191
|
+
throw userError(`${path} has no URL.`, 'INVALID_SKILLS_MANIFEST');
|
|
192
|
+
}
|
|
193
|
+
const url = validateSkillsUrl(file.url, `${path} URL`);
|
|
194
|
+
if (seenUrls.has(url)) {
|
|
195
|
+
throw userError(`Skills manifest repeats the URL for ${path}.`, 'INVALID_SKILLS_MANIFEST');
|
|
196
|
+
}
|
|
197
|
+
seenUrls.add(url);
|
|
198
|
+
if (typeof file.sha256 !== 'string' || !/^[a-fA-F0-9]{64}$/.test(file.sha256)) {
|
|
199
|
+
throw userError(`${path} has an invalid SHA-256.`, 'INVALID_SKILLS_MANIFEST');
|
|
200
|
+
}
|
|
201
|
+
if (typeof file.size !== 'number' ||
|
|
202
|
+
!Number.isSafeInteger(file.size) ||
|
|
203
|
+
file.size < 0 ||
|
|
204
|
+
file.size > MAX_SKILL_FILE_SIZE) {
|
|
205
|
+
throw userError(`${path} has an invalid or excessive size.`, 'INVALID_SKILLS_MANIFEST');
|
|
206
|
+
}
|
|
207
|
+
totalSize += file.size;
|
|
208
|
+
return { path, url, sha256: file.sha256.toLowerCase(), size: file.size };
|
|
209
|
+
});
|
|
210
|
+
if (!files.some(file => file.path === `${candidate.name}/SKILL.md`)) {
|
|
211
|
+
throw userError(`${candidate.name} is missing SKILL.md.`, 'INVALID_SKILLS_MANIFEST');
|
|
212
|
+
}
|
|
213
|
+
return { name: candidate.name, files };
|
|
214
|
+
});
|
|
215
|
+
const missing = PUBLIC_SKILLS.filter(skill => !seenSkills.has(skill));
|
|
216
|
+
if (missing.length > 0 || seenSkills.size !== PUBLIC_SKILLS.length) {
|
|
217
|
+
throw userError(`Skills manifest must contain exactly the public skill set; missing: ${missing.join(', ') || 'none'}.`, 'INVALID_SKILLS_MANIFEST');
|
|
218
|
+
}
|
|
219
|
+
if (totalSize > MAX_SKILL_SET_SIZE) {
|
|
220
|
+
throw userError('Skills manifest exceeds the maximum total size.', 'INVALID_SKILLS_MANIFEST');
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
schemaVersion: 1,
|
|
224
|
+
skillSetVersion: value.skillSetVersion,
|
|
225
|
+
minCliVersion: value.minCliVersion,
|
|
226
|
+
skills,
|
|
227
|
+
};
|
|
228
|
+
};
|
|
229
|
+
export const bundledSkillsRoot = () => {
|
|
230
|
+
const candidates = [
|
|
231
|
+
resolve(moduleDir, '..', '..', 'skills'),
|
|
232
|
+
resolve(moduleDir, '..', '..', '..', '.claude', 'skills'),
|
|
233
|
+
];
|
|
234
|
+
const found = candidates.find(candidate => existsSync(join(candidate, 'manifest.json')) &&
|
|
235
|
+
existsSync(join(candidate, PUBLIC_SKILLS[0], 'SKILL.md')));
|
|
236
|
+
if (!found) {
|
|
237
|
+
throw userError(`Controller AI skills are missing from this installation. Checked: ${candidates.join(', ')}`, 'SKILLS_NOT_BUNDLED');
|
|
238
|
+
}
|
|
239
|
+
return found;
|
|
240
|
+
};
|
|
241
|
+
const readJsonBytes = (bytes, label) => {
|
|
242
|
+
try {
|
|
243
|
+
return JSON.parse(bytes.toString('utf8'));
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
throw userError(`${label} is not valid JSON.`, 'INVALID_SKILLS_MANIFEST');
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
export const readBundledSkillsManifest = () => {
|
|
250
|
+
const manifestPath = join(bundledSkillsRoot(), 'manifest.json');
|
|
251
|
+
const manifestStat = lstatSync(manifestPath);
|
|
252
|
+
if (!manifestStat.isFile() || manifestStat.size > MAX_MANIFEST_SIZE) {
|
|
253
|
+
throw userError('Bundled skills manifest is too large.', 'INVALID_SKILLS_MANIFEST');
|
|
254
|
+
}
|
|
255
|
+
const bytes = readFileSync(manifestPath);
|
|
256
|
+
return validateSkillsManifest(readJsonBytes(bytes, 'Bundled skills manifest'));
|
|
257
|
+
};
|
|
258
|
+
const readBundledSkillSet = () => {
|
|
259
|
+
const root = bundledSkillsRoot();
|
|
260
|
+
const manifest = readBundledSkillsManifest();
|
|
261
|
+
const contents = new Map();
|
|
262
|
+
for (const skill of manifest.skills) {
|
|
263
|
+
for (const file of skill.files) {
|
|
264
|
+
const filePath = join(root, ...file.path.split('/'));
|
|
265
|
+
const fileStat = lstatSync(filePath);
|
|
266
|
+
if (!fileStat.isFile() || fileStat.size !== file.size) {
|
|
267
|
+
throw userError(`Bundled skill file ${file.path} does not match its manifest. Reinstall the CLI.`, 'INVALID_BUNDLED_SKILLS');
|
|
268
|
+
}
|
|
269
|
+
const bytes = readFileSync(filePath);
|
|
270
|
+
if (bytes.length !== file.size || sha256(bytes) !== file.sha256) {
|
|
271
|
+
throw userError(`Bundled skill file ${file.path} does not match its manifest. Reinstall the CLI.`, 'INVALID_BUNDLED_SKILLS');
|
|
272
|
+
}
|
|
273
|
+
contents.set(file.path, bytes);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return { manifest, contents, source: 'bundled' };
|
|
277
|
+
};
|
|
278
|
+
const readBoundedResponseBody = async (response, maximumSize, label) => {
|
|
279
|
+
if (!response.body)
|
|
280
|
+
return Buffer.alloc(0);
|
|
281
|
+
const reader = response.body.getReader();
|
|
282
|
+
const chunks = [];
|
|
283
|
+
let total = 0;
|
|
284
|
+
try {
|
|
285
|
+
for (;;) {
|
|
286
|
+
const chunk = await reader.read();
|
|
287
|
+
if (chunk.done)
|
|
288
|
+
break;
|
|
289
|
+
const bytes = Buffer.from(chunk.value);
|
|
290
|
+
total += bytes.length;
|
|
291
|
+
if (total > maximumSize) {
|
|
292
|
+
try {
|
|
293
|
+
await reader.cancel();
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
// The integrity failure remains authoritative even if cancellation races a closed body.
|
|
297
|
+
}
|
|
298
|
+
throw userError(`${label} exceeds the maximum allowed size.`, 'SKILLS_INTEGRITY_FAILED');
|
|
299
|
+
}
|
|
300
|
+
chunks.push(bytes);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
finally {
|
|
304
|
+
reader.releaseLock();
|
|
305
|
+
}
|
|
306
|
+
return Buffer.concat(chunks, total);
|
|
307
|
+
};
|
|
308
|
+
const fetchBytes = async (fetchImpl, url, maximumSize, label) => {
|
|
309
|
+
const controller = new AbortController();
|
|
310
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
311
|
+
try {
|
|
312
|
+
const response = await fetchImpl(url, { signal: controller.signal, redirect: 'manual' });
|
|
313
|
+
if (response.status >= 300 && response.status < 400) {
|
|
314
|
+
throw userError(`${label} attempted an HTTP redirect.`, 'UNSAFE_SKILLS_REDIRECT');
|
|
315
|
+
}
|
|
316
|
+
if (!response.ok) {
|
|
317
|
+
throw networkError(`${label} returned HTTP ${response.status}.`, 'SKILLS_HTTP_UNAVAILABLE');
|
|
318
|
+
}
|
|
319
|
+
return await readBoundedResponseBody(response, maximumSize, label);
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
if (error instanceof CaiError)
|
|
323
|
+
throw error;
|
|
324
|
+
throw networkError(`${label} could not be downloaded: ${error instanceof Error ? error.message : String(error)}`, 'SKILLS_NETWORK_UNAVAILABLE');
|
|
325
|
+
}
|
|
326
|
+
finally {
|
|
327
|
+
clearTimeout(timer);
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
export const fetchHostedSkillSet = async (manifestUrl = process.env.CAI_SKILLS_MANIFEST_URL ?? DEFAULT_SKILLS_MANIFEST_URL, fetchImpl = fetch) => {
|
|
331
|
+
const safeManifestUrl = validateSkillsUrl(manifestUrl, 'Skills manifest URL');
|
|
332
|
+
const manifestBytes = await fetchBytes(fetchImpl, safeManifestUrl, MAX_MANIFEST_SIZE, 'Skills manifest');
|
|
333
|
+
const manifest = validateSkillsManifest(readJsonBytes(manifestBytes, 'Skills manifest'));
|
|
334
|
+
const manifestOrigin = new URL(safeManifestUrl).origin;
|
|
335
|
+
for (const skill of manifest.skills) {
|
|
336
|
+
for (const file of skill.files) {
|
|
337
|
+
const fileUrl = new URL(file.url);
|
|
338
|
+
if (fileUrl.origin !== manifestOrigin) {
|
|
339
|
+
throw userError(`${file.path} must be hosted on the skills manifest origin (${manifestOrigin}).`, 'UNSAFE_SKILLS_URL');
|
|
340
|
+
}
|
|
341
|
+
const expectedPath = `/skills/${manifest.skillSetVersion}/${file.path}`;
|
|
342
|
+
if (fileUrl.pathname !== expectedPath) {
|
|
343
|
+
throw userError(`${file.path} URL must use the versioned path ${expectedPath}.`, 'UNSAFE_SKILLS_URL');
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
const contents = new Map();
|
|
348
|
+
await Promise.all(manifest.skills.flatMap(skill => skill.files.map(async (file) => {
|
|
349
|
+
const bytes = await fetchBytes(fetchImpl, file.url, file.size, file.path);
|
|
350
|
+
if (bytes.length !== file.size) {
|
|
351
|
+
throw userError(`${file.path} size does not match the manifest.`, 'SKILLS_INTEGRITY_FAILED');
|
|
352
|
+
}
|
|
353
|
+
if (sha256(bytes) !== file.sha256) {
|
|
354
|
+
throw userError(`${file.path} SHA-256 does not match the manifest.`, 'SKILLS_INTEGRITY_FAILED');
|
|
355
|
+
}
|
|
356
|
+
contents.set(file.path, bytes);
|
|
357
|
+
})));
|
|
358
|
+
return { manifest, contents, source: 'hosted', manifestUrl: safeManifestUrl };
|
|
359
|
+
};
|
|
360
|
+
const directoryIdentity = (directory) => {
|
|
361
|
+
let cursor = resolve(directory);
|
|
362
|
+
const missingSegments = [];
|
|
363
|
+
for (;;) {
|
|
364
|
+
try {
|
|
365
|
+
return join(realpathSync(cursor), ...missingSegments);
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
const parent = dirname(cursor);
|
|
369
|
+
if (parent === cursor)
|
|
370
|
+
return resolve(directory);
|
|
371
|
+
missingSegments.unshift(basename(cursor));
|
|
372
|
+
cursor = parent;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
const dedupeDirectories = (rows) => {
|
|
377
|
+
const seen = new Set();
|
|
378
|
+
return rows.filter(row => {
|
|
379
|
+
const key = directoryIdentity(row.directory);
|
|
380
|
+
if (seen.has(key))
|
|
381
|
+
return false;
|
|
382
|
+
seen.add(key);
|
|
383
|
+
return true;
|
|
384
|
+
});
|
|
385
|
+
};
|
|
386
|
+
export const skillTargetDirectories = (target, projectDir = process.cwd(), home = homedir(), codexHome = process.env.CODEX_HOME ?? join(home, '.codex')) => {
|
|
387
|
+
const candidates = [
|
|
388
|
+
{
|
|
389
|
+
target: 'codex',
|
|
390
|
+
homeDirectory: codexHome,
|
|
391
|
+
skillsDirectory: join(codexHome, 'skills'),
|
|
392
|
+
},
|
|
393
|
+
{
|
|
394
|
+
target: 'claude',
|
|
395
|
+
homeDirectory: join(home, '.claude'),
|
|
396
|
+
skillsDirectory: join(home, '.claude', 'skills'),
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
target: 'cursor',
|
|
400
|
+
homeDirectory: join(home, '.cursor'),
|
|
401
|
+
skillsDirectory: join(home, '.cursor', 'skills'),
|
|
402
|
+
},
|
|
403
|
+
{
|
|
404
|
+
target: 'copilot',
|
|
405
|
+
homeDirectory: join(home, '.copilot'),
|
|
406
|
+
skillsDirectory: join(home, '.copilot', 'skills'),
|
|
407
|
+
},
|
|
408
|
+
{
|
|
409
|
+
target: 'agents',
|
|
410
|
+
homeDirectory: join(home, '.agents'),
|
|
411
|
+
skillsDirectory: join(home, '.agents', 'skills'),
|
|
412
|
+
},
|
|
413
|
+
];
|
|
414
|
+
if (target === 'auto') {
|
|
415
|
+
const detected = candidates.filter(candidate => ['codex', 'claude', 'cursor', 'copilot'].includes(candidate.target)
|
|
416
|
+
? existsSync(candidate.homeDirectory)
|
|
417
|
+
: false);
|
|
418
|
+
const selected = detected.length > 0 ? detected : candidates.filter(row => row.target === 'agents');
|
|
419
|
+
return dedupeDirectories(selected.map(row => ({ target: row.target, directory: row.skillsDirectory })));
|
|
420
|
+
}
|
|
421
|
+
if (target === 'all') {
|
|
422
|
+
return dedupeDirectories(candidates.map(row => ({ target: row.target, directory: row.skillsDirectory })));
|
|
423
|
+
}
|
|
424
|
+
const selected = candidates.find(candidate => candidate.target === target);
|
|
425
|
+
if (selected)
|
|
426
|
+
return [{ target: selected.target, directory: selected.skillsDirectory }];
|
|
427
|
+
if (target === 'project') {
|
|
428
|
+
return [{ target: 'project', directory: join(resolve(projectDir), '.agents', 'skills') }];
|
|
429
|
+
}
|
|
430
|
+
return [];
|
|
431
|
+
};
|
|
432
|
+
const markerFilesForSkill = (skill) => skill.files
|
|
433
|
+
.map(file => ({
|
|
434
|
+
path: file.path.slice(skill.name.length + 1),
|
|
435
|
+
sha256: file.sha256,
|
|
436
|
+
size: file.size,
|
|
437
|
+
}))
|
|
438
|
+
.sort((left, right) => left.path.localeCompare(right.path));
|
|
439
|
+
const digestMarkerFiles = (files) => sha256(files.map(file => `${file.path}\0${file.sha256}\0${file.size}\n`).join(''));
|
|
440
|
+
/** Unlike existsSync, this treats dangling symbolic links as existing directory entries. */
|
|
441
|
+
const pathExistsLexically = (path) => {
|
|
442
|
+
try {
|
|
443
|
+
lstatSync(path);
|
|
444
|
+
return true;
|
|
445
|
+
}
|
|
446
|
+
catch (error) {
|
|
447
|
+
if (isRecord(error) && error.code === 'ENOENT')
|
|
448
|
+
return false;
|
|
449
|
+
throw error;
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
const hasControllerManagedMarker = (skillDirectory) => {
|
|
453
|
+
try {
|
|
454
|
+
const markerPath = join(skillDirectory, MARKER);
|
|
455
|
+
const markerStat = lstatSync(markerPath);
|
|
456
|
+
if (!markerStat.isFile() || markerStat.size > MAX_MANIFEST_SIZE)
|
|
457
|
+
return false;
|
|
458
|
+
const marker = JSON.parse(readFileSync(markerPath, 'utf8'));
|
|
459
|
+
return isRecord(marker) && marker.package === '@controllerai/cli';
|
|
460
|
+
}
|
|
461
|
+
catch {
|
|
462
|
+
return false;
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
const isControllerManagedSkillDirectory = (path) => {
|
|
466
|
+
try {
|
|
467
|
+
// A symlink is never owned by this installer, even when its target happens
|
|
468
|
+
// to contain a Controller AI marker.
|
|
469
|
+
if (!lstatSync(path).isDirectory())
|
|
470
|
+
return false;
|
|
471
|
+
return hasControllerManagedMarker(path);
|
|
472
|
+
}
|
|
473
|
+
catch {
|
|
474
|
+
return false;
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
const assertReplaceableSkillOutput = (output, force = false) => {
|
|
478
|
+
if (pathExistsLexically(output) &&
|
|
479
|
+
!isControllerManagedSkillDirectory(output) &&
|
|
480
|
+
!force) {
|
|
481
|
+
throw userError(`${output} already exists and is not managed by Controller AI. ` +
|
|
482
|
+
'Move it, or rerun with --force to replace it.', 'UNMANAGED_SKILL_EXISTS');
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
const writeStagedSkill = (stagingRoot, skill, skillSet, installedAt) => {
|
|
486
|
+
const output = join(stagingRoot, skill.name);
|
|
487
|
+
const files = markerFilesForSkill(skill);
|
|
488
|
+
const digest = digestMarkerFiles(files);
|
|
489
|
+
for (const file of skill.files) {
|
|
490
|
+
const relativePath = file.path.slice(skill.name.length + 1);
|
|
491
|
+
const destination = join(output, ...relativePath.split('/'));
|
|
492
|
+
const bytes = skillSet.contents.get(file.path);
|
|
493
|
+
if (!bytes) {
|
|
494
|
+
throw userError(`Skill source is missing ${file.path}.`, 'INCOMPLETE_SKILL_SOURCE');
|
|
495
|
+
}
|
|
496
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
497
|
+
writeFileSync(destination, bytes, { mode: 0o644 });
|
|
498
|
+
}
|
|
499
|
+
const marker = {
|
|
500
|
+
schemaVersion: 1,
|
|
501
|
+
package: '@controllerai/cli',
|
|
502
|
+
skill: skill.name,
|
|
503
|
+
installedAt,
|
|
504
|
+
skillSetVersion: skillSet.manifest.skillSetVersion,
|
|
505
|
+
digest,
|
|
506
|
+
files,
|
|
507
|
+
source: skillSet.source,
|
|
508
|
+
...(skillSet.manifestUrl ? { manifestUrl: skillSet.manifestUrl } : {}),
|
|
509
|
+
};
|
|
510
|
+
writeFileSync(join(output, MARKER), `${JSON.stringify(marker, null, 2)}\n`, { mode: 0o600 });
|
|
511
|
+
return { digest };
|
|
512
|
+
};
|
|
513
|
+
/**
|
|
514
|
+
* True only when the occupant's marker proves it is the copy THIS run staged.
|
|
515
|
+
* (dev, ino) alone is not a safe identity: Linux filesystems eagerly reuse
|
|
516
|
+
* inode numbers, so a directory created moments after ours was deleted can
|
|
517
|
+
* carry the same numbers. The marker's per-run installedAt + content digest
|
|
518
|
+
* cannot be inherited that way.
|
|
519
|
+
*/
|
|
520
|
+
const occupantIsInstalledStage = (output, identity) => {
|
|
521
|
+
const outputStat = lstatSync(output);
|
|
522
|
+
if (outputStat.dev !== identity.dev || outputStat.ino !== identity.ino)
|
|
523
|
+
return false;
|
|
524
|
+
try {
|
|
525
|
+
const marker = JSON.parse(readFileSync(join(output, MARKER), 'utf8'));
|
|
526
|
+
return marker.installedAt === identity.installedAt && marker.digest === identity.digest;
|
|
527
|
+
}
|
|
528
|
+
catch {
|
|
529
|
+
return false;
|
|
530
|
+
}
|
|
531
|
+
};
|
|
532
|
+
const removeNewEmptyDestination = (prepared) => {
|
|
533
|
+
if (prepared.directoryExisted)
|
|
534
|
+
return;
|
|
535
|
+
try {
|
|
536
|
+
rmdirSync(prepared.destination.directory);
|
|
537
|
+
}
|
|
538
|
+
catch {
|
|
539
|
+
// A parent may now contain user-created content; never remove it recursively.
|
|
540
|
+
}
|
|
541
|
+
};
|
|
542
|
+
const prepareDestination = (destination, skillSet, fileOperations = {}) => {
|
|
543
|
+
const directoryExisted = existsSync(destination.directory);
|
|
544
|
+
let stagingRoot;
|
|
545
|
+
let backupRoot;
|
|
546
|
+
const installedAt = new Date().toISOString();
|
|
547
|
+
const digests = new Map();
|
|
548
|
+
try {
|
|
549
|
+
mkdirSync(destination.directory, { recursive: true });
|
|
550
|
+
stagingRoot = mkdtempSync(join(destination.directory, '.controllerai-stage-'));
|
|
551
|
+
backupRoot = mkdtempSync(join(destination.directory, '.controllerai-backup-'));
|
|
552
|
+
for (const skill of skillSet.manifest.skills) {
|
|
553
|
+
digests.set(skill.name, writeStagedSkill(stagingRoot, skill, skillSet, installedAt).digest);
|
|
554
|
+
}
|
|
555
|
+
return {
|
|
556
|
+
destination,
|
|
557
|
+
directoryExisted,
|
|
558
|
+
stagingRoot,
|
|
559
|
+
backupRoot,
|
|
560
|
+
digests,
|
|
561
|
+
installedAt,
|
|
562
|
+
renamePath: fileOperations.rename ?? renameSync,
|
|
563
|
+
removeTree: fileOperations.removeTree ??
|
|
564
|
+
(path => {
|
|
565
|
+
rmSync(path, { recursive: true, force: true });
|
|
566
|
+
}),
|
|
567
|
+
swaps: [],
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
catch (error) {
|
|
571
|
+
if (stagingRoot)
|
|
572
|
+
rmSync(stagingRoot, { recursive: true, force: true });
|
|
573
|
+
if (backupRoot)
|
|
574
|
+
rmSync(backupRoot, { recursive: true, force: true });
|
|
575
|
+
if (!directoryExisted) {
|
|
576
|
+
try {
|
|
577
|
+
rmdirSync(destination.directory);
|
|
578
|
+
}
|
|
579
|
+
catch {
|
|
580
|
+
// mkdirSync may have failed before creating the destination.
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
throw error;
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
const commitDestination = (prepared, skillSet, force = false) => {
|
|
587
|
+
for (const skill of skillSet.manifest.skills) {
|
|
588
|
+
const output = join(prepared.destination.directory, skill.name);
|
|
589
|
+
const stagedPath = join(prepared.stagingRoot, skill.name);
|
|
590
|
+
const stagedStat = lstatSync(stagedPath);
|
|
591
|
+
// Preflight happened before staging. Revalidate ownership at the last
|
|
592
|
+
// possible moment so a newly-created unmanaged occupant is never moved.
|
|
593
|
+
assertReplaceableSkillOutput(output, force);
|
|
594
|
+
const backup = pathExistsLexically(output)
|
|
595
|
+
? join(prepared.backupRoot, skill.name)
|
|
596
|
+
: undefined;
|
|
597
|
+
const swap = {
|
|
598
|
+
output,
|
|
599
|
+
backup,
|
|
600
|
+
stagedInstalled: false,
|
|
601
|
+
stagedIdentity: {
|
|
602
|
+
dev: stagedStat.dev,
|
|
603
|
+
ino: stagedStat.ino,
|
|
604
|
+
installedAt: prepared.installedAt,
|
|
605
|
+
digest: prepared.digests.get(skill.name) ?? '',
|
|
606
|
+
},
|
|
607
|
+
};
|
|
608
|
+
// Journal the backup before the risky staged rename. If that rename fails,
|
|
609
|
+
// rollback still knows where the original copy lives.
|
|
610
|
+
prepared.swaps.push(swap);
|
|
611
|
+
if (backup)
|
|
612
|
+
prepared.renamePath(output, backup);
|
|
613
|
+
prepared.renamePath(stagedPath, output);
|
|
614
|
+
swap.stagedInstalled = true;
|
|
615
|
+
}
|
|
616
|
+
};
|
|
617
|
+
const rollbackDestination = (prepared) => {
|
|
618
|
+
const failures = [];
|
|
619
|
+
try {
|
|
620
|
+
for (const swap of [...prepared.swaps].reverse()) {
|
|
621
|
+
try {
|
|
622
|
+
if (swap.stagedInstalled && pathExistsLexically(swap.output)) {
|
|
623
|
+
const isInstalledStage = occupantIsInstalledStage(swap.output, swap.stagedIdentity);
|
|
624
|
+
if (!isInstalledStage) {
|
|
625
|
+
if (swap.backup) {
|
|
626
|
+
throw new Error(`refusing to remove an unexpected occupant at ${swap.output}`);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
else {
|
|
630
|
+
prepared.removeTree(swap.output);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
if (swap.backup && pathExistsLexically(swap.backup)) {
|
|
634
|
+
if (pathExistsLexically(swap.output)) {
|
|
635
|
+
throw new Error(`refusing to overwrite an unexpected occupant at ${swap.output}`);
|
|
636
|
+
}
|
|
637
|
+
prepared.renamePath(swap.backup, swap.output);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
catch (error) {
|
|
641
|
+
failures.push(error instanceof Error ? error.message : String(error));
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
if (failures.length === 0)
|
|
645
|
+
prepared.swaps.length = 0;
|
|
646
|
+
}
|
|
647
|
+
finally {
|
|
648
|
+
try {
|
|
649
|
+
prepared.removeTree(prepared.stagingRoot);
|
|
650
|
+
}
|
|
651
|
+
catch (error) {
|
|
652
|
+
failures.push(`could not remove staging directory ${prepared.stagingRoot}: ${error instanceof Error ? error.message : String(error)}`);
|
|
653
|
+
}
|
|
654
|
+
if (failures.length === 0) {
|
|
655
|
+
try {
|
|
656
|
+
prepared.removeTree(prepared.backupRoot);
|
|
657
|
+
}
|
|
658
|
+
catch (error) {
|
|
659
|
+
failures.push(`could not remove backup directory ${prepared.backupRoot}: ${error instanceof Error ? error.message : String(error)}`);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
removeNewEmptyDestination(prepared);
|
|
663
|
+
}
|
|
664
|
+
if (failures.length > 0) {
|
|
665
|
+
throw userError(`Could not restore a previous skill installation; recoverable backups remain at ` +
|
|
666
|
+
`${prepared.backupRoot}: ${failures.join('; ')}`, 'SKILLS_ROLLBACK_RESTORE_FAILED');
|
|
667
|
+
}
|
|
668
|
+
};
|
|
669
|
+
const finalizeDestination = (prepared) => {
|
|
670
|
+
const warnings = [];
|
|
671
|
+
for (const [label, path] of [
|
|
672
|
+
['staging', prepared.stagingRoot],
|
|
673
|
+
['backup', prepared.backupRoot],
|
|
674
|
+
]) {
|
|
675
|
+
try {
|
|
676
|
+
prepared.removeTree(path);
|
|
677
|
+
}
|
|
678
|
+
catch (error) {
|
|
679
|
+
warnings.push(`Skills were installed, but the temporary ${label} directory could not be removed; ` +
|
|
680
|
+
`it remains at ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
return warnings;
|
|
684
|
+
};
|
|
685
|
+
const installedRows = (prepared, skillSet) => skillSet.manifest.skills.map(skill => {
|
|
686
|
+
const path = join(prepared.destination.directory, skill.name);
|
|
687
|
+
return {
|
|
688
|
+
target: prepared.destination.target,
|
|
689
|
+
skill: skill.name,
|
|
690
|
+
path,
|
|
691
|
+
skillFile: join(path, 'SKILL.md'),
|
|
692
|
+
skillSetVersion: skillSet.manifest.skillSetVersion,
|
|
693
|
+
digest: prepared.digests.get(skill.name),
|
|
694
|
+
source: skillSet.source,
|
|
695
|
+
};
|
|
696
|
+
});
|
|
697
|
+
const assertSkillTarget = (target) => {
|
|
698
|
+
if (!['auto', 'agents', 'codex', 'claude', 'cursor', 'copilot', 'all', 'project'].includes(target)) {
|
|
699
|
+
throw userError('--target must be auto, agents, codex, claude, cursor, copilot, all, or project.', 'BAD_SKILL_TARGET');
|
|
700
|
+
}
|
|
701
|
+
};
|
|
702
|
+
const createSkillInstallResult = (value) => {
|
|
703
|
+
const retained = value.retained ?? [];
|
|
704
|
+
const updated = value.installed;
|
|
705
|
+
const available = [...updated, ...retained];
|
|
706
|
+
return {
|
|
707
|
+
...value,
|
|
708
|
+
updateSkillSetVersion: value.skillSetVersion,
|
|
709
|
+
updateSource: value.source,
|
|
710
|
+
installed: updated,
|
|
711
|
+
updated,
|
|
712
|
+
retained,
|
|
713
|
+
available,
|
|
714
|
+
availableSkillSetVersions: [...new Set(available.map(row => row.skillSetVersion))],
|
|
715
|
+
availableSkillSources: [...new Set(available.map(row => row.source))],
|
|
716
|
+
availableSkillCount: available.length,
|
|
717
|
+
updatedSkillCount: updated.length,
|
|
718
|
+
retainedSkillCount: retained.length,
|
|
719
|
+
};
|
|
720
|
+
};
|
|
721
|
+
const installSkillSet = (skillSet, opts, selectedDestinations) => {
|
|
722
|
+
const target = opts.target ?? 'auto';
|
|
723
|
+
assertSkillTarget(target);
|
|
724
|
+
const home = opts.homeDir ?? homedir();
|
|
725
|
+
const codexHome = opts.codexHome ?? process.env.CODEX_HOME ?? join(home, '.codex');
|
|
726
|
+
const allDestinations = skillTargetDirectories(target, opts.projectDir, home, codexHome);
|
|
727
|
+
const destinations = selectedDestinations ?? allDestinations;
|
|
728
|
+
// Preflight every collision across every target before changing anything.
|
|
729
|
+
for (const destination of destinations) {
|
|
730
|
+
for (const skill of PUBLIC_SKILLS) {
|
|
731
|
+
const output = join(destination.directory, skill);
|
|
732
|
+
assertReplaceableSkillOutput(output, opts.force);
|
|
733
|
+
assertInstalledSkillVersionImmutable(output, skill, skillSet.manifest.skills.find(candidate => candidate.name === skill), skillSet.manifest.skillSetVersion);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
const prepared = [];
|
|
737
|
+
try {
|
|
738
|
+
for (const destination of destinations) {
|
|
739
|
+
prepared.push(prepareDestination(destination, skillSet, opts.fileOperations));
|
|
740
|
+
}
|
|
741
|
+
opts.afterPrepare?.(destinations);
|
|
742
|
+
for (const destination of prepared)
|
|
743
|
+
commitDestination(destination, skillSet, opts.force);
|
|
744
|
+
}
|
|
745
|
+
catch (error) {
|
|
746
|
+
const rollbackFailures = [];
|
|
747
|
+
for (const destination of [...prepared].reverse()) {
|
|
748
|
+
try {
|
|
749
|
+
rollbackDestination(destination);
|
|
750
|
+
}
|
|
751
|
+
catch (candidate) {
|
|
752
|
+
rollbackFailures.push({
|
|
753
|
+
target: destination.destination.target,
|
|
754
|
+
directory: destination.destination.directory,
|
|
755
|
+
backupRoot: destination.backupRoot,
|
|
756
|
+
...(candidate instanceof CaiError ? { code: candidate.code } : {}),
|
|
757
|
+
message: candidate instanceof Error ? candidate.message : String(candidate),
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
if (rollbackFailures.length > 0) {
|
|
762
|
+
throw new CaiError(`Skill installation failed and rollback was incomplete for ${rollbackFailures.length} ` +
|
|
763
|
+
`destination${rollbackFailures.length === 1 ? '' : 's'}:\n${rollbackFailures
|
|
764
|
+
.map(failure => `- ${failure.target} (${failure.directory}): recoverable backup at ` +
|
|
765
|
+
`${failure.backupRoot}; ${failure.message}`)
|
|
766
|
+
.join('\n')}`, {
|
|
767
|
+
code: 'SKILLS_ROLLBACK_FAILED',
|
|
768
|
+
detail: {
|
|
769
|
+
installationError: error instanceof Error ? error.message : String(error),
|
|
770
|
+
rollbackFailures,
|
|
771
|
+
},
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
throw error;
|
|
775
|
+
}
|
|
776
|
+
const installed = prepared.flatMap(destination => installedRows(destination, skillSet));
|
|
777
|
+
const cleanupWarnings = prepared.flatMap(finalizeDestination);
|
|
778
|
+
return createSkillInstallResult({
|
|
779
|
+
skillSetVersion: skillSet.manifest.skillSetVersion,
|
|
780
|
+
source: skillSet.source,
|
|
781
|
+
...(skillSet.manifestUrl ? { manifestUrl: skillSet.manifestUrl } : {}),
|
|
782
|
+
installed,
|
|
783
|
+
removedLegacyPaths: [],
|
|
784
|
+
migrationWarnings: [],
|
|
785
|
+
cleanupWarnings,
|
|
786
|
+
});
|
|
787
|
+
};
|
|
788
|
+
export const installSkills = (opts = {}) => installSkillSet(readBundledSkillSet(), opts);
|
|
789
|
+
export const updateSkills = async (opts = {}) => {
|
|
790
|
+
assertSkillTarget(opts.target ?? 'auto');
|
|
791
|
+
const manifestUrl = opts.manifestUrl ?? process.env.CAI_SKILLS_MANIFEST_URL ?? DEFAULT_SKILLS_MANIFEST_URL;
|
|
792
|
+
let skillSet;
|
|
793
|
+
try {
|
|
794
|
+
skillSet = await fetchHostedSkillSet(manifestUrl, opts.fetchImpl ?? fetch);
|
|
795
|
+
assertImmutableSkillSetVersion(skillSet.manifest, readBundledSkillSet().manifest);
|
|
796
|
+
}
|
|
797
|
+
catch (error) {
|
|
798
|
+
if (opts.fallbackToBundled === false)
|
|
799
|
+
throw error;
|
|
800
|
+
if (!(error instanceof CaiError) || !skillUpdateFallbackCodeSet.has(error.code)) {
|
|
801
|
+
throw error;
|
|
802
|
+
}
|
|
803
|
+
const warning = `Hosted skill update was unavailable (${error instanceof Error ? error.message : String(error)}). ` +
|
|
804
|
+
'Intact current or newer managed skill sets will be retained; the verified bundled set ' +
|
|
805
|
+
'will be installed only where retention is impossible.';
|
|
806
|
+
(opts.onWarning ?? warn)(warning);
|
|
807
|
+
skillSet = readBundledSkillSet();
|
|
808
|
+
return installAvailabilityFallback(skillSet, opts);
|
|
809
|
+
}
|
|
810
|
+
return installSkillSet(skillSet, opts);
|
|
811
|
+
};
|
|
812
|
+
const filesUnder = (directory, root = directory) => readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
|
|
813
|
+
const path = join(directory, entry.name);
|
|
814
|
+
if (entry.isDirectory())
|
|
815
|
+
return filesUnder(path, root);
|
|
816
|
+
return [relative(root, path).split(sep).join('/')];
|
|
817
|
+
});
|
|
818
|
+
const parseManagedMarker = (path, skill) => {
|
|
819
|
+
let value;
|
|
820
|
+
try {
|
|
821
|
+
const markerStat = lstatSync(path);
|
|
822
|
+
if (!markerStat.isFile() || markerStat.size > MAX_MANIFEST_SIZE)
|
|
823
|
+
return { kind: 'invalid' };
|
|
824
|
+
const bytes = readFileSync(path);
|
|
825
|
+
value = JSON.parse(bytes.toString('utf8'));
|
|
826
|
+
}
|
|
827
|
+
catch {
|
|
828
|
+
return { kind: 'invalid' };
|
|
829
|
+
}
|
|
830
|
+
if (!isRecord(value) || value.package !== '@controllerai/cli')
|
|
831
|
+
return { kind: 'invalid' };
|
|
832
|
+
if (value.schemaVersion !== 1 || value.skillSetVersion === undefined || value.digest === undefined) {
|
|
833
|
+
return { kind: 'legacy' };
|
|
834
|
+
}
|
|
835
|
+
if (value.skill !== skill ||
|
|
836
|
+
typeof value.installedAt !== 'string' ||
|
|
837
|
+
typeof value.skillSetVersion !== 'string' ||
|
|
838
|
+
typeof value.digest !== 'string' ||
|
|
839
|
+
!/^[a-f0-9]{64}$/.test(value.digest) ||
|
|
840
|
+
!Array.isArray(value.files) ||
|
|
841
|
+
!['bundled', 'hosted'].includes(String(value.source))) {
|
|
842
|
+
return { kind: 'invalid' };
|
|
843
|
+
}
|
|
844
|
+
const seen = new Set();
|
|
845
|
+
const files = [];
|
|
846
|
+
try {
|
|
847
|
+
for (const candidate of value.files) {
|
|
848
|
+
if (!isRecord(candidate))
|
|
849
|
+
return { kind: 'invalid' };
|
|
850
|
+
const filePath = safeRelativePath(candidate.path, 'Managed skill file path');
|
|
851
|
+
if (seen.has(filePath))
|
|
852
|
+
return { kind: 'invalid' };
|
|
853
|
+
seen.add(filePath);
|
|
854
|
+
if (typeof candidate.sha256 !== 'string' ||
|
|
855
|
+
!/^[a-f0-9]{64}$/.test(candidate.sha256) ||
|
|
856
|
+
typeof candidate.size !== 'number' ||
|
|
857
|
+
!Number.isSafeInteger(candidate.size) ||
|
|
858
|
+
candidate.size < 0 ||
|
|
859
|
+
candidate.size > MAX_SKILL_FILE_SIZE) {
|
|
860
|
+
return { kind: 'invalid' };
|
|
861
|
+
}
|
|
862
|
+
files.push({ path: filePath, sha256: candidate.sha256, size: candidate.size });
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
catch {
|
|
866
|
+
return { kind: 'invalid' };
|
|
867
|
+
}
|
|
868
|
+
files.sort((left, right) => left.path.localeCompare(right.path));
|
|
869
|
+
if (!seen.has('SKILL.md') || digestMarkerFiles(files) !== value.digest) {
|
|
870
|
+
return { kind: 'invalid' };
|
|
871
|
+
}
|
|
872
|
+
return {
|
|
873
|
+
kind: 'current',
|
|
874
|
+
marker: {
|
|
875
|
+
schemaVersion: 1,
|
|
876
|
+
package: '@controllerai/cli',
|
|
877
|
+
skill,
|
|
878
|
+
installedAt: value.installedAt,
|
|
879
|
+
skillSetVersion: value.skillSetVersion,
|
|
880
|
+
digest: value.digest,
|
|
881
|
+
files,
|
|
882
|
+
source: value.source,
|
|
883
|
+
...(typeof value.manifestUrl === 'string' ? { manifestUrl: value.manifestUrl } : {}),
|
|
884
|
+
},
|
|
885
|
+
};
|
|
886
|
+
};
|
|
887
|
+
function assertInstalledSkillVersionImmutable(output, skill, incoming, incomingVersion) {
|
|
888
|
+
if (!isControllerManagedSkillDirectory(output))
|
|
889
|
+
return;
|
|
890
|
+
const parsed = parseManagedMarker(join(output, MARKER), skill);
|
|
891
|
+
if (parsed.kind !== 'current' || parsed.marker.skillSetVersion !== incomingVersion)
|
|
892
|
+
return;
|
|
893
|
+
const incomingDigest = digestMarkerFiles(markerFilesForSkill(incoming));
|
|
894
|
+
if (parsed.marker.digest === incomingDigest)
|
|
895
|
+
return;
|
|
896
|
+
throw userError(`Installed ${skill} and the update both claim skill set ${incomingVersion}, but their ` +
|
|
897
|
+
'contents differ. Skill-set versions are immutable; refusing to overwrite the installed copy.', 'SKILL_SET_VERSION_CONFLICT');
|
|
898
|
+
}
|
|
899
|
+
const inspectInstalledSkill = (destination, skill, expected, baselineVersion) => {
|
|
900
|
+
const path = join(destination.directory, skill);
|
|
901
|
+
const skillFile = join(path, 'SKILL.md');
|
|
902
|
+
const expectedFiles = markerFilesForSkill(expected);
|
|
903
|
+
const expectedDigest = digestMarkerFiles(expectedFiles);
|
|
904
|
+
const base = {
|
|
905
|
+
target: destination.target,
|
|
906
|
+
skill,
|
|
907
|
+
path,
|
|
908
|
+
skillFile,
|
|
909
|
+
installed: existsSync(skillFile),
|
|
910
|
+
managed: existsSync(join(path, MARKER)),
|
|
911
|
+
expectedDigest,
|
|
912
|
+
};
|
|
913
|
+
if (!existsSync(path))
|
|
914
|
+
return { ...base, status: 'missing' };
|
|
915
|
+
if (!base.managed) {
|
|
916
|
+
return { ...base, status: 'unmanaged', reason: 'Controller AI management marker is absent.' };
|
|
917
|
+
}
|
|
918
|
+
const parsed = parseManagedMarker(join(path, MARKER), skill);
|
|
919
|
+
if (parsed.kind === 'legacy') {
|
|
920
|
+
return { ...base, status: 'stale', reason: 'Legacy marker has no version or digest.' };
|
|
921
|
+
}
|
|
922
|
+
if (parsed.kind === 'invalid') {
|
|
923
|
+
return { ...base, status: 'invalid', reason: 'Management marker is invalid.' };
|
|
924
|
+
}
|
|
925
|
+
const marker = parsed.marker;
|
|
926
|
+
for (const file of marker.files) {
|
|
927
|
+
const filePath = join(path, ...file.path.split('/'));
|
|
928
|
+
if (!existsSync(filePath)) {
|
|
929
|
+
return {
|
|
930
|
+
...base,
|
|
931
|
+
status: 'incomplete',
|
|
932
|
+
skillSetVersion: marker.skillSetVersion,
|
|
933
|
+
digest: marker.digest,
|
|
934
|
+
source: marker.source,
|
|
935
|
+
reason: `${file.path} is missing.`,
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
const fileStat = lstatSync(filePath);
|
|
939
|
+
if (!fileStat.isFile()) {
|
|
940
|
+
return {
|
|
941
|
+
...base,
|
|
942
|
+
status: 'incomplete',
|
|
943
|
+
skillSetVersion: marker.skillSetVersion,
|
|
944
|
+
digest: marker.digest,
|
|
945
|
+
source: marker.source,
|
|
946
|
+
reason: `${file.path} is not a regular file.`,
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
if (fileStat.size !== file.size) {
|
|
950
|
+
return {
|
|
951
|
+
...base,
|
|
952
|
+
status: 'modified',
|
|
953
|
+
skillSetVersion: marker.skillSetVersion,
|
|
954
|
+
digest: marker.digest,
|
|
955
|
+
source: marker.source,
|
|
956
|
+
reason: `${file.path} size differs from its installed marker.`,
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
const actualFiles = filesUnder(path).filter(file => file !== MARKER).sort();
|
|
961
|
+
const declaredFiles = marker.files.map(file => file.path).sort();
|
|
962
|
+
if (actualFiles.length !== declaredFiles.length ||
|
|
963
|
+
actualFiles.some((file, index) => file !== declaredFiles[index])) {
|
|
964
|
+
return {
|
|
965
|
+
...base,
|
|
966
|
+
status: 'modified',
|
|
967
|
+
skillSetVersion: marker.skillSetVersion,
|
|
968
|
+
digest: marker.digest,
|
|
969
|
+
source: marker.source,
|
|
970
|
+
reason: 'Managed skill contains undeclared or missing files.',
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
for (const file of marker.files) {
|
|
974
|
+
const filePath = join(path, ...file.path.split('/'));
|
|
975
|
+
const fileStat = lstatSync(filePath);
|
|
976
|
+
if (!fileStat.isFile() || fileStat.size !== file.size) {
|
|
977
|
+
return {
|
|
978
|
+
...base,
|
|
979
|
+
status: 'modified',
|
|
980
|
+
skillSetVersion: marker.skillSetVersion,
|
|
981
|
+
digest: marker.digest,
|
|
982
|
+
source: marker.source,
|
|
983
|
+
reason: `${file.path} changed while it was being checked.`,
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
const bytes = readFileSync(filePath);
|
|
987
|
+
if (bytes.length !== file.size || sha256(bytes) !== file.sha256) {
|
|
988
|
+
return {
|
|
989
|
+
...base,
|
|
990
|
+
status: 'modified',
|
|
991
|
+
skillSetVersion: marker.skillSetVersion,
|
|
992
|
+
digest: marker.digest,
|
|
993
|
+
source: marker.source,
|
|
994
|
+
reason: `${file.path} differs from its installed digest.`,
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
const versionComparison = compareSkillSetVersions(marker.skillSetVersion, baselineVersion);
|
|
999
|
+
const stale = versionComparison === undefined
|
|
1000
|
+
? marker.skillSetVersion !== baselineVersion
|
|
1001
|
+
: versionComparison < 0 || (versionComparison === 0 && marker.digest !== expectedDigest);
|
|
1002
|
+
if (stale) {
|
|
1003
|
+
return {
|
|
1004
|
+
...base,
|
|
1005
|
+
status: 'stale',
|
|
1006
|
+
skillSetVersion: marker.skillSetVersion,
|
|
1007
|
+
digest: marker.digest,
|
|
1008
|
+
source: marker.source,
|
|
1009
|
+
reason: `Bundled skill set is ${baselineVersion}.`,
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
return {
|
|
1013
|
+
...base,
|
|
1014
|
+
status: 'current',
|
|
1015
|
+
skillSetVersion: marker.skillSetVersion,
|
|
1016
|
+
digest: marker.digest,
|
|
1017
|
+
source: marker.source,
|
|
1018
|
+
};
|
|
1019
|
+
};
|
|
1020
|
+
export const installedSkillStatus = (opts = {}) => {
|
|
1021
|
+
const manifest = readBundledSkillsManifest();
|
|
1022
|
+
const expected = new Map(manifest.skills.map(skill => [skill.name, skill]));
|
|
1023
|
+
const home = opts.homeDir ?? homedir();
|
|
1024
|
+
const codexHome = opts.codexHome ?? process.env.CODEX_HOME ?? join(home, '.codex');
|
|
1025
|
+
return skillTargetDirectories(opts.target ?? 'all', opts.projectDir, home, codexHome).flatMap(destination => PUBLIC_SKILLS.map(skill => inspectInstalledSkill(destination, skill, expected.get(skill), manifest.skillSetVersion)));
|
|
1026
|
+
};
|
|
1027
|
+
const installAvailabilityFallback = (bundledSkillSet, opts) => {
|
|
1028
|
+
const target = opts.target ?? 'auto';
|
|
1029
|
+
const home = opts.homeDir ?? homedir();
|
|
1030
|
+
const codexHome = opts.codexHome ?? process.env.CODEX_HOME ?? join(home, '.codex');
|
|
1031
|
+
const destinations = skillTargetDirectories(target, opts.projectDir, home, codexHome);
|
|
1032
|
+
const statuses = installedSkillStatus(opts);
|
|
1033
|
+
const retained = [];
|
|
1034
|
+
const installDestinations = destinations.filter(destination => {
|
|
1035
|
+
const rows = statuses.filter(row => directoryIdentity(dirname(row.path)) === directoryIdentity(destination.directory));
|
|
1036
|
+
const retain = rows.length === PUBLIC_SKILLS.length && rows.every(row => row.status === 'current');
|
|
1037
|
+
if (!retain)
|
|
1038
|
+
return true;
|
|
1039
|
+
for (const row of rows) {
|
|
1040
|
+
retained.push({
|
|
1041
|
+
target: destination.target,
|
|
1042
|
+
skill: row.skill,
|
|
1043
|
+
path: row.path,
|
|
1044
|
+
skillFile: row.skillFile,
|
|
1045
|
+
skillSetVersion: row.skillSetVersion,
|
|
1046
|
+
digest: row.digest,
|
|
1047
|
+
source: row.source,
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
return false;
|
|
1051
|
+
});
|
|
1052
|
+
const result = installSkillSet(bundledSkillSet, opts, installDestinations);
|
|
1053
|
+
return createSkillInstallResult({
|
|
1054
|
+
skillSetVersion: result.skillSetVersion,
|
|
1055
|
+
source: result.source,
|
|
1056
|
+
...(result.manifestUrl ? { manifestUrl: result.manifestUrl } : {}),
|
|
1057
|
+
installed: result.installed,
|
|
1058
|
+
retained,
|
|
1059
|
+
removedLegacyPaths: result.removedLegacyPaths,
|
|
1060
|
+
migrationWarnings: result.migrationWarnings,
|
|
1061
|
+
cleanupWarnings: result.cleanupWarnings,
|
|
1062
|
+
});
|
|
1063
|
+
};
|
|
1064
|
+
export const entrySkillPaths = (result) => [
|
|
1065
|
+
...new Set(result.available
|
|
1066
|
+
.filter(row => row.skill === 'controllerai-start')
|
|
1067
|
+
.map(row => row.skillFile)),
|
|
1068
|
+
];
|
|
1069
|
+
const printInstallResult = (result) => {
|
|
1070
|
+
const entries = entrySkillPaths(result);
|
|
1071
|
+
const available = [
|
|
1072
|
+
...result.updated.map(row => ({ action: 'updated', row })),
|
|
1073
|
+
...result.retained.map(row => ({ action: 'retained', row })),
|
|
1074
|
+
];
|
|
1075
|
+
printKeyValues([
|
|
1076
|
+
['update skill set', result.updateSkillSetVersion],
|
|
1077
|
+
['update source', result.updateSource],
|
|
1078
|
+
['available versions', result.availableSkillSetVersions.join(', ')],
|
|
1079
|
+
['available sources', result.availableSkillSources.join(', ')],
|
|
1080
|
+
['skills available', result.availableSkillCount],
|
|
1081
|
+
['skills updated', result.updatedSkillCount],
|
|
1082
|
+
['skills retained', result.retainedSkillCount],
|
|
1083
|
+
]);
|
|
1084
|
+
process.stdout.write('\nAvailable skills:\n');
|
|
1085
|
+
printTable(['action', 'target', 'skill', 'version', 'digest', 'path'], available.map(({ action, row }) => [
|
|
1086
|
+
action,
|
|
1087
|
+
row.target,
|
|
1088
|
+
row.skill,
|
|
1089
|
+
row.skillSetVersion,
|
|
1090
|
+
row.digest,
|
|
1091
|
+
row.skillFile,
|
|
1092
|
+
]));
|
|
1093
|
+
if (result.removedLegacyPaths.length > 0) {
|
|
1094
|
+
process.stdout.write('\nRemoved legacy managed Codex copies:\n');
|
|
1095
|
+
for (const path of result.removedLegacyPaths)
|
|
1096
|
+
process.stdout.write(` ${path}\n`);
|
|
1097
|
+
}
|
|
1098
|
+
if (entries.length > 0) {
|
|
1099
|
+
process.stdout.write('\nRead this entry skill now:\n');
|
|
1100
|
+
for (const path of entries)
|
|
1101
|
+
process.stdout.write(` ${path}\n`);
|
|
1102
|
+
}
|
|
1103
|
+
};
|
|
1104
|
+
const emitInstallWarnings = (result) => {
|
|
1105
|
+
for (const message of [...result.migrationWarnings, ...result.cleanupWarnings])
|
|
1106
|
+
warn(message);
|
|
1107
|
+
};
|
|
1108
|
+
export const registerSkills = (program) => {
|
|
1109
|
+
const skills = program
|
|
1110
|
+
.command('skills')
|
|
1111
|
+
.description('Install and update Controller AI guidance for your agent');
|
|
1112
|
+
const addInstallOptions = (command) => command
|
|
1113
|
+
.option('--target <target>', 'auto, agents, codex, claude, cursor, copilot, all, or project', 'auto')
|
|
1114
|
+
.option('--project-dir <path>', 'project root for the open-standard project target')
|
|
1115
|
+
.option('--force', 'replace an existing unmanaged skill with the same name', false);
|
|
1116
|
+
addInstallOptions(skills.command('install'))
|
|
1117
|
+
.description('Install the verified skill set bundled with this CLI')
|
|
1118
|
+
.action(async (opts) => {
|
|
1119
|
+
const result = installSkills(opts);
|
|
1120
|
+
emitInstallWarnings(result);
|
|
1121
|
+
emit({
|
|
1122
|
+
...result,
|
|
1123
|
+
entrySkillPath: entrySkillPaths(result)[0],
|
|
1124
|
+
entrySkillPaths: entrySkillPaths(result),
|
|
1125
|
+
}, () => printInstallResult(result));
|
|
1126
|
+
});
|
|
1127
|
+
addInstallOptions(skills.command('update'))
|
|
1128
|
+
.description('Fetch, verify, and install the latest hosted Controller AI skill set')
|
|
1129
|
+
.action(async (opts) => {
|
|
1130
|
+
const result = await updateSkills(opts);
|
|
1131
|
+
emitInstallWarnings(result);
|
|
1132
|
+
emit({
|
|
1133
|
+
...result,
|
|
1134
|
+
entrySkillPath: entrySkillPaths(result)[0],
|
|
1135
|
+
entrySkillPaths: entrySkillPaths(result),
|
|
1136
|
+
}, () => printInstallResult(result));
|
|
1137
|
+
});
|
|
1138
|
+
skills
|
|
1139
|
+
.command('status')
|
|
1140
|
+
.option('--target <target>', 'auto, agents, codex, claude, cursor, copilot, all, or project', 'all')
|
|
1141
|
+
.option('--project-dir <path>', 'project root for the open-standard project target')
|
|
1142
|
+
.description('Verify installed Controller AI skills, versions, and digests')
|
|
1143
|
+
.action(async (opts) => {
|
|
1144
|
+
assertSkillTarget(opts.target);
|
|
1145
|
+
const rows = installedSkillStatus(opts);
|
|
1146
|
+
emit({ skills: rows }, () => printTable(['target', 'skill', 'status', 'version', 'digest', 'path'], rows.map(row => [
|
|
1147
|
+
row.target,
|
|
1148
|
+
row.skill,
|
|
1149
|
+
row.status,
|
|
1150
|
+
row.skillSetVersion,
|
|
1151
|
+
row.digest,
|
|
1152
|
+
row.skillFile,
|
|
1153
|
+
])));
|
|
1154
|
+
});
|
|
1155
|
+
};
|
|
1156
|
+
//# sourceMappingURL=skills.js.map
|