@constraint/cli 0.4.1 → 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.1",
3
+ "version": "0.4.2",
4
4
  "description": "Install, publish, and report Agent Skills with Constraint",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  } from './setup.js';
27
27
  import {
28
28
  configuredAgents,
29
+ identifyLocalSkill,
29
30
  installSkill,
30
31
  installedSkills,
31
32
  manageableDomainRecords,
@@ -35,7 +36,7 @@ import {
35
36
  uploadSkill,
36
37
  } from './skills.js';
37
38
 
38
- const VERSION = '0.4.1';
39
+ const VERSION = '0.4.2';
39
40
 
40
41
  const HELP = `Constraint Skills CLI
41
42
 
@@ -481,7 +482,31 @@ async function reportCommand(args, config, out) {
481
482
  if (!resolvedClient || !resolvedSession) {
482
483
  throw new Error('Could not tell the client and session from this path. Pass --client and --session.');
483
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
+
484
508
  const query = new URLSearchParams({ skill, client: resolvedClient, session: resolvedSession });
509
+ if (identified.verified) query.set('version', String(identified.version));
485
510
  const run = await apiRequest(config, `/skill-reports?${query.toString()}`, {
486
511
  method: 'POST',
487
512
  headers: { 'Content-Type': mediaType(transcript.absolute), 'X-Transcript-Filename': transcript.filename },
@@ -491,6 +516,10 @@ async function reportCommand(args, config, out) {
491
516
  run_id: run.run_id,
492
517
  skill: run.skill_slug,
493
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
+ : {}),
494
523
  state: run.state,
495
524
  client: run.client,
496
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/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) };