@constraint/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/src/cli.js ADDED
@@ -0,0 +1,405 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import readline from 'node:readline/promises';
4
+
5
+ import { apiRequest, jsonRequest } from './api.js';
6
+ import { rejectUnknown, takeFlag, takeOption } from './arguments.js';
7
+ import { login, logout } from './auth.js';
8
+ import { loadConfig } from './config.js';
9
+ import { mediaType } from './files.js';
10
+ import { AGENTS, detectAgents, findProjectRoot, normalizeAgent } from './paths.js';
11
+ import {
12
+ begin,
13
+ chooseAgents,
14
+ chooseMode,
15
+ chooseScope,
16
+ confirmPlan,
17
+ finish,
18
+ note,
19
+ taskSpinner,
20
+ } from './prompts.js';
21
+ import { setupAgent, setupStatus } from './setup.js';
22
+ import {
23
+ configuredAgents,
24
+ installSkill,
25
+ installedSkills,
26
+ manageableDomains,
27
+ readTranscript,
28
+ updateAll,
29
+ uploadSkill,
30
+ } from './skills.js';
31
+
32
+ const VERSION = '0.2.0';
33
+
34
+ const HELP = `Constraint Skills CLI
35
+
36
+ Usage:
37
+ constraint login | logout | whoami
38
+ constraint setup [--agent codex|claude-code]... [--dry-run] [--yes]
39
+ constraint doctor [--agent codex|claude-code]...
40
+ constraint skills list [--search <text>]
41
+ constraint skills list --installed [--project|--global] [--agent <agent>]...
42
+ constraint skills inspect <skill> [--version <version>] [--files]
43
+ constraint skills install <skill> [--version <version>] [--agent <agent>]...
44
+ [--project|--global] [--copy] [--yes]
45
+ constraint skills update <skill> [--agent <agent>]... [--project|--global]
46
+ [--copy] [--yes]
47
+ constraint skills update --all [--agent <agent>]... [--project|--global] [--yes]
48
+ constraint skills upload <path>
49
+ constraint skills run start <skill> --client <client> --session <session-id>
50
+ constraint skills run complete <run-id> <transcript-path>
51
+ constraint skills run list [--team <team>]... [--user <user>]... [--skill <skill>]...
52
+ constraint skills run get <run-id>... [--raw] [--output <path>]
53
+
54
+ Global options: --json --quiet --no-color --help --version`;
55
+
56
+ function globals(args) {
57
+ const copy = [...args];
58
+ return {
59
+ args: copy.filter((value) => !['--json', '--quiet', '--no-color'].includes(value)),
60
+ json: copy.includes('--json'),
61
+ quiet: copy.includes('--quiet'),
62
+ noColor: copy.includes('--no-color'),
63
+ };
64
+ }
65
+
66
+ function printer(options) {
67
+ return {
68
+ line(value) {
69
+ if (!options.quiet) process.stdout.write(`${typeof value === 'string' ? value : JSON.stringify(value, null, 2)}\n`);
70
+ },
71
+ data(value) {
72
+ if (!options.quiet) process.stdout.write(`${JSON.stringify(value, null, options.json ? 0 : 2)}\n`);
73
+ },
74
+ };
75
+ }
76
+
77
+ function interactive(out) {
78
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY && !out.json && !out.quiet);
79
+ }
80
+
81
+ function takeScope(args) {
82
+ const project = takeFlag(args, '--project');
83
+ const global = takeFlag(args, '--global');
84
+ if (project && global) throw new Error('Pass either --project or --global, not both.');
85
+ return project ? 'project' : global ? 'global' : null;
86
+ }
87
+
88
+ function takeAgents(args) {
89
+ return takeOption(args, '--agent', { multiple: true }).map(normalizeAgent);
90
+ }
91
+
92
+ async function resolveAgents(provided, out, { prompt = true } = {}) {
93
+ if (provided.length) return [...new Set(provided)];
94
+ const configured = await configuredAgents();
95
+ const detected = configured.length ? configured : await detectAgents();
96
+ if (interactive(out) && prompt) return chooseAgents(detected);
97
+ if (detected.length) return detected;
98
+ throw new Error('No agent is configured. Pass --agent codex or --agent claude-code.');
99
+ }
100
+
101
+ async function chooseDomain(domains) {
102
+ if (domains.length === 0) throw new Error('You do not have permission to upload skills.');
103
+ if (domains.length === 1) return domains[0];
104
+ if (!process.stdin.isTTY) throw new Error('Upload has more than one permitted domain and requires an interactive terminal.');
105
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
106
+ domains.forEach((domain, index) => process.stdout.write(`${index + 1}. ${domain}\n`));
107
+ const answer = await rl.question('Choose the skill domain: ');
108
+ rl.close();
109
+ const selected = domains[Number(answer) - 1];
110
+ if (!selected) throw new Error('Invalid domain selection.');
111
+ return selected;
112
+ }
113
+
114
+ async function authCommand(command, args, config, out) {
115
+ rejectUnknown(args);
116
+ if (args.length) throw new Error(`${command} does not accept positional arguments`);
117
+ if (command === 'login') {
118
+ const session = await login(config, out.line);
119
+ out.line(`Logged in as ${session.user?.email || session.user?.id || 'Constraint user'}.`);
120
+ } else if (command === 'logout') {
121
+ await logout(config);
122
+ out.line('Logged out.');
123
+ } else {
124
+ out.data(await apiRequest(config, '/me'));
125
+ }
126
+ }
127
+
128
+ async function setupCommand(args, out) {
129
+ let agents = takeAgents(args);
130
+ const dryRun = takeFlag(args, '--dry-run');
131
+ const yes = takeFlag(args, '--yes');
132
+ rejectUnknown(args);
133
+ if (args.length) throw new Error('setup does not accept positional arguments');
134
+ const useUi = interactive(out) && !dryRun;
135
+ if (useUi) begin('Constraint Skills setup');
136
+ if (!agents.length) {
137
+ const configured = await configuredAgents();
138
+ const detected = configured.length ? configured : await detectAgents();
139
+ agents = useUi && !yes ? await chooseAgents(detected) : detected;
140
+ }
141
+ if (!agents.length) throw new Error('Choose at least one agent with --agent.');
142
+ const results = [];
143
+ for (const agent of agents) {
144
+ results.push(await setupAgent(agent, {
145
+ dryRun,
146
+ yes,
147
+ output: useUi ? () => {} : out.line,
148
+ confirmChange: confirmPlan,
149
+ }));
150
+ }
151
+ if (useUi) finish(`Constraint Skills configured for ${agents.join(' and ')}.`);
152
+ else out.data(results);
153
+ }
154
+
155
+ async function doctorCommand(args, config, out) {
156
+ let agents = takeAgents(args);
157
+ rejectUnknown(args);
158
+ if (args.length) throw new Error('doctor does not accept positional arguments');
159
+ if (!agents.length) agents = AGENTS;
160
+ let identity = null;
161
+ let authError = null;
162
+ try {
163
+ identity = await apiRequest(config, '/me');
164
+ } catch (error) {
165
+ authError = error.message;
166
+ }
167
+ out.data({
168
+ api_url: config.apiUrl,
169
+ authenticated: Boolean(identity),
170
+ identity,
171
+ auth_error: authError,
172
+ agents: await Promise.all(agents.map(setupStatus)),
173
+ });
174
+ }
175
+
176
+ async function listSkills(args, config, out) {
177
+ const search = takeOption(args, '--search');
178
+ const installed = takeFlag(args, '--installed');
179
+ const scopeFlag = takeScope(args);
180
+ const agents = takeAgents(args);
181
+ rejectUnknown(args);
182
+ if (args.length) throw new Error('skills list does not accept positional arguments');
183
+ if (installed) {
184
+ if (search) throw new Error('--search cannot be combined with --installed.');
185
+ out.data(await installedSkills({ scope: scopeFlag || 'project', agents }));
186
+ return;
187
+ }
188
+ if (scopeFlag || agents.length) throw new Error('--project, --global, and --agent require --installed.');
189
+ const query = search ? `?search=${encodeURIComponent(search)}` : '';
190
+ out.data(await apiRequest(config, `/skills${query}`));
191
+ }
192
+
193
+ async function inspectSkill(args, config, out) {
194
+ const version = takeOption(args, '--version');
195
+ const files = takeFlag(args, '--files');
196
+ rejectUnknown(args);
197
+ if (args.length !== 1) throw new Error('Usage: constraint skills inspect <skill>');
198
+ const suffix = version ? `/versions/${encodeURIComponent(version)}` : '';
199
+ const detail = await apiRequest(config, `/skills/${encodeURIComponent(args[0])}${suffix}`);
200
+ if (!files) detail.version.files = [];
201
+ out.data(detail);
202
+ }
203
+
204
+ async function installCommand(args, config, out, update = false) {
205
+ const version = takeOption(args, '--version');
206
+ let agents = takeAgents(args);
207
+ let scope = takeScope(args);
208
+ const copyRequested = takeFlag(args, '--copy');
209
+ let copy = copyRequested ? true : update ? undefined : false;
210
+ const yes = takeFlag(args, '--yes');
211
+ rejectUnknown(args);
212
+ if (args.length !== 1) throw new Error(`Usage: constraint skills ${update ? 'update' : 'install'} <skill>`);
213
+ const useUi = interactive(out);
214
+ if (useUi) begin(update ? 'Update a Constraint skill' : 'Install a Constraint skill');
215
+ if (!scope) scope = useUi && !yes ? await chooseScope() : 'project';
216
+ const root = scope === 'project' ? await findProjectRoot() : null;
217
+ if (update && !agents.length) {
218
+ const installed = await installedSkills({ scope, projectRoot: root });
219
+ agents = [...new Set(installed
220
+ .filter((item) => item.skill_id === args[0] || item.slug === args[0])
221
+ .map((item) => item.agent))];
222
+ }
223
+ agents = await resolveAgents(agents, out, { prompt: !yes && !update });
224
+ if (!copyRequested && useUi && !yes) copy = (await chooseMode()) === 'copy';
225
+ if (useUi) {
226
+ note([
227
+ `${update ? 'Update' : 'Install'}: ${args[0]}${version ? ` v${version}` : ' (latest)'}`,
228
+ `Agents: ${agents.join(', ')}`,
229
+ `Scope: ${scope}${root ? ` (${root})` : ''}`,
230
+ `Mode: ${copy === undefined ? 'preserve installed mode' : copy ? 'copy' : 'shared package'}`,
231
+ ].join('\n'));
232
+ if (!yes && !(await confirmPlan(`${update ? 'Update' : 'Install'} this skill?`))) {
233
+ finish('No changes made.');
234
+ return;
235
+ }
236
+ }
237
+ const spinner = taskSpinner(useUi);
238
+ const results = await installSkill(config, args[0], {
239
+ version: version ? Number(version) : undefined,
240
+ agents,
241
+ scope,
242
+ projectRoot: root,
243
+ copy,
244
+ update,
245
+ progress: (message) => spinner.message(message),
246
+ });
247
+ const data = results.map((result) => ({
248
+ skill: result.detail.slug,
249
+ version: result.detail.version.version,
250
+ agent: result.agent,
251
+ scope: result.scope,
252
+ mode: result.mode,
253
+ path: result.target,
254
+ changed: result.changed,
255
+ }));
256
+ if (useUi) {
257
+ spinner.stop(`${data[0].skill} v${data[0].version} installed.`);
258
+ finish(`Ready in ${agents.length} agent${agents.length === 1 ? '' : 's'}.`);
259
+ } else out.data(data.length === 1 ? data[0] : data);
260
+ }
261
+
262
+ async function updateCommand(args, config, out) {
263
+ const all = takeFlag(args, '--all');
264
+ if (!all) return installCommand(args, config, out, true);
265
+ const agents = takeAgents(args);
266
+ let scope = takeScope(args);
267
+ const yes = takeFlag(args, '--yes');
268
+ if (takeFlag(args, '--copy')) throw new Error('--copy is not used with update --all; each installed mode is preserved.');
269
+ rejectUnknown(args);
270
+ if (args.length) throw new Error('Pass either --all or a skill, not both.');
271
+ const useUi = interactive(out);
272
+ if (!scope) scope = useUi ? (begin('Update Constraint skills'), await chooseScope()) : 'project';
273
+ if (useUi && !yes && !(await confirmPlan(`Update all installed skills in ${scope} scope?`))) {
274
+ finish('No changes made.');
275
+ return;
276
+ }
277
+ const spinner = taskSpinner(useUi);
278
+ const results = await updateAll(config, {
279
+ agents,
280
+ scope,
281
+ progress: (message) => spinner.message(message),
282
+ });
283
+ const data = results.map((item) => ({
284
+ skill: item.detail.slug,
285
+ version: item.detail.version.version,
286
+ agent: item.agent,
287
+ scope: item.scope,
288
+ mode: item.mode,
289
+ changed: item.changed,
290
+ }));
291
+ if (useUi) {
292
+ spinner.stop(`Updated ${data.length} installation${data.length === 1 ? '' : 's'}.`);
293
+ finish('Skills are current.');
294
+ } else out.data(data);
295
+ }
296
+
297
+ async function uploadCommand(args, config, out) {
298
+ rejectUnknown(args);
299
+ if (args.length !== 1) throw new Error('Usage: constraint skills upload <path>');
300
+ const domain = await chooseDomain(await manageableDomains(config));
301
+ const detail = await uploadSkill(config, args[0], domain);
302
+ out.data({ skill_id: detail.skill_id, slug: detail.slug, domain: detail.domain, version: detail.version.version });
303
+ }
304
+
305
+ async function runStart(args, config, out) {
306
+ const client = takeOption(args, '--client');
307
+ const session = takeOption(args, '--session');
308
+ rejectUnknown(args);
309
+ if (args.length !== 1 || !client || !session) {
310
+ throw new Error('Usage: constraint skills run start <skill> --client <client> --session <session-id>');
311
+ }
312
+ const run = await apiRequest(config, '/skill-runs', {
313
+ method: 'POST',
314
+ ...jsonRequest({ skill: args[0], client, session_id: session }),
315
+ });
316
+ if (out.json) out.data(run);
317
+ else out.line(run.run_id);
318
+ }
319
+
320
+ async function runComplete(args, config, out) {
321
+ rejectUnknown(args);
322
+ if (args.length !== 2) throw new Error('Usage: constraint skills run complete <run-id> <transcript-path>');
323
+ const transcript = await readTranscript(args[1]);
324
+ const run = await apiRequest(config, `/skill-runs/${encodeURIComponent(args[0])}/complete`, {
325
+ method: 'POST',
326
+ headers: { 'Content-Type': mediaType(transcript.absolute), 'X-Transcript-Filename': transcript.filename },
327
+ body: transcript.content,
328
+ });
329
+ out.data(run);
330
+ }
331
+
332
+ async function runList(args, config, out) {
333
+ const query = new URLSearchParams();
334
+ for (const value of takeOption(args, '--team', { multiple: true })) query.append('team', value);
335
+ for (const value of takeOption(args, '--user', { multiple: true })) query.append('user', value);
336
+ for (const value of takeOption(args, '--skill', { multiple: true })) query.append('skill', value);
337
+ for (const name of ['--after', '--before', '--limit', '--cursor']) {
338
+ const value = takeOption(args, name);
339
+ if (value) query.set(name.slice(2), value);
340
+ }
341
+ rejectUnknown(args);
342
+ if (args.length) throw new Error('skills run list does not accept positional arguments');
343
+ out.data(await apiRequest(config, `/skill-runs?${query.toString()}`));
344
+ }
345
+
346
+ async function runGet(args, config, out) {
347
+ const raw = takeFlag(args, '--raw');
348
+ const output = takeOption(args, '--output');
349
+ rejectUnknown(args);
350
+ if (!args.length) throw new Error('Usage: constraint skills run get <run-id>...');
351
+ if (raw && args.length !== 1) throw new Error('--raw accepts exactly one run ID');
352
+ if (raw) {
353
+ const response = await apiRequest(config, `/skill-runs/${encodeURIComponent(args[0])}/transcript`, { rawResponse: true });
354
+ const content = Buffer.from(await response.arrayBuffer());
355
+ if (output) await fs.writeFile(path.resolve(output), content);
356
+ else process.stdout.write(content);
357
+ return;
358
+ }
359
+ const runs = await Promise.all(args.map((runId) => apiRequest(config, `/skill-runs/${encodeURIComponent(runId)}`)));
360
+ const value = runs.length === 1 ? runs[0] : runs;
361
+ if (output) await fs.writeFile(path.resolve(output), `${JSON.stringify(value, null, 2)}\n`);
362
+ else out.data(value);
363
+ }
364
+
365
+ async function runCommand(args, config, out) {
366
+ const command = args.shift();
367
+ if (command === 'start') return runStart(args, config, out);
368
+ if (command === 'complete') return runComplete(args, config, out);
369
+ if (command === 'list') return runList(args, config, out);
370
+ if (command === 'get') return runGet(args, config, out);
371
+ throw new Error('Expected skills run start, complete, list, or get.');
372
+ }
373
+
374
+ async function skillsCommand(args, config, out) {
375
+ const command = args.shift();
376
+ if (command === 'list') return listSkills(args, config, out);
377
+ if (command === 'inspect') return inspectSkill(args, config, out);
378
+ if (command === 'install') return installCommand(args, config, out);
379
+ if (command === 'update') return updateCommand(args, config, out);
380
+ if (command === 'upload') return uploadCommand(args, config, out);
381
+ if (command === 'run') return runCommand(args, config, out);
382
+ throw new Error('Expected skills list, inspect, install, update, upload, or run.');
383
+ }
384
+
385
+ export async function main(argv) {
386
+ if (argv.length === 1 && argv[0] === '--version') {
387
+ process.stdout.write(`${VERSION}\n`);
388
+ return;
389
+ }
390
+ if (!argv.length || (argv.length === 1 && ['--help', '-h'].includes(argv[0]))) {
391
+ process.stdout.write(`${HELP}\n`);
392
+ return;
393
+ }
394
+ const options = globals(argv);
395
+ if (options.noColor) process.env.NO_COLOR = '1';
396
+ const args = options.args;
397
+ const out = { ...printer(options), ...options };
398
+ const command = args.shift();
399
+ const config = await loadConfig();
400
+ if (['login', 'logout', 'whoami'].includes(command)) return authCommand(command, args, config, out);
401
+ if (command === 'setup') return setupCommand(args, out);
402
+ if (command === 'doctor') return doctorCommand(args, config, out);
403
+ if (command === 'skills') return skillsCommand(args, config, out);
404
+ throw new Error(`Unknown command: ${command}`);
405
+ }
package/src/config.js ADDED
@@ -0,0 +1,61 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ import { configDirectory } from './paths.js';
5
+
6
+ const DEFAULT_API_URL = 'https://api-production-3183d.up.railway.app';
7
+
8
+ export async function readJson(file, fallback) {
9
+ try {
10
+ return JSON.parse(await fs.readFile(file, 'utf8'));
11
+ } catch (error) {
12
+ if (error && error.code === 'ENOENT') return fallback;
13
+ throw error;
14
+ }
15
+ }
16
+
17
+ export async function writeJson(file, value, { mode = 0o600 } = {}) {
18
+ await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
19
+ const temporary = `${file}.${process.pid}.tmp`;
20
+ await fs.writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode });
21
+ await fs.rename(temporary, file);
22
+ await fs.chmod(file, mode);
23
+ }
24
+
25
+ export async function loadConfig() {
26
+ const file = path.join(configDirectory(), 'config.json');
27
+ const saved = await readJson(file, {});
28
+ return {
29
+ apiUrl: (process.env.CONSTRAINT_API_URL || saved.apiUrl || DEFAULT_API_URL).replace(/\/$/, ''),
30
+ agents: Array.isArray(saved.agents) ? saved.agents : Array.isArray(saved.clients) ? saved.clients : [],
31
+ };
32
+ }
33
+
34
+ export async function saveConfig(config) {
35
+ await writeJson(path.join(configDirectory(), 'config.json'), config);
36
+ }
37
+
38
+ export async function loadInstallations() {
39
+ const value = await readJson(path.join(configDirectory(), 'installations.json'), { lockfile_version: 1, installations: {} });
40
+ if (value.installations) return value;
41
+ return { lockfile_version: 1, installations: value };
42
+ }
43
+
44
+ export async function saveInstallations(value) {
45
+ const normalized = value.installations ? value : { lockfile_version: 1, installations: value };
46
+ await writeJson(path.join(configDirectory(), 'installations.json'), normalized);
47
+ }
48
+
49
+ export async function loadProjectInstallations(projectRoot) {
50
+ return readJson(path.join(projectRoot, 'skills-lock.json'), { lockfile_version: 1, installations: {} });
51
+ }
52
+
53
+ export async function saveProjectInstallations(projectRoot, value) {
54
+ await writeJson(path.join(projectRoot, 'skills-lock.json'), value, { mode: 0o644 });
55
+ }
56
+
57
+ export async function fetchCliConfig(apiUrl) {
58
+ const response = await fetch(`${apiUrl}/cli/config`, { headers: { Accept: 'application/json' } });
59
+ if (!response.ok) throw new Error(`Could not load CLI configuration from ${apiUrl} (${response.status})`);
60
+ return response.json();
61
+ }
package/src/files.js ADDED
@@ -0,0 +1,146 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ const MIME = {
6
+ '.md': 'text/markdown', '.json': 'application/json', '.jsonl': 'application/x-ndjson',
7
+ '.yaml': 'application/yaml', '.yml': 'application/yaml', '.js': 'text/javascript',
8
+ '.mjs': 'text/javascript', '.ts': 'text/typescript', '.py': 'text/x-python',
9
+ '.txt': 'text/plain', '.svg': 'image/svg+xml', '.png': 'image/png',
10
+ '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.pdf': 'application/pdf',
11
+ };
12
+
13
+ export function mediaType(file) {
14
+ return MIME[path.extname(file).toLowerCase()] || 'application/octet-stream';
15
+ }
16
+
17
+ async function visit(root, current, files) {
18
+ const entries = await fs.readdir(current, { withFileTypes: true });
19
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
20
+ const absolute = path.join(current, entry.name);
21
+ if (entry.isSymbolicLink()) throw new Error(`Skill packages may not contain symlinks: ${absolute}`);
22
+ if (entry.isDirectory()) await visit(root, absolute, files);
23
+ else if (entry.isFile()) {
24
+ files.push({
25
+ path: path.relative(root, absolute).split(path.sep).join('/'),
26
+ media_type: mediaType(absolute),
27
+ content: await fs.readFile(absolute),
28
+ });
29
+ }
30
+ }
31
+ }
32
+
33
+ export async function readSkillDirectory(input) {
34
+ let root = path.resolve(input);
35
+ const stat = await fs.stat(root);
36
+ if (stat.isFile() && path.basename(root) === 'SKILL.md') root = path.dirname(root);
37
+ else if (!stat.isDirectory()) throw new Error('Skill upload path must be a directory or SKILL.md');
38
+ const files = [];
39
+ await visit(root, root, files);
40
+ if (!files.some((file) => file.path === 'SKILL.md')) throw new Error('Skill directory requires a root SKILL.md');
41
+ return files;
42
+ }
43
+
44
+ export function packageDigest(files) {
45
+ const hash = crypto.createHash('sha256');
46
+ for (const file of [...files].sort((a, b) => a.path.localeCompare(b.path))) {
47
+ hash.update(file.path);
48
+ hash.update('\0');
49
+ hash.update(file.content);
50
+ hash.update('\0');
51
+ }
52
+ return hash.digest('hex');
53
+ }
54
+
55
+ export async function writeSkillDirectory(target, files, { expectedHash = null, forceDirectory = false } = {}) {
56
+ try {
57
+ const targetStat = await fs.lstat(target);
58
+ const currentHash = packageDigest(await readSkillDirectory(target));
59
+ if (currentHash === packageDigest(files) && !(forceDirectory && targetStat.isSymbolicLink())) {
60
+ return { changed: false, hash: currentHash };
61
+ }
62
+ if (!expectedHash || currentHash !== expectedHash) {
63
+ throw new Error(`Local skill has uncommitted changes and was not replaced: ${target}`);
64
+ }
65
+ } catch (error) {
66
+ if (error && error.code !== 'ENOENT') throw error;
67
+ }
68
+
69
+ await fs.mkdir(path.dirname(target), { recursive: true });
70
+ const staging = `${target}.constraint-${process.pid}`;
71
+ const backup = `${target}.constraint-backup-${process.pid}`;
72
+ await fs.rm(staging, { recursive: true, force: true });
73
+ await fs.mkdir(staging, { recursive: true });
74
+ for (const file of files) {
75
+ const destination = path.join(staging, ...file.path.split('/'));
76
+ await fs.mkdir(path.dirname(destination), { recursive: true });
77
+ await fs.writeFile(destination, file.content);
78
+ }
79
+ let hadTarget = false;
80
+ try {
81
+ await fs.rename(target, backup);
82
+ hadTarget = true;
83
+ } catch (error) {
84
+ if (error && error.code !== 'ENOENT') throw error;
85
+ }
86
+ try {
87
+ await fs.rename(staging, target);
88
+ await fs.rm(backup, { recursive: true, force: true });
89
+ } catch (error) {
90
+ if (hadTarget) await fs.rename(backup, target).catch(() => {});
91
+ throw error;
92
+ }
93
+ return { changed: true, hash: packageDigest(files) };
94
+ }
95
+
96
+ export async function assertManagedSkillUnchanged(target, expectedHash) {
97
+ if (!expectedHash) return;
98
+ let current;
99
+ try {
100
+ current = packageDigest(await readSkillDirectory(target));
101
+ } catch (error) {
102
+ if (error && error.code === 'ENOENT') throw new Error(`Managed skill is missing and was not replaced: ${target}`);
103
+ throw error;
104
+ }
105
+ if (current !== expectedHash) throw new Error(`Local skill has uncommitted changes and was not replaced: ${target}`);
106
+ }
107
+
108
+ export async function linkSkillDirectory(target, source, { managed = false, expectedHash = null } = {}) {
109
+ let existing = null;
110
+ try {
111
+ existing = await fs.lstat(target);
112
+ } catch (error) {
113
+ if (!error || error.code !== 'ENOENT') throw error;
114
+ }
115
+ if (existing) {
116
+ if (!managed) throw new Error(`An unmanaged skill already exists and was not replaced: ${target}`);
117
+ await assertManagedSkillUnchanged(target, expectedHash);
118
+ }
119
+
120
+ await fs.mkdir(path.dirname(target), { recursive: true });
121
+ const staging = `${target}.constraint-link-${process.pid}`;
122
+ const backup = `${target}.constraint-backup-${process.pid}`;
123
+ await fs.rm(staging, { recursive: true, force: true });
124
+ const relative = path.relative(path.dirname(staging), source);
125
+ try {
126
+ await fs.symlink(relative, staging, process.platform === 'win32' ? 'junction' : 'dir');
127
+ } catch (error) {
128
+ if (['EPERM', 'EACCES', 'ENOTSUP', 'EINVAL'].includes(error?.code)) return { supported: false, changed: false };
129
+ throw error;
130
+ }
131
+ let hadTarget = false;
132
+ try {
133
+ await fs.rename(target, backup);
134
+ hadTarget = true;
135
+ } catch (error) {
136
+ if (!error || error.code !== 'ENOENT') throw error;
137
+ }
138
+ try {
139
+ await fs.rename(staging, target);
140
+ await fs.rm(backup, { recursive: true, force: true });
141
+ } catch (error) {
142
+ if (hadTarget) await fs.rename(backup, target).catch(() => {});
143
+ throw error;
144
+ }
145
+ return { supported: true, changed: true };
146
+ }
package/src/paths.js ADDED
@@ -0,0 +1,83 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+
6
+ export const AGENTS = ['codex', 'claude-code'];
7
+
8
+ export function expandPath(value) {
9
+ if (value === '~') return os.homedir();
10
+ if (value.startsWith('~/')) return path.join(os.homedir(), value.slice(2));
11
+ return path.resolve(value);
12
+ }
13
+
14
+ export function configDirectory() {
15
+ if (process.env.CONSTRAINT_CONFIG_DIR) return expandPath(process.env.CONSTRAINT_CONFIG_DIR);
16
+ if (process.platform === 'win32' && process.env.APPDATA) return path.join(process.env.APPDATA, 'Constraint');
17
+ return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'constraint');
18
+ }
19
+
20
+ export function normalizeAgent(agent) {
21
+ const value = String(agent || '').trim().toLowerCase().replaceAll('_', '-');
22
+ if (value === 'claude') return 'claude-code';
23
+ if (AGENTS.includes(value)) return value;
24
+ throw new Error(`Unsupported agent: ${agent}. Expected codex or claude-code.`);
25
+ }
26
+
27
+ export function agentPaths(agent, { scope = 'global', projectRoot = process.cwd() } = {}) {
28
+ const selected = normalizeAgent(agent);
29
+ if (scope === 'project') {
30
+ if (selected === 'codex') {
31
+ return { skills: path.join(projectRoot, '.agents', 'skills'), instructions: null };
32
+ }
33
+ return { skills: path.join(projectRoot, '.claude', 'skills'), instructions: null };
34
+ }
35
+ if (selected === 'codex') {
36
+ const root = process.env.CODEX_HOME ? expandPath(process.env.CODEX_HOME) : path.join(os.homedir(), '.codex');
37
+ return { instructions: path.join(root, 'AGENTS.md'), skills: path.join(root, 'skills') };
38
+ }
39
+ return {
40
+ instructions: path.join(os.homedir(), '.claude', 'CLAUDE.md'),
41
+ skills: path.join(os.homedir(), '.claude', 'skills'),
42
+ };
43
+ }
44
+
45
+ async function exists(candidate) {
46
+ try {
47
+ await fs.stat(candidate);
48
+ return true;
49
+ } catch (error) {
50
+ if (error && error.code === 'ENOENT') return false;
51
+ throw error;
52
+ }
53
+ }
54
+
55
+ export async function findProjectRoot(start = process.cwd()) {
56
+ let current = path.resolve(start);
57
+ while (true) {
58
+ if (await exists(path.join(current, '.git'))) return current;
59
+ const parent = path.dirname(current);
60
+ if (parent === current) return path.resolve(start);
61
+ current = parent;
62
+ }
63
+ }
64
+
65
+ export async function detectAgents() {
66
+ const detected = [];
67
+ const codexRoot = process.env.CODEX_HOME ? expandPath(process.env.CODEX_HOME) : path.join(os.homedir(), '.codex');
68
+ if (await exists(codexRoot)) detected.push('codex');
69
+ if (await exists(path.join(os.homedir(), '.claude'))) detected.push('claude-code');
70
+ return detected;
71
+ }
72
+
73
+ export function workspaceId(projectRoot) {
74
+ const digest = crypto.createHash('sha256').update(path.resolve(projectRoot)).digest('hex').slice(0, 24);
75
+ return `ws_${digest}`;
76
+ }
77
+
78
+ export function canonicalSkillPath({ scope, projectRoot, skillId, slug, version }) {
79
+ if (scope === 'project') {
80
+ return path.join(projectRoot, '.constraint', 'skills', slug, `v${version}`);
81
+ }
82
+ return path.join(configDirectory(), 'skill-cache', skillId, `v${version}`);
83
+ }