@constraint/cli 0.4.0 → 0.4.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@constraint/cli",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Install, publish, and report Agent Skills with Constraint",
5
5
  "type": "module",
6
6
  "bin": {
@@ -57,10 +57,10 @@ constraint report <transcript-path> --skill <skill-name>
57
57
  ```
58
58
 
59
59
  Pass the explicit local path to the transcript as it exists at that moment.
60
- Use the invoked skill's frontmatter `name`. The server detects the client and
61
- session id from the transcript itself; re-reporting the same session later
62
- supersedes the earlier report. Never create a replacement transcript,
63
- summarize it, or pass a temporary file. If the transcript path cannot be
60
+ Use the invoked skill's frontmatter `name`. Client and session id are read
61
+ from the transcript path; re-reporting the same session later supersedes the
62
+ earlier report. Never create a replacement transcript, summarize it, copy it
63
+ elsewhere first, or pass a temporary file. If the transcript path cannot be
64
64
  determined, say so instead of guessing.
65
65
 
66
66
  ## Work safely
@@ -12,7 +12,7 @@ constraint setup
12
12
  constraint doctor
13
13
  ```
14
14
 
15
- `login` uses WorkOS device authorization. Never request or embed an API key or client secret. `setup` detects Codex and Claude Code, installs this skill globally, and removes the legacy managed block that earlier releases injected into global instruction files. It never adds to or replaces user instructions.
15
+ `login` uses WorkOS device authorization. Never request or embed an API key or client secret. `setup` detects Codex and Claude Code and installs this skill globally. It never touches instruction files.
16
16
 
17
17
  ## Discover, inspect, install, and update
18
18
 
@@ -53,8 +53,9 @@ constraint skills run get <run-id> --raw
53
53
  ```
54
54
 
55
55
  `report` is user-driven: run it only when the user asks to put a session on the
56
- record. The server detects client and session id from the transcript;
57
- `--client`/`--session` are overrides for transcripts it cannot parse.
56
+ record. Client and session id are read from the transcript path itself (Claude
57
+ Code project transcripts and Codex rollouts are named by session id);
58
+ `--client`/`--session` are only needed for files outside those locations.
58
59
  Re-reporting the same session supersedes the earlier report.
59
60
 
60
61
  Repeat `--team`, `--user`, or `--skill` to request subsets. Review visibility is permission-controlled.
package/src/cli.js CHANGED
@@ -20,13 +20,13 @@ import {
20
20
  taskSpinner,
21
21
  } from './prompts.js';
22
22
  import {
23
- applyInstructionSetup,
24
23
  installCompanionSkill,
25
24
  setupPlan,
26
25
  setupStatus,
27
26
  } from './setup.js';
28
27
  import {
29
28
  configuredAgents,
29
+ identifyLocalSkill,
30
30
  installSkill,
31
31
  installedSkills,
32
32
  manageableDomainRecords,
@@ -36,7 +36,7 @@ import {
36
36
  uploadSkill,
37
37
  } from './skills.js';
38
38
 
39
- const VERSION = '0.4.0';
39
+ const VERSION = '0.4.2';
40
40
 
41
41
  const HELP = `Constraint Skills CLI
42
42
 
@@ -249,30 +249,17 @@ async function setupCommand(args, out) {
249
249
  if (dryRun) {
250
250
  out.data(plans.map((plan) => ({
251
251
  agent: plan.agent,
252
- instructions: {
253
- path: plan.instructions.path,
254
- action: plan.instructions.changed ? 'update' : 'current',
255
- content: plan.instructions.content,
256
- },
257
252
  skill: { path: plan.skill.path, scope: 'global', action: plan.skill.action },
258
253
  })));
259
254
  return;
260
255
  }
261
- const hasInstructionChanges = plans.some((plan) => plan.instructions.changed);
262
256
  const hasSkillChanges = plans.some((plan) => plan.skill.action !== 'current');
263
- if (!useUi && !yes && (hasInstructionChanges || hasSkillChanges)) {
257
+ if (!useUi && !yes && hasSkillChanges) {
264
258
  throw new Error('Non-interactive setup has pending changes. Pass --yes or use --dry-run.');
265
259
  }
266
260
 
267
- let configureInstructions = true;
268
261
  let installSkill = true;
269
262
  if (useUi) {
270
- note(
271
- plans.map((plan) => `${humanize(plan.agent)}\n${plan.instructions.path}\n${plan.instructions.changed ? 'Legacy Constraint block will be removed' : 'Nothing to clean up'}`).join('\n\n'),
272
- '1. Remove legacy instruction blocks',
273
- );
274
- configureInstructions = !hasInstructionChanges || yes || await confirmPlan('Remove the legacy Constraint block from these files?');
275
-
276
263
  note(
277
264
  [
278
265
  'Skill: constraint-skills',
@@ -284,20 +271,12 @@ async function setupCommand(args, out) {
284
271
  '',
285
272
  ]),
286
273
  ].join('\n').trim(),
287
- '2. Install Constraint Skills',
274
+ 'Install Constraint Skills',
288
275
  );
289
276
  installSkill = !hasSkillChanges || yes || await confirmPlan(`Install globally for ${agents.map(humanize).join(' and ')}?`);
290
277
  }
291
278
 
292
- const result = { instructions: [], skill: [] };
293
- if (configureInstructions) {
294
- const spinner = taskSpinner(useUi && hasInstructionChanges);
295
- for (const plan of plans) {
296
- spinner.message(`Cleaning ${humanize(plan.agent)} instructions`);
297
- result.instructions.push(await applyInstructionSetup(plan.agent));
298
- }
299
- spinner.stop('Agent instructions cleaned.');
300
- }
279
+ const result = { skill: [] };
301
280
  if (installSkill) {
302
281
  const spinner = taskSpinner(useUi && hasSkillChanges);
303
282
  for (const plan of plans) {
@@ -309,7 +288,6 @@ async function setupCommand(args, out) {
309
288
  if (useUi) {
310
289
  finish([
311
290
  'Setup complete.',
312
- `Instructions: ${configureInstructions ? `${agents.length} configured` : 'skipped'}`,
313
291
  `Skill: ${installSkill ? `installed for ${agents.map(humanize).join(' and ')}` : 'skipped'}`,
314
292
  ].join('\n'));
315
293
  } else out.data(result);
@@ -472,8 +450,23 @@ async function uploadCommand(args, config, out) {
472
450
 
473
451
  // A run is the posting of a transcript in which a skill was invoked. The user
474
452
  // drives reporting: one command, after the session, with the finished
475
- // transcript. Client and session id are detected server-side from the
476
- // transcript; flags override when detection cannot.
453
+ // transcript. Client and session id come straight from the transcript path —
454
+ // agent clients keep sessions at well-known locations named by session id.
455
+ export function transcriptIdentity(absolute) {
456
+ const basename = path.basename(absolute);
457
+ const stem = basename.replace(/\.(jsonl?|json)$/i, '');
458
+ const uuid = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
459
+ if (absolute.includes(`${path.sep}.codex${path.sep}sessions${path.sep}`) || stem.startsWith('rollout-')) {
460
+ const match = stem.match(uuid);
461
+ return { client: 'codex', session: match ? match[0] : stem };
462
+ }
463
+ if (absolute.includes(`${path.sep}.claude${path.sep}projects${path.sep}`) || uuid.test(stem)) {
464
+ const match = stem.match(uuid);
465
+ return { client: 'claude-code', session: match ? match[0] : stem };
466
+ }
467
+ return { client: null, session: null };
468
+ }
469
+
477
470
  async function reportCommand(args, config, out) {
478
471
  const skill = takeOption(args, '--skill');
479
472
  const client = takeOption(args, '--client');
@@ -483,9 +476,37 @@ async function reportCommand(args, config, out) {
483
476
  throw new Error('Usage: constraint report <transcript-path> --skill <skill> [--client <client>] [--session <session-id>]');
484
477
  }
485
478
  const transcript = await readTranscript(args[0]);
486
- const query = new URLSearchParams({ skill });
487
- if (client) query.set('client', client);
488
- if (session) query.set('session', session);
479
+ const identity = transcriptIdentity(transcript.absolute);
480
+ const resolvedClient = client || identity.client;
481
+ const resolvedSession = session || identity.session;
482
+ if (!resolvedClient || !resolvedSession) {
483
+ throw new Error('Could not tell the client and session from this path. Pass --client and --session.');
484
+ }
485
+
486
+ // Version attribution: hash the local skill folder and match it against the
487
+ // published versions, so the run records what actually ran — not "latest".
488
+ let detail;
489
+ try {
490
+ detail = await apiRequest(config, `/skills/${encodeURIComponent(skill)}`);
491
+ } catch (error) {
492
+ if (/not found/i.test(error.message)) {
493
+ const projectRoot = await findProjectRoot();
494
+ const hint = await identifyLocalSkill(config, { skill_id: skill, slug: skill, latest_version: 0 }, { projectRoot })
495
+ .then((found) => found.localPath)
496
+ .catch(() => null);
497
+ throw new Error(
498
+ `${skill} is not on Constraint.` +
499
+ (hint ? ` Found a local skill at ${hint} — upload it first:
500
+ constraint skills upload ${hint} --domain <domain>` : ' Upload it first with constraint skills upload.'),
501
+ );
502
+ }
503
+ throw error;
504
+ }
505
+ const projectRoot = await findProjectRoot();
506
+ const identified = await identifyLocalSkill(config, detail, { projectRoot });
507
+
508
+ const query = new URLSearchParams({ skill, client: resolvedClient, session: resolvedSession });
509
+ if (identified.verified) query.set('version', String(identified.version));
489
510
  const run = await apiRequest(config, `/skill-reports?${query.toString()}`, {
490
511
  method: 'POST',
491
512
  headers: { 'Content-Type': mediaType(transcript.absolute), 'X-Transcript-Filename': transcript.filename },
@@ -495,6 +516,10 @@ async function reportCommand(args, config, out) {
495
516
  run_id: run.run_id,
496
517
  skill: run.skill_slug,
497
518
  version: run.skill_version,
519
+ version_verified: identified.verified,
520
+ ...(identified.localPath && !identified.verified
521
+ ? { warning: `local copy at ${identified.localPath} does not match any published version` }
522
+ : {}),
498
523
  state: run.state,
499
524
  client: run.client,
500
525
  session: run.client_session_id,
package/src/files.js CHANGED
@@ -43,7 +43,9 @@ export async function readSkillDirectory(input) {
43
43
 
44
44
  export function packageDigest(files) {
45
45
  const hash = crypto.createHash('sha256');
46
- for (const file of [...files].sort((a, b) => a.path.localeCompare(b.path))) {
46
+ // Sort by code points, not locale: this digest must match the server's,
47
+ // which sorts paths with plain string comparison.
48
+ for (const file of [...files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))) {
47
49
  hash.update(file.path);
48
50
  hash.update('\0');
49
51
  hash.update(file.content);
package/src/paths.js CHANGED
@@ -28,18 +28,15 @@ export function agentPaths(agent, { scope = 'global', projectRoot = process.cwd(
28
28
  const selected = normalizeAgent(agent);
29
29
  if (scope === 'project') {
30
30
  if (selected === 'codex') {
31
- return { skills: path.join(projectRoot, '.agents', 'skills'), instructions: null };
31
+ return { skills: path.join(projectRoot, '.agents', 'skills') };
32
32
  }
33
- return { skills: path.join(projectRoot, '.claude', 'skills'), instructions: null };
33
+ return { skills: path.join(projectRoot, '.claude', 'skills') };
34
34
  }
35
35
  if (selected === 'codex') {
36
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') };
37
+ return { skills: path.join(root, 'skills') };
38
38
  }
39
- return {
40
- instructions: path.join(os.homedir(), '.claude', 'CLAUDE.md'),
41
- skills: path.join(os.homedir(), '.claude', 'skills'),
42
- };
39
+ return { skills: path.join(os.homedir(), '.claude', 'skills') };
43
40
  }
44
41
 
45
42
  async function exists(candidate) {
package/src/setup.js CHANGED
@@ -6,32 +6,6 @@ import { loadConfig, saveConfig } from './config.js';
6
6
  import { packageDigest, readSkillDirectory, writeSkillDirectory } from './files.js';
7
7
  import { agentPaths, normalizeAgent } from './paths.js';
8
8
 
9
- export const BLOCK_START = '<!-- constraint-skills:start -->';
10
- export const BLOCK_END = '<!-- constraint-skills:end -->';
11
-
12
- // Earlier releases injected a managed instruction block into the user's global
13
- // CLAUDE.md/AGENTS.md. Writing into user-owned instruction files was never
14
- // trustworthy behavior; setup now only removes that legacy block if present.
15
- export function stripInstructionBlock(existing) {
16
- const start = existing.indexOf(BLOCK_START);
17
- const end = existing.indexOf(BLOCK_END);
18
- if (start < 0 && end < 0) return existing;
19
- if ((start >= 0) !== (end >= 0)) {
20
- throw new Error('The existing Constraint Skills instruction block is incomplete; fix it before running setup.');
21
- }
22
- const before = existing.slice(0, start).replace(/\n+$/, '\n');
23
- const after = existing.slice(end + BLOCK_END.length).replace(/^\n+/, '\n');
24
- const stripped = `${before}${after}`;
25
- return stripped.trim() === '' ? '' : stripped.replace(/\n{3,}/g, '\n\n');
26
- }
27
-
28
- async function writeInstructions(file, content, mode) {
29
- await fs.mkdir(path.dirname(file), { recursive: true });
30
- const staging = `${file}.constraint-${process.pid}`;
31
- await fs.writeFile(staging, content, { mode });
32
- await fs.rename(staging, file);
33
- }
34
-
35
9
  async function companionFiles() {
36
10
  const here = path.dirname(fileURLToPath(import.meta.url));
37
11
  return readSkillDirectory(path.resolve(here, '..', 'skills', 'constraint-skills'));
@@ -44,19 +18,6 @@ export function expectedCompanionHash(config, selected, currentHash) {
44
18
  return currentHash && recorded.includes(currentHash) ? currentHash : null;
45
19
  }
46
20
 
47
- async function instructionPlan(selected, paths) {
48
- let existing = '';
49
- let mode = 0o644;
50
- try {
51
- existing = await fs.readFile(paths.instructions, 'utf8');
52
- mode = (await fs.stat(paths.instructions)).mode & 0o777;
53
- } catch (error) {
54
- if (error && error.code !== 'ENOENT') throw error;
55
- }
56
- const content = stripInstructionBlock(existing);
57
- return { path: paths.instructions, changed: content !== existing, content, mode };
58
- }
59
-
60
21
  async function skillPlan(selected, paths) {
61
22
  const files = await companionFiles();
62
23
  const bundledHash = packageDigest(files);
@@ -84,7 +45,6 @@ export async function setupPlan(agent) {
84
45
  const paths = agentPaths(selected);
85
46
  return {
86
47
  agent: selected,
87
- instructions: await instructionPlan(selected, paths),
88
48
  skill: await skillPlan(selected, paths),
89
49
  };
90
50
  }
@@ -95,15 +55,6 @@ async function recordConfiguredAgent(selected, config = null) {
95
55
  await saveConfig(next);
96
56
  }
97
57
 
98
- export async function applyInstructionSetup(agent) {
99
- const selected = normalizeAgent(agent);
100
- const paths = agentPaths(selected);
101
- const plan = await instructionPlan(selected, paths);
102
- if (plan.changed) await writeInstructions(plan.path, plan.content, plan.mode);
103
- await recordConfiguredAgent(selected);
104
- return { agent: selected, path: plan.path, changed: plan.changed, applied: true };
105
- }
106
-
107
58
  export async function installCompanionSkill(agent) {
108
59
  const selected = normalizeAgent(agent);
109
60
  const paths = agentPaths(selected);
@@ -122,42 +73,25 @@ export async function installCompanionSkill(agent) {
122
73
 
123
74
  export async function setupAgent(agent, {
124
75
  dryRun = false,
125
- yes = false,
126
- confirmChange = async () => false,
127
76
  output = console.log,
128
- instructions = true,
129
- skill = true,
130
77
  } = {}) {
131
78
  const selected = normalizeAgent(agent);
132
79
  const plan = await setupPlan(selected);
133
- output(`${selected}: ${plan.instructions.path}`);
134
80
  if (dryRun) {
135
- if (instructions) output(plan.instructions.content);
136
- if (skill) output(`Install constraint-skills globally at ${plan.skill.path} (${plan.skill.action}).`);
137
- return { agent: selected, instructions: plan.instructions, skill: plan.skill, applied: false };
138
- }
139
- let instructionResult = null;
140
- let skillResult = null;
141
- if (instructions) {
142
- const approved = !plan.instructions.changed || yes || await confirmChange(
143
- `Remove the legacy Constraint block from ${plan.instructions.path}?`,
144
- );
145
- if (approved) instructionResult = await applyInstructionSetup(selected);
81
+ output(`Install constraint-skills globally at ${plan.skill.path} (${plan.skill.action}).`);
82
+ return { agent: selected, skill: plan.skill, applied: false };
146
83
  }
147
- if (skill) skillResult = await installCompanionSkill(selected);
148
- return { agent: selected, instructions: instructionResult, skill: skillResult, applied: true };
84
+ const skillResult = await installCompanionSkill(selected);
85
+ return { agent: selected, skill: skillResult, applied: true };
149
86
  }
150
87
 
151
88
  export async function setupStatus(agent) {
152
89
  const selected = normalizeAgent(agent);
153
90
  const paths = agentPaths(selected);
154
91
  try {
155
- const [instructions, skill] = await Promise.all([
156
- fs.readFile(paths.instructions, 'utf8'),
157
- fs.stat(path.join(paths.skills, 'constraint-skills', 'SKILL.md')),
158
- ]);
159
- return { agent: selected, legacy_instruction_block: instructions.includes(BLOCK_START), skill: skill.isFile() };
92
+ const skill = await fs.stat(path.join(paths.skills, 'constraint-skills', 'SKILL.md'));
93
+ return { agent: selected, skill: skill.isFile() };
160
94
  } catch {
161
- return { agent: selected, legacy_instruction_block: false, skill: false };
95
+ return { agent: selected, skill: false };
162
96
  }
163
97
  }
package/src/skills.js CHANGED
@@ -231,6 +231,42 @@ export async function installedSkills({ scope = 'project', projectRoot, agents }
231
231
  return Object.values(lock.installations || {}).filter((item) => !selected || selected.has(item.agent));
232
232
  }
233
233
 
234
+ // Version attribution for reports: find the skill's local folder (managed
235
+ // symlink or plain folder — agents read both from the same directories), hash
236
+ // it with the registry's package digest, and match against published versions.
237
+ export async function identifyLocalSkill(config, detail, { projectRoot = null } = {}) {
238
+ const candidates = [];
239
+ if (projectRoot) {
240
+ for (const agent of ['claude-code', 'codex']) {
241
+ candidates.push(path.join(agentPaths(agent, { scope: 'project', projectRoot }).skills, detail.slug));
242
+ }
243
+ }
244
+ for (const agent of ['claude-code', 'codex']) {
245
+ candidates.push(path.join(agentPaths(agent).skills, detail.slug));
246
+ }
247
+ let local = null;
248
+ for (const candidate of candidates) {
249
+ try {
250
+ const digest = packageDigest(await readSkillDirectory(candidate));
251
+ local = { path: candidate, digest };
252
+ break;
253
+ } catch {
254
+ // not present here; keep looking
255
+ }
256
+ }
257
+ if (!local) return { localPath: null, version: null, verified: false };
258
+ for (let version = detail.latest_version; version >= 1; version -= 1) {
259
+ const candidate = await apiRequest(
260
+ config,
261
+ `/skills/${encodeURIComponent(detail.skill_id)}/versions/${version}`,
262
+ );
263
+ if (candidate.version.content_sha256 === local.digest) {
264
+ return { localPath: local.path, version, verified: true };
265
+ }
266
+ }
267
+ return { localPath: local.path, version: null, verified: false };
268
+ }
269
+
234
270
  export async function readTranscript(file) {
235
271
  const absolute = path.resolve(file);
236
272
  return { absolute, filename: path.basename(absolute), content: await fs.readFile(absolute) };