@goodboyjs/cli 0.1.0 → 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.
@@ -43,7 +43,7 @@ export const addCommand = new Command('add')
43
43
  formatValidationResult(result, dirName);
44
44
  throw new HandledFailure();
45
45
  }
46
- if (result.issues.some((i) => i.severity === 'warning')) {
46
+ if (result.issues.some((i) => i.severity === 'warning' || i.severity === 'info')) {
47
47
  spinner.stop();
48
48
  formatValidationResult(result, dirName);
49
49
  spinner.start('Continuing...');
@@ -4,7 +4,7 @@ import { appendFile, readFile } from 'node:fs/promises';
4
4
  import { join, sep } from 'node:path';
5
5
  import ora from 'ora';
6
6
  import { createRegistryAdapter } from '../lib/registry-adapter.js';
7
- import { readManifest, validateManifest } from '../lib/manifest.js';
7
+ import { readManifest, validateManifestDetailed } from '../lib/manifest.js';
8
8
  import { requestConsent } from '../lib/consent.js';
9
9
  import { scanForSymlinks } from '../lib/fs-security.js';
10
10
  import { logger, sanitiseError } from '../lib/logger.js';
@@ -73,7 +73,11 @@ export async function installNamed(name, options, cwd) {
73
73
  let manifest;
74
74
  try {
75
75
  const data = await readManifest(join(skillPath, 'manifest.json'));
76
- manifest = validateManifest(data);
76
+ const detailed = validateManifestDetailed(data);
77
+ manifest = detailed.manifest;
78
+ for (const warning of detailed.warnings) {
79
+ logger.warn(warning);
80
+ }
77
81
  }
78
82
  catch (err) {
79
83
  spinner.fail('Manifest validation failed');
@@ -2,5 +2,6 @@ import { Command } from 'commander';
2
2
  declare const BUMP_LEVELS: readonly ["patch", "minor", "major"];
3
3
  type BumpLevel = (typeof BUMP_LEVELS)[number];
4
4
  export declare function bumpVersion(version: string, level: BumpLevel): string;
5
+ export declare function assertWithin(target: string, base: string, label: string): void;
5
6
  export declare function registerSkillVersion(program: Command): void;
6
7
  export {};
@@ -1,9 +1,9 @@
1
- import { cp } from 'node:fs/promises';
1
+ import { cp, rm } from 'node:fs/promises';
2
2
  import { join, resolve, sep } from 'node:path';
3
3
  import ora from 'ora';
4
4
  import { getRegistryPath } from '../lib/registry.js';
5
5
  import { readRegistryEntry, writeRegistryEntry, resolveLatestVersion, resolveVersionPath, addVersionToEntry, } from '../lib/registry-entry.js';
6
- import { readManifest, writeManifest, validateManifest } from '../lib/manifest.js';
6
+ import { readManifest, writeManifest, validateManifestDetailed, FIELD_INTRODUCED_IN, KNOWN_SCHEMA_VERSION, } from '../lib/manifest.js';
7
7
  import { SKILL_NAME_RE } from '../lib/validation.js';
8
8
  import { logger, sanitiseError } from '../lib/logger.js';
9
9
  const BUMP_LEVELS = ['patch', 'minor', 'major'];
@@ -15,7 +15,10 @@ export function bumpVersion(version, level) {
15
15
  case 'major': return `${major + 1}.0.0`;
16
16
  }
17
17
  }
18
- function assertWithin(target, base, label) {
18
+ // Exported for direct unit testing: this guard is the security boundary in
19
+ // front of the rm() cleanup on a failed version bump, so its throw path is
20
+ // tested directly rather than left unreached-in-practice behind an ignore.
21
+ export function assertWithin(target, base, label) {
19
22
  const resolvedTarget = resolve(target);
20
23
  const resolvedBase = resolve(base);
21
24
  if (!resolvedTarget.startsWith(resolvedBase + sep)) {
@@ -75,14 +78,71 @@ async function createNewVersion(skillName, bump) {
75
78
  assertWithin(newVersionDir, skillDir, 'new version path');
76
79
  const spinner = ora(`Creating ${skillName}@${newVersion}...`).start();
77
80
  try {
78
- await cp(sourceVersionDir, newVersionDir, { recursive: true });
79
- const manifestPath = join(newVersionDir, 'manifest.json');
80
- const rawManifest = await readManifest(manifestPath);
81
- const manifest = validateManifest(rawManifest);
82
- manifest.version = newVersion;
83
- await writeManifest(manifestPath, manifest);
84
- const updatedEntry = addVersionToEntry(entry, newVersion, join('versions', newVersion));
85
- await writeRegistryEntry(skillDir, updatedEntry);
81
+ // Validate-then-act: read the SOURCE manifest (byte-identical to what cp()
82
+ // would copy below) and decide whether this bump is even allowed BEFORE
83
+ // creating anything on disk. A refusal here leaves no orphaned
84
+ // versions/<newVersion>/ directory behind — nothing has been written yet.
85
+ const sourceManifestPath = join(sourceVersionDir, 'manifest.json');
86
+ const rawManifest = await readManifest(sourceManifestPath);
87
+ const { manifest, warnings } = validateManifestDetailed(rawManifest);
88
+ // Refuse rather than persist a lossy write. S1's tolerant path returns a
89
+ // manifest with unknown top-level fields already stripped and the warning
90
+ // about it discarded by the thin validateManifest() wrapper — "stripped
91
+ // fields are invisible downstream" is a safe guarantee for read-only
92
+ // consumers (list, skill-status, ...), but this is the one path that
93
+ // WRITES a validated manifest back to disk. Persisting the stripped
94
+ // object here would silently delete fields a newer-minor manifest
95
+ // declared and silently downgrade its schema_version, with no warning
96
+ // ever surfaced to the author. Fail closed instead: require a real
97
+ // GoodBoy upgrade before this skill can be bumped.
98
+ if (warnings.length > 0) {
99
+ throw new Error(`${skillName}/manifest.json declares schema_version ${manifest.schema_version}, which is newer than ` +
100
+ `this GoodBoy CLI knows (${KNOWN_SCHEMA_VERSION}). Upgrade GoodBoy to bump this skill — bumping now ` +
101
+ `would discard fields this version does not understand.`);
102
+ }
103
+ // Everything from here on mutates the filesystem. The "already exists"
104
+ // check above (entry.versions[newVersion]) already guarantees no
105
+ // registered version lives at newVersionDir, so anything found there from
106
+ // this point is either created by this call's own cp() or is debris from
107
+ // an earlier failed attempt at this exact, still-unregistered version
108
+ // number — never content worth protecting. That makes cleanup on failure
109
+ // safe unconditionally, with no extra existence guard needed beyond the
110
+ // registry check that already ran.
111
+ try {
112
+ await cp(sourceVersionDir, newVersionDir, { recursive: true });
113
+ const manifestPath = join(newVersionDir, 'manifest.json');
114
+ manifest.version = newVersion;
115
+ // Stamp the lowest schema version this manifest actually needs. This only
116
+ // ever runs on a manifest that already validated strictly (no warnings,
117
+ // checked above) — it normalizes an already-valid, possibly over-stamped
118
+ // manifest down to its minimum (e.g. requires present but stamped higher
119
+ // than 1.1.0 -> 1.1.0; requires absent -> 1.0.0). It does NOT rescue an
120
+ // under-stamped, invalid manifest (schema_version below what a field it
121
+ // uses requires) — that already failed above, in manifest.ts's own
122
+ // feature-stamping gate, before this function even reached the copy step;
123
+ // fixing it requires a manual schema_version edit, by design.
124
+ manifest.schema_version = manifest.requires ? FIELD_INTRODUCED_IN['requires'] : '1.0.0';
125
+ await writeManifest(manifestPath, manifest);
126
+ const updatedEntry = addVersionToEntry(entry, newVersion, join('versions', newVersion));
127
+ await writeRegistryEntry(skillDir, updatedEntry);
128
+ }
129
+ catch (writeErr) {
130
+ // The copy (or a write after it) failed partway through. Remove whatever
131
+ // landed at newVersionDir so a failed bump never leaves an orphaned,
132
+ // unregistered versions/<newVersion>/ directory behind. Re-assert the
133
+ // path is still inside skillDir immediately before removing it — never
134
+ // remove anything outside that boundary. A cleanup failure must never
135
+ // replace or hide the real error: it's caught in its own try/catch and
136
+ // only logged, and the ORIGINAL write error is always what propagates.
137
+ try {
138
+ assertWithin(newVersionDir, skillDir, 'new version path');
139
+ await rm(newVersionDir, { recursive: true, force: true });
140
+ }
141
+ catch (cleanupErr) {
142
+ logger.warn(`Failed to clean up "${newVersionDir}" after a failed bump: ${sanitiseError(cleanupErr)}`);
143
+ }
144
+ throw writeErr;
145
+ }
86
146
  spinner.succeed();
87
147
  }
88
148
  catch (err) {
@@ -1,2 +1,7 @@
1
1
  import { Command } from 'commander';
2
+ export interface UpgradeOptions {
3
+ global?: boolean;
4
+ }
5
+ export declare function upgradeSkill(name: string, options: UpgradeOptions, cwd: string): Promise<void>;
6
+ export declare function upgradeAll(options: UpgradeOptions, cwd: string): Promise<void>;
2
7
  export declare const upgradeCommand: Command;
@@ -3,7 +3,7 @@ import { cpSync, existsSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import ora from 'ora';
5
5
  import { createRegistryAdapter } from '../lib/registry-adapter.js';
6
- import { readManifest, validateManifest } from '../lib/manifest.js';
6
+ import { readManifest, validateManifestDetailed } from '../lib/manifest.js';
7
7
  import { scanForSymlinks } from '../lib/fs-security.js';
8
8
  import { logger, sanitiseError } from '../lib/logger.js';
9
9
  import { SKILL_NAME_RE } from '../lib/validation.js';
@@ -12,7 +12,7 @@ import { getStorePath, getGoodboyHome } from '../lib/store.js';
12
12
  function getProjectSkillsPath(cwd) {
13
13
  return join(cwd, '.claude', 'skills');
14
14
  }
15
- async function upgradeSkill(name, options, cwd) {
15
+ export async function upgradeSkill(name, options, cwd) {
16
16
  if (!SKILL_NAME_RE.test(name)) {
17
17
  throw new Error(`Invalid skill name: "${name}". Must match ^[a-z0-9-]+$.`);
18
18
  }
@@ -29,7 +29,11 @@ async function upgradeSkill(name, options, cwd) {
29
29
  let manifest;
30
30
  try {
31
31
  const data = await readManifest(join(skillPath, 'manifest.json'));
32
- manifest = validateManifest(data);
32
+ const detailed = validateManifestDetailed(data);
33
+ manifest = detailed.manifest;
34
+ for (const warning of detailed.warnings) {
35
+ logger.warn(warning);
36
+ }
33
37
  }
34
38
  catch (err) {
35
39
  spinner.fail('Manifest validation failed');
@@ -67,7 +71,7 @@ async function upgradeSkill(name, options, cwd) {
67
71
  const from = lockedVersion !== null ? `${lockedVersion} → ` : '';
68
72
  spinner.succeed(`Upgraded "${name}" (${from}${manifest.version})`);
69
73
  }
70
- async function upgradeAll(options, cwd) {
74
+ export async function upgradeAll(options, cwd) {
71
75
  const dir = options.global ? getGoodboyHome() : cwd;
72
76
  const goodboy = await readGoodBoyJson(dir);
73
77
  if (!goodboy) {
@@ -32,13 +32,26 @@ export function summarizePermissions(manifest) {
32
32
  .map((p) => PERMISSION_LABELS[p]);
33
33
  }
34
34
  export async function requestConsent(manifest) {
35
- const lines = summarizePermissions(manifest);
36
- if (lines.length === 0)
35
+ const permissionLines = summarizePermissions(manifest);
36
+ const secretNames = manifest.requires?.secrets ?? [];
37
+ // Explicit, not inferred: declared secrets imply the "env" permission (enforced
38
+ // as a hard error in manifest.ts), but this check must not rely on that — it
39
+ // decides whether to prompt at all, so it names both conditions directly.
40
+ if (permissionLines.length === 0 && secretNames.length === 0)
37
41
  return true;
38
42
  logger.info('');
39
- logger.info(`Skill "${manifest.name}" requests the following permissions:`);
40
- for (const line of lines) {
41
- logger.info(` • ${line}`);
43
+ if (permissionLines.length > 0) {
44
+ logger.info(`Skill "${manifest.name}" requests the following permissions:`);
45
+ for (const line of permissionLines) {
46
+ logger.info(` • ${line}`);
47
+ }
48
+ }
49
+ if (secretNames.length > 0) {
50
+ logger.info('');
51
+ logger.info('Required secrets (names only — never resolved or read during install):');
52
+ for (const name of secretNames) {
53
+ logger.info(` • ${name}`);
54
+ }
42
55
  }
43
56
  logger.info('');
44
57
  return confirm({
@@ -1,4 +1,23 @@
1
1
  import type { GoodBoyManifest } from '../types/index.js';
2
+ /**
3
+ * The manifest schema version this CLI validates strictly against. Manifests
4
+ * declaring a newer minor are tolerated (unknown top-level fields stripped,
5
+ * with a warning); a newer major is rejected. See validateManifestDetailed().
6
+ */
7
+ export declare const KNOWN_SCHEMA_VERSION = "1.1.0";
8
+ export declare const FIELD_INTRODUCED_IN: Record<string, string>;
2
9
  export declare function readManifest(filePath: string): Promise<unknown>;
10
+ export interface ManifestValidationResult {
11
+ manifest: GoodBoyManifest;
12
+ warnings: string[];
13
+ }
14
+ /**
15
+ * Validates a manifest, tolerating a newer-minor `schema_version` than this
16
+ * CLI knows: unknown top-level fields are stripped (never mutating the
17
+ * caller's object) and a warning is returned instead of a hard failure. A
18
+ * newer major is always rejected. Manifests at or below the known minor take
19
+ * the exact same strict path as before this tolerance existed.
20
+ */
21
+ export declare function validateManifestDetailed(data: unknown): ManifestValidationResult;
3
22
  export declare function validateManifest(data: unknown): GoodBoyManifest;
4
23
  export declare function writeManifest(filePath: string, data: GoodBoyManifest): Promise<void>;
@@ -7,16 +7,100 @@ const addFormats = addFormatsPkg.default;
7
7
  const _require = createRequire(import.meta.url);
8
8
  const MAX_MANIFEST_BYTES = 512 * 1024; // 512 KB
9
9
  const MAX_NESTING_DEPTH = 10;
10
+ /**
11
+ * The manifest schema version this CLI validates strictly against. Manifests
12
+ * declaring a newer minor are tolerated (unknown top-level fields stripped,
13
+ * with a warning); a newer major is rejected. See validateManifestDetailed().
14
+ */
15
+ export const KNOWN_SCHEMA_VERSION = '1.1.0';
16
+ // Feature-driven stamping: a top-level field may only be used by a manifest
17
+ // that declares a schema_version at or above the version that introduced it.
18
+ // Without this, a manifest stamped e.g. "1.0.0" that uses a field only the
19
+ // known schema (now on a later minor) recognizes would validate successfully
20
+ // under Ajv — but an older, tolerant CLI that has never heard of the field
21
+ // would reject it with a confusing additionalProperties error. Enforced here,
22
+ // in code, because remediation text like this needs to name the exact field
23
+ // and the exact version to stamp — not something Ajv's own error shapes give us.
24
+ export const FIELD_INTRODUCED_IN = {
25
+ requires: '1.1.0',
26
+ };
27
+ const SCHEMA_VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)$/;
28
+ let _schema = null;
10
29
  let _validator = null;
30
+ function getSchema() {
31
+ if (_schema)
32
+ return _schema;
33
+ _schema = _require('@goodboyjs/schema/src/manifest.schema.json');
34
+ return _schema;
35
+ }
11
36
  function getValidator() {
12
37
  if (_validator)
13
38
  return _validator;
14
39
  const ajv = new Ajv({ strict: true, allErrors: true });
15
40
  addFormats(ajv);
16
- const schema = _require('@goodboyjs/schema/src/manifest.schema.json');
17
- _validator = ajv.compile(schema);
41
+ _validator = ajv.compile(getSchema());
18
42
  return _validator;
19
43
  }
44
+ // Top-level property names the known schema recognizes. Used only to strip
45
+ // unknown top-level fields from a newer-minor manifest before strict
46
+ // validation — never to loosen validation of known fields.
47
+ function getKnownTopLevelKeys() {
48
+ /* c8 ignore next -- the shipped schema always has a root "properties" key; ?? fallback is unreachable. Fail-closed if ever violated: an empty Set would strip every field, and every existing manifest test would fail immediately. */
49
+ const properties = (getSchema()['properties'] ?? {});
50
+ return new Set(Object.keys(properties));
51
+ }
52
+ function throwValidationError(validate) {
53
+ /* c8 ignore next 2 -- ajv always populates errors[] after a failed validate(); ?? fallbacks are unreachable */
54
+ const lines = (validate.errors ?? []).map((e) => ` ${e.instancePath || '(root)'}: ${e.message ?? 'validation failed'}`);
55
+ throw new Error(`Invalid manifest:\n${lines.join('\n')}`);
56
+ }
57
+ function parseSemverTriple(version) {
58
+ const match = SCHEMA_VERSION_PATTERN.exec(version);
59
+ /* c8 ignore next -- both call sites only ever pass an already-valid version string; null is a defensive return, not a reachable case */
60
+ if (!match)
61
+ return null;
62
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
63
+ }
64
+ function isVersionAtLeast(declared, required) {
65
+ for (let i = 0; i < 3; i++) {
66
+ if (declared[i] !== required[i])
67
+ return declared[i] > required[i];
68
+ }
69
+ return true;
70
+ }
71
+ // Runs after a manifest has already passed Ajv validation (so schema_version
72
+ // is guaranteed to match the schema's own pattern). Rejects a manifest that
73
+ // uses a field its declared schema_version predates.
74
+ function assertFeatureStamping(manifest) {
75
+ const declared = parseSemverTriple(manifest.schema_version);
76
+ /* c8 ignore next -- schema_version already satisfied Ajv's own pattern check by this point, so it always parses */
77
+ if (!declared)
78
+ return;
79
+ for (const key of Object.keys(manifest)) {
80
+ const introducedIn = FIELD_INTRODUCED_IN[key];
81
+ if (!introducedIn)
82
+ continue;
83
+ const required = parseSemverTriple(introducedIn);
84
+ if (!isVersionAtLeast(declared, required)) {
85
+ throw new Error(`manifest declares schema_version ${manifest.schema_version} but uses "${key}", which needs ${introducedIn}.\n` +
86
+ `Set "schema_version": "${introducedIn}" in manifest.json.`);
87
+ }
88
+ }
89
+ }
90
+ // Hard-error consistency rule (concept doc §7.1): requires.secrets is only
91
+ // ever visible to a skill-installing user via the "env" permission on a
92
+ // tolerant older CLI that doesn't know about `requires` at all — so the two
93
+ // fields must never disagree. Fail closed rather than silently trusting one.
94
+ function assertPermissionsConsistency(manifest) {
95
+ const secrets = manifest.requires?.secrets;
96
+ if (!secrets || secrets.length === 0)
97
+ return;
98
+ const permissions = manifest.permissions ?? [];
99
+ if (!permissions.includes('env')) {
100
+ throw new Error('manifest declares requires.secrets but "permissions" does not include "env".\n' +
101
+ 'Secrets are delivered as environment variables; add "env" to permissions.');
102
+ }
103
+ }
20
104
  // Heuristic nesting depth check: counts opening brackets/braces.
21
105
  // This is intentionally fast and runs before JSON.parse() to guard against
22
106
  // deeply nested payloads that could exhaust the stack. Brackets inside
@@ -66,14 +150,67 @@ export async function readManifest(filePath) {
66
150
  throw new Error(`manifest.json contains invalid JSON`);
67
151
  }
68
152
  }
69
- export function validateManifest(data) {
153
+ /**
154
+ * Validates a manifest, tolerating a newer-minor `schema_version` than this
155
+ * CLI knows: unknown top-level fields are stripped (never mutating the
156
+ * caller's object) and a warning is returned instead of a hard failure. A
157
+ * newer major is always rejected. Manifests at or below the known minor take
158
+ * the exact same strict path as before this tolerance existed.
159
+ */
160
+ export function validateManifestDetailed(data) {
70
161
  const validate = getValidator();
71
- if (!validate(data)) {
72
- /* c8 ignore next 2 -- ajv always populates errors[] after a failed validate(); ?? fallbacks are unreachable */
73
- const lines = (validate.errors ?? []).map((e) => ` ${e.instancePath || '(root)'}: ${e.message ?? 'validation failed'}`);
74
- throw new Error(`Invalid manifest:\n${lines.join('\n')}`);
162
+ const knownParts = KNOWN_SCHEMA_VERSION.split('.').map(Number);
163
+ const knownMajor = knownParts[0];
164
+ const knownMinor = knownParts[1];
165
+ if (data !== null && typeof data === 'object' && !Array.isArray(data)) {
166
+ const rawVersion = data['schema_version'];
167
+ // The length gate (mirrors the schema's own maxLength: 32) must run before any
168
+ // interpolation of rawVersion into a message: an overlong value falls straight
169
+ // through to strict Ajv validation, which rejects it via maxLength without ever
170
+ // embedding the value in the error text.
171
+ if (typeof rawVersion === 'string' && rawVersion.length <= 32) {
172
+ const match = SCHEMA_VERSION_PATTERN.exec(rawVersion);
173
+ if (match) {
174
+ const major = Number(match[1]);
175
+ const minor = Number(match[2]);
176
+ if (major !== knownMajor) {
177
+ const upgradeHint = major > knownMajor ? ' Upgrade GoodBoy to use this skill.' : '';
178
+ throw new Error(`manifest declares schema ${rawVersion}; this version of GoodBoy supports ${knownMajor}.x manifests.${upgradeHint}`);
179
+ }
180
+ if (minor > knownMinor) {
181
+ const known = getKnownTopLevelKeys();
182
+ const stripped = {};
183
+ for (const [key, value] of Object.entries(data)) {
184
+ if (known.has(key))
185
+ stripped[key] = value;
186
+ }
187
+ if (!validate(stripped))
188
+ throwValidationError(validate);
189
+ const manifest = stripped;
190
+ assertFeatureStamping(manifest);
191
+ assertPermissionsConsistency(manifest);
192
+ return {
193
+ manifest,
194
+ warnings: [
195
+ `manifest uses schema ${rawVersion}; this GoodBoy CLI knows ${KNOWN_SCHEMA_VERSION}. Unknown fields were ignored — upgrade GoodBoy to use them.`,
196
+ ],
197
+ };
198
+ }
199
+ // minor <= knownMinor: fall through to strict validation below, unchanged.
200
+ }
201
+ // non-matching string: fall through; Ajv's pattern check reports the standard error.
202
+ }
203
+ // missing/non-string schema_version: fall through; Ajv's required/type check reports the standard error.
75
204
  }
76
- return data;
205
+ if (!validate(data))
206
+ throwValidationError(validate);
207
+ const manifest = data;
208
+ assertFeatureStamping(manifest);
209
+ assertPermissionsConsistency(manifest);
210
+ return { manifest, warnings: [] };
211
+ }
212
+ export function validateManifest(data) {
213
+ return validateManifestDetailed(data).manifest;
77
214
  }
78
215
  export async function writeManifest(filePath, data) {
79
216
  const resolved = resolve(filePath);
@@ -1,4 +1,4 @@
1
- export type ValidationSeverity = 'error' | 'warning';
1
+ export type ValidationSeverity = 'error' | 'warning' | 'info';
2
2
  export interface ValidationIssue {
3
3
  severity: ValidationSeverity;
4
4
  message: string;
@@ -1,7 +1,7 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
- import { readManifest, validateManifest } from './manifest.js';
4
+ import { readManifest, validateManifestDetailed } from './manifest.js';
5
5
  import { logger } from './logger.js';
6
6
  function parseFrontmatter(content) {
7
7
  const lines = content.split('\n');
@@ -40,7 +40,11 @@ export async function validateSkillDirectory(skillPath) {
40
40
  try {
41
41
  const raw = await readManifest(manifestPath);
42
42
  try {
43
- manifest = validateManifest(raw);
43
+ const detailed = validateManifestDetailed(raw);
44
+ manifest = detailed.manifest;
45
+ for (const warning of detailed.warnings) {
46
+ issues.push({ severity: 'warning', message: warning });
47
+ }
44
48
  }
45
49
  catch (err) {
46
50
  /* c8 ignore next */
@@ -68,6 +72,13 @@ export async function validateSkillDirectory(skillPath) {
68
72
  if (!manifest.tags || manifest.tags.length === 0) {
69
73
  issues.push({ severity: 'warning', message: 'manifest has no tags' });
70
74
  }
75
+ const secretCount = manifest.requires?.secrets.length ?? 0;
76
+ if (secretCount > 0) {
77
+ issues.push({
78
+ severity: 'info',
79
+ message: `declares ${secretCount} required secret${secretCount === 1 ? '' : 's'}`,
80
+ });
81
+ }
71
82
  }
72
83
  // --- SKILL.md checks ---
73
84
  const skillMdPath = join(skillPath, 'SKILL.md');
@@ -107,6 +118,7 @@ export async function validateSkillDirectory(skillPath) {
107
118
  export function formatValidationResult(result, skillName) {
108
119
  const errors = result.issues.filter((i) => i.severity === 'error');
109
120
  const warnings = result.issues.filter((i) => i.severity === 'warning');
121
+ const infos = result.issues.filter((i) => i.severity === 'info');
110
122
  if (errors.length > 0) {
111
123
  logger.error(`Validation errors for "${skillName}":`);
112
124
  for (const issue of errors) {
@@ -119,4 +131,7 @@ export function formatValidationResult(result, skillName) {
119
131
  logger.warn(` • ${issue.message}`);
120
132
  }
121
133
  }
134
+ for (const issue of infos) {
135
+ logger.success(issue.message);
136
+ }
122
137
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodboyjs/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "A personal skill registry and package manager for Claude Code and the Agent Skills ecosystem",
5
5
  "keywords": [
6
6
  "claude-code",