agent-first-data 0.12.2 → 0.13.1

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/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Agent-First Data (AFDATA) — suffix-driven output formatting and protocol templates.
3
3
  */
4
- export { buildJsonOk, buildJsonError, buildJson, type JsonValue, RedactionPolicy, type RedactionOptions, OutputStyle, type OutputOptions, outputJson, outputJsonWith, outputJsonWithOptions, outputYaml, outputYamlWithOptions, outputPlain, outputPlainWithOptions, internalRedactSecrets, internalRedactSecretsWithOptions, redactedValue, redactedValueWith, redactedValueWithOptions, redactUrlSecrets, redactUrlSecretsWithOptions, parseSize, } from "./format.js";
4
+ export { buildJsonOk, buildJsonError, buildJson, type JsonValue, RedactionPolicy, type RedactionOptions, OutputStyle, type OutputOptions, outputJson, outputJsonWith, outputJsonWithOptions, outputYaml, outputYamlWithOptions, outputPlain, outputPlainWithOptions, redactSecretsInPlace, redactSecretsInPlaceWithOptions, redactedValue, redactedValueWith, redactedValueWithOptions, redactUrlSecrets, redactUrlSecretsWithOptions, parseSize, normalizeUtcOffset, } from "./format.js";
5
5
  export { log, span, initJson, initPlain, initYaml } from "./afdata_logging.js";
6
- export { type OutputFormat, cliParseOutput, cliParseLogFilters, cliOutput, cliOutputWithOptions, buildCliError, } from "./cli.js";
6
+ export { type OutputFormat, cliParseOutput, cliParseLogFilters, cliOutput, cliOutputWithOptions, buildCliError, buildCliVersion, cliRenderVersion, cliHandleVersionOrContinue, } from "./cli.js";
7
7
  export { type SkillSpec, type SkillAction, type SkillScope, type SkillAgentSelection, type SkillAgent, type SkillOptions, type SkillTargetStatus, type SkillUninstallStatus, type SkillReport, SkillError, runSkillAdmin, } from "./skill.js";
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Agent-First Data (AFDATA) — suffix-driven output formatting and protocol templates.
3
3
  */
4
- export { buildJsonOk, buildJsonError, buildJson, RedactionPolicy, OutputStyle, outputJson, outputJsonWith, outputJsonWithOptions, outputYaml, outputYamlWithOptions, outputPlain, outputPlainWithOptions, internalRedactSecrets, internalRedactSecretsWithOptions, redactedValue, redactedValueWith, redactedValueWithOptions, redactUrlSecrets, redactUrlSecretsWithOptions, parseSize, } from "./format.js";
4
+ export { buildJsonOk, buildJsonError, buildJson, RedactionPolicy, OutputStyle, outputJson, outputJsonWith, outputJsonWithOptions, outputYaml, outputYamlWithOptions, outputPlain, outputPlainWithOptions, redactSecretsInPlace, redactSecretsInPlaceWithOptions, redactedValue, redactedValueWith, redactedValueWithOptions, redactUrlSecrets, redactUrlSecretsWithOptions, parseSize, normalizeUtcOffset, } from "./format.js";
5
5
  export { log, span, initJson, initPlain, initYaml } from "./afdata_logging.js";
6
- export { cliParseOutput, cliParseLogFilters, cliOutput, cliOutputWithOptions, buildCliError, } from "./cli.js";
6
+ export { cliParseOutput, cliParseLogFilters, cliOutput, cliOutputWithOptions, buildCliError, buildCliVersion, cliRenderVersion, cliHandleVersionOrContinue, } from "./cli.js";
7
7
  export { SkillError, runSkillAdmin, } from "./skill.js";
package/dist/skill.js CHANGED
@@ -10,8 +10,11 @@
10
10
  * stdout/stderr itself.
11
11
  */
12
12
  import * as fs from "node:fs";
13
- import { join } from "node:path";
13
+ import { basename, join } from "node:path";
14
14
  const SKILL_FILE_NAME = "SKILL.md";
15
+ const FNV1A64_OFFSET = 0xcbf29ce484222325n;
16
+ const FNV1A64_PRIME = 0x100000001b3n;
17
+ const FNV1A64_MASK = 0xffffffffffffffffn;
15
18
  /** A skill admin failure with an operator-facing message and optional hint. */
16
19
  export class SkillError extends Error {
17
20
  hint;
@@ -27,6 +30,7 @@ export class SkillError extends Error {
27
30
  * {@link SkillError}. Does not touch stdout/stderr.
28
31
  */
29
32
  export function runSkillAdmin(spec, action, options) {
33
+ validateSpec(spec);
30
34
  switch (action) {
31
35
  case "status":
32
36
  return status(spec, options);
@@ -57,10 +61,18 @@ function install(spec, options) {
57
61
  const installed = [];
58
62
  for (const target of targets) {
59
63
  fs.mkdirSync(target.skillDir, { recursive: true });
60
- if (fs.existsSync(target.skillPath) && !isManagedOrBundledSkill(spec, target.skillPath) && !options.force) {
61
- throw new SkillError(`refusing to overwrite unmanaged skill at ${target.skillPath}`, "pass --force to replace it, or choose another --skills-dir");
64
+ const st = lstatOrNull(target.skillPath);
65
+ if (st !== null) {
66
+ if (st.isSymbolicLink()) {
67
+ if (!options.force) {
68
+ throw new SkillError(`refusing to overwrite symlinked skill at ${target.skillPath}`, "pass --force to replace the symlink itself, or choose another --skills-dir");
69
+ }
70
+ }
71
+ else if (!isManagedOrBundledSkill(spec, target.skillPath) && !options.force) {
72
+ throw new SkillError(`refusing to overwrite unmanaged skill at ${target.skillPath}`, "pass --force to replace it, or choose another --skills-dir");
73
+ }
62
74
  }
63
- fs.writeFileSync(target.skillPath, content);
75
+ writeSkillAtomic(target, content);
64
76
  validateInstalledSkill(spec, target.skillPath);
65
77
  installed.push(targetStatus(spec, target));
66
78
  }
@@ -76,11 +88,17 @@ function uninstall(spec, options) {
76
88
  const targets = resolveTargets(spec, options);
77
89
  const removed = [];
78
90
  for (const target of targets) {
79
- if (!fs.existsSync(target.skillPath)) {
91
+ const st = lstatOrNull(target.skillPath);
92
+ if (st === null) {
80
93
  removed.push(targetUninstallStatus(target, false));
81
94
  continue;
82
95
  }
83
- if (!isManagedOrBundledSkill(spec, target.skillPath) && !options.force) {
96
+ if (st.isSymbolicLink()) {
97
+ if (!options.force) {
98
+ throw new SkillError(`refusing to remove symlinked skill at ${target.skillPath}`, "pass --force to remove the symlink itself");
99
+ }
100
+ }
101
+ else if (!isManagedOrBundledSkill(spec, target.skillPath) && !options.force) {
84
102
  throw new SkillError(`refusing to remove unmanaged skill at ${target.skillPath}`, `only skills generated by ${spec.markerSlug} skill install can be removed without --force`);
85
103
  }
86
104
  fs.rmSync(target.skillPath);
@@ -172,16 +190,20 @@ function defaultSkillsDir(agent, scope) {
172
190
  return join(homeDir(), ".config", "opencode", "skills");
173
191
  }
174
192
  function targetStatus(spec, target) {
175
- const installed = isFile(target.skillPath);
193
+ const st = lstatOrNull(target.skillPath);
194
+ const installed = st !== null;
176
195
  let valid = false;
177
196
  let current = false;
178
197
  let managed = false;
179
198
  let validationError = null;
180
- if (installed) {
199
+ if (st?.isSymbolicLink()) {
200
+ validationError = "target SKILL.md is a symlink; refusing to follow it";
201
+ }
202
+ else if (st?.isFile()) {
181
203
  const text = fs.readFileSync(target.skillPath, "utf-8");
182
204
  managed = skillTextIsManagedOrBundled(spec, text);
183
- current = normalizeSkillText(spec, text) === normalizeSkillText(spec, spec.source);
184
- const error = validateSkillFrontmatter(text);
205
+ current = !skillTextHasLegacyMarker(spec, text) && normalizeSkillText(spec, text) === normalizeSkillText(spec, spec.source);
206
+ const error = validateSkillTextError(spec, text);
185
207
  if (error === null) {
186
208
  valid = true;
187
209
  }
@@ -189,6 +211,9 @@ function targetStatus(spec, target) {
189
211
  validationError = error;
190
212
  }
191
213
  }
214
+ else if (installed) {
215
+ validationError = "target SKILL.md is not a regular file";
216
+ }
192
217
  return {
193
218
  agent: target.agent,
194
219
  scope: target.scope,
@@ -216,9 +241,30 @@ function generatedBy(spec) {
216
241
  function marker(spec) {
217
242
  return `${spec.markerSlug}-managed-skill: true`;
218
243
  }
244
+ function sourceHash(spec) {
245
+ let hash = FNV1A64_OFFSET;
246
+ for (const byte of Buffer.from(spec.source, "utf-8")) {
247
+ hash ^= BigInt(byte);
248
+ hash = (hash * FNV1A64_PRIME) & FNV1A64_MASK;
249
+ }
250
+ return hash.toString(16).padStart(16, "0");
251
+ }
252
+ function managedMarkerBlock(spec) {
253
+ const slug = spec.markerSlug;
254
+ return [
255
+ "<!--",
256
+ generatedBy(spec),
257
+ `${slug}-managed-skill: true`,
258
+ `${slug}-managed-skill-name: ${spec.name}`,
259
+ `${slug}-managed-skill-source-hash-fnv1a64: ${sourceHash(spec)}`,
260
+ "-->",
261
+ ].join("\n");
262
+ }
263
+ function legacyMarkerBlock(spec) {
264
+ return `<!-- ${generatedBy(spec)} -->\n<!-- ${marker(spec)} -->`;
265
+ }
219
266
  function managedSkillContents(spec) {
220
- const gen = generatedBy(spec);
221
- const mark = marker(spec);
267
+ const block = managedMarkerBlock(spec);
222
268
  const lines = splitLines(spec.source);
223
269
  const out = [];
224
270
  let inserted = false;
@@ -227,12 +273,12 @@ function managedSkillContents(spec) {
227
273
  if (i === 0)
228
274
  return;
229
275
  if (!inserted && line.trim() === "---") {
230
- out.push(`<!-- ${gen} -->`, `<!-- ${mark} -->`, "");
276
+ out.push(block, "");
231
277
  inserted = true;
232
278
  }
233
279
  });
234
280
  if (!inserted) {
235
- out.push(`<!-- ${gen} -->`, `<!-- ${mark} -->`);
281
+ out.push(block);
236
282
  }
237
283
  return out.map((line) => line + "\n").join("");
238
284
  }
@@ -240,21 +286,37 @@ function validateInstalledSkill(spec, path) {
240
286
  validateSkillText(spec, fs.readFileSync(path, "utf-8"));
241
287
  }
242
288
  function validateSkillText(spec, text) {
243
- const error = validateSkillFrontmatter(text);
289
+ const error = validateSkillTextError(spec, text);
244
290
  if (error !== null) {
245
- throw new SkillError(`invalid ${spec.title} skill front matter: ${error}`, "quote scalar values that contain ': ', especially description");
291
+ const hint = error.includes("does not match expected")
292
+ ? `set front matter name to ${spec.name}`
293
+ : "quote scalar values that contain ': ', especially description";
294
+ throw new SkillError(`invalid ${spec.title} skill front matter: ${error}`, hint);
295
+ }
296
+ }
297
+ function validateSkillTextError(spec, text) {
298
+ const parsed = parseSkillFrontmatter(text);
299
+ if (parsed.error !== null) {
300
+ return parsed.error;
246
301
  }
302
+ if (parsed.name !== spec.name) {
303
+ return `name ${JSON.stringify(parsed.name)} does not match expected ${JSON.stringify(spec.name)}`;
304
+ }
305
+ return null;
247
306
  }
248
307
  function validateSkillFrontmatter(text) {
308
+ return parseSkillFrontmatter(text).error;
309
+ }
310
+ function parseSkillFrontmatter(text) {
249
311
  const lines = splitLines(text);
250
312
  if (lines.length === 0) {
251
- return "missing YAML front matter";
313
+ return { error: "missing YAML front matter" };
252
314
  }
253
315
  if (lines[0].trim() !== "---") {
254
- return "missing opening --- YAML front matter delimiter";
316
+ return { error: "missing opening --- YAML front matter delimiter" };
255
317
  }
256
318
  let foundEnd = false;
257
- let hasName = false;
319
+ let name;
258
320
  let hasDescription = false;
259
321
  for (let idx = 1; idx < lines.length; idx++) {
260
322
  const line = lines[idx];
@@ -268,67 +330,84 @@ function validateSkillFrontmatter(text) {
268
330
  continue;
269
331
  }
270
332
  if (line.startsWith(" ") || line.startsWith("\t")) {
271
- return `line ${lineNo}: nested YAML is not supported here`;
333
+ return { error: `line ${lineNo}: nested YAML is not supported here` };
272
334
  }
273
335
  const colon = line.indexOf(":");
274
336
  if (colon === -1) {
275
- return `line ${lineNo}: expected key: value`;
337
+ return { error: `line ${lineNo}: expected key: value` };
276
338
  }
277
339
  const key = line.slice(0, colon).trim();
278
340
  if (key === "") {
279
- return `line ${lineNo}: empty key`;
341
+ return { error: `line ${lineNo}: empty key` };
280
342
  }
281
343
  const value = line.slice(colon + 1).replace(/^\s+/, "");
282
344
  if (key === "name")
283
- hasName = true;
345
+ name = parseFrontmatterScalar(value);
284
346
  if (key === "description")
285
347
  hasDescription = true;
286
348
  if (value.startsWith('"') || value.startsWith("'")) {
287
349
  continue;
288
350
  }
289
351
  if (value.includes(": ")) {
290
- return `line ${lineNo}: unquoted scalar contains ': '; quote the value`;
352
+ return { error: `line ${lineNo}: unquoted scalar contains ': '; quote the value` };
291
353
  }
292
354
  }
293
355
  if (!foundEnd) {
294
- return "missing closing --- YAML front matter delimiter";
356
+ return { error: "missing closing --- YAML front matter delimiter" };
295
357
  }
296
- if (!hasName) {
297
- return "missing required name field";
358
+ if (name === undefined) {
359
+ return { error: "missing required name field" };
298
360
  }
299
361
  if (!hasDescription) {
300
- return "missing required description field";
362
+ return { error: "missing required description field" };
301
363
  }
302
- return null;
364
+ return { name, error: null };
365
+ }
366
+ function parseFrontmatterScalar(value) {
367
+ value = value.trim();
368
+ if (value.length >= 2) {
369
+ const first = value[0];
370
+ const last = value[value.length - 1];
371
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
372
+ return value.slice(1, -1);
373
+ }
374
+ }
375
+ return value;
303
376
  }
304
377
  function isManagedOrBundledSkill(spec, path) {
305
- if (!fs.existsSync(path)) {
378
+ const st = lstatOrNull(path);
379
+ if (st === null) {
306
380
  return false;
307
381
  }
382
+ if (st.isSymbolicLink()) {
383
+ throw new SkillError(`refusing to inspect symlinked skill at ${path}`, "pass --force to replace or remove the symlink itself");
384
+ }
308
385
  return skillTextIsManagedOrBundled(spec, fs.readFileSync(path, "utf-8"));
309
386
  }
310
387
  function skillTextIsManagedOrBundled(spec, text) {
311
- const mark = marker(spec);
312
- const gen = generatedBy(spec);
313
- if (text.includes(mark) && text.includes(gen)) {
388
+ if (skillTextHasCurrentMarker(spec, text) || skillTextHasLegacyMarker(spec, text)) {
314
389
  return true;
315
390
  }
316
391
  return normalizeSkillText(spec, text) === normalizeSkillText(spec, spec.source);
317
392
  }
393
+ function skillTextHasCurrentMarker(spec, text) {
394
+ return text.replace(/\r\n/g, "\n").includes(managedMarkerBlock(spec));
395
+ }
396
+ function skillTextHasLegacyMarker(spec, text) {
397
+ return text.replace(/\r\n/g, "\n").includes(legacyMarkerBlock(spec));
398
+ }
318
399
  /**
319
- * Drop the managed-marker lines, then collapse runs of blank lines to one so that the
400
+ * Drop the managed-marker blocks, then collapse runs of blank lines to one so that the
320
401
  * blank line `managedSkillContents` inserts after the marker block does not make a
321
402
  * managed install compare unequal to the bundled source.
322
403
  */
323
404
  function normalizeSkillText(spec, text) {
324
- const mark = marker(spec);
325
- const gen = generatedBy(spec);
405
+ text = text.replace(/\r\n/g, "\n");
406
+ text = text.replace(managedMarkerBlock(spec), "");
407
+ text = text.replace(legacyMarkerBlock(spec), "");
326
408
  const out = [];
327
409
  for (const line of splitLines(text)) {
328
410
  const trimmed = line.trim();
329
- if (trimmed.includes(mark) || trimmed.includes(gen)) {
330
- continue;
331
- }
332
411
  if (trimmed === "" && out.length > 0 && out[out.length - 1].trim() === "") {
333
412
  continue;
334
413
  }
@@ -352,14 +431,47 @@ function expandTilde(input) {
352
431
  }
353
432
  return input;
354
433
  }
434
+ function validateSpec(spec) {
435
+ validateSlug("skill name", spec.name);
436
+ validateSlug("marker slug", spec.markerSlug);
437
+ validateSkillText(spec, spec.source);
438
+ }
439
+ function validateSlug(field, value) {
440
+ if (slugIsValid(value)) {
441
+ return;
442
+ }
443
+ throw new SkillError(`invalid ${field} ${JSON.stringify(value)}: expected a lowercase slug matching [a-z0-9][a-z0-9-]*[a-z0-9]`, "use lowercase ASCII letters, digits, and single hyphen-separated words");
444
+ }
445
+ function slugIsValid(value) {
446
+ if (value.length === 0)
447
+ return false;
448
+ const isLowerAlnum = (ch) => (ch >= "a" && ch <= "z") || (ch >= "0" && ch <= "9");
449
+ return isLowerAlnum(value[0]) && isLowerAlnum(value[value.length - 1]) &&
450
+ [...value].every((ch) => isLowerAlnum(ch) || ch === "-");
451
+ }
452
+ function lstatOrNull(path) {
453
+ return fs.lstatSync(path, { throwIfNoEntry: false }) ?? null;
454
+ }
455
+ function writeSkillAtomic(target, content) {
456
+ const tmp = join(target.skillDir, `.${basename(SKILL_FILE_NAME)}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`);
457
+ try {
458
+ fs.writeFileSync(tmp, content);
459
+ fs.renameSync(tmp, target.skillPath);
460
+ }
461
+ catch (error) {
462
+ try {
463
+ fs.rmSync(tmp, { force: true });
464
+ }
465
+ catch {
466
+ // ignore cleanup errors
467
+ }
468
+ throw error;
469
+ }
470
+ }
355
471
  /**
356
472
  * Mirror Rust's `str::lines`: split on `\n` (normalizing `\r\n`), dropping a single
357
473
  * trailing empty element from a final newline.
358
474
  */
359
- function isFile(p) {
360
- const st = fs.statSync(p, { throwIfNoEntry: false });
361
- return st !== undefined && st.isFile();
362
- }
363
475
  function splitLines(s) {
364
476
  const lines = s.replace(/\r\n/g, "\n").split("\n");
365
477
  if (lines.length > 0 && lines[lines.length - 1] === "") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-first-data",
3
- "version": "0.12.2",
3
+ "version": "0.13.1",
4
4
  "description": "A naming convention that lets AI agents understand your data without being told what it means.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -26,6 +26,7 @@
26
26
  ],
27
27
  "scripts": {
28
28
  "build": "tsc",
29
+ "prepack": "npm run build",
29
30
  "test": "tsx --test src/*.test.ts examples/agent_cli.ts"
30
31
  },
31
32
  "devDependencies": {