happyskills 1.19.0 → 1.20.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/CHANGELOG.md CHANGED
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.20.0] - 2026-07-06
11
+
12
+ ### Added
13
+
14
+ - Add post-install manual-setup support (`setupGuide` + `complete_manual_setup`). A `config`/`env` field can declare a `setupGuide` (a path to a reference explaining how a human obtains the value — e.g. minting a scoped API token in a provider dashboard, something the agent can't do) and an optional `verify` command. On install/update, when a `required` field with a `setupGuide` is still unset, `install` now emits a `next_step` with the new closed action **`complete_manual_setup`** (kind `continuation`), carrying `context.pending[]` = `{ skill, var, guide, verify, target }`, plus a human-readable "ACTION REQUIRED" banner in text mode. The requirement re-surfaces in `warnings` on every reinstall/update until satisfied (self-healing). `validate` gains two checks: a `setupGuide` must resolve to a real file in the skill, and a `required` secret with no `setupGuide` in a skill whose text mentions manual credential setup gets an advisory warning. Bumped `ENVELOPE_SCHEMA_VERSION` to `1.1.0` (additive: one new next_step action).
15
+
10
16
  ## [1.19.0] - 2026-07-06
11
17
 
12
18
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "1.19.0",
3
+ "version": "1.20.0",
4
4
  "description": "Package manager for AI agent skills",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Nicolas Dao <nic@cloudlesslabs.com> (https://cloudlesslabs.com)",
@@ -234,6 +234,17 @@ const run = (args) => catch_errors('Install failed', async () => {
234
234
 
235
235
  const snapshot_by_skill = Object.fromEntries(fresh_snapshots.map(s => [s.skill, s.snapshot_id]))
236
236
 
237
+ // Manual-setup requirements aggregated across everything installed: required credentials/config
238
+ // a HUMAN must satisfy (the agent can't — e.g. mint a scoped API token in a dashboard). Surfaced
239
+ // as a `complete_manual_setup` next_step so the piloting skill guides the principal; the same
240
+ // state re-surfaces on every reinstall (via configure's warnings) until it is resolved.
241
+ const setup_pending = results.flatMap(({ result }) => result.setup_pending || [])
242
+ const manual_setup_next_step = setup_pending.length ? {
243
+ action: 'complete_manual_setup',
244
+ instructions: `${setup_pending.length} credential(s) need manual setup only a human can do before ${setup_pending.length === 1 ? 'this skill works' : 'these skills work'}: ${setup_pending.map(p => p.var).join(', ')}. For EACH: read its guide, walk the user through it in plain language, then run its verify command. Do not report the install as complete until this is done.`,
245
+ context: { pending: setup_pending }
246
+ } : null
247
+
237
248
  if (args.flags.json) {
238
249
  // Spec 260525-cli-default-json § 4.3 rules 2 + 4:
239
250
  // - No shape-flipping between single and batch — always emit
@@ -261,10 +272,23 @@ const run = (args) => catch_errors('Install failed', async () => {
261
272
  status: 'failed',
262
273
  error: { code: 'INTERNAL_ERROR', message: error }
263
274
  }))
264
- print_json({ data: { results: [...success_rows, ...failure_rows] } })
275
+ print_json({
276
+ data: { results: [...success_rows, ...failure_rows] },
277
+ ...(manual_setup_next_step ? { next_step: manual_setup_next_step } : {})
278
+ })
265
279
  return
266
280
  }
267
281
 
282
+ if (setup_pending.length) {
283
+ console.log()
284
+ print_warn('⚠ ACTION REQUIRED — install is not finished. A human must complete manual setup:')
285
+ for (const p of setup_pending) {
286
+ print_warn(` ${p.skill} · ${p.var} — the agent cannot do this for you.`)
287
+ print_warn(` → guide: ${p.guide}`)
288
+ if (p.verify) print_warn(` → verify: ${p.verify}`)
289
+ }
290
+ }
291
+
268
292
  console.log()
269
293
  print_hint(`See what's installed: ${code('happyskills list')}`)
270
294
  }).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
@@ -8,6 +8,7 @@ const { validate_file_sizes } = require('../validation/file_size_rules')
8
8
  const { validate_changelog_version } = require('../validation/changelog_rules')
9
9
  const { validate_dependencies } = require('../validation/dependency_rules')
10
10
  const { validate_config_usage } = require('../validation/config_usage_rules')
11
+ const { validate_setup_guides } = require('../validation/setup_guide_rules')
11
12
  const { file_exists, read_json } = require('../utils/fs')
12
13
  const { skills_dir, find_project_root, lock_root } = require('../config/paths')
13
14
  const { read_lock, get_all_locked_skills } = require('../lock/reader')
@@ -181,6 +182,8 @@ const run = (args) => catch_errors('Validate failed', async () => {
181
182
  if (cl_err) throw cl_err
182
183
  const [cfg_err, cfg_results] = await validate_config_usage(skill_dir, json_data.manifest, md_data.content)
183
184
  if (cfg_err) throw cfg_err
185
+ const [sg_err, sg_results] = await validate_setup_guides(skill_dir, json_data.manifest, md_data.content)
186
+ if (sg_err) throw sg_err
184
187
 
185
188
  // Dependency validation — registry checks when authenticated
186
189
  let dep_results = []
@@ -222,7 +225,7 @@ const run = (args) => catch_errors('Validate failed', async () => {
222
225
  }
223
226
  }
224
227
 
225
- const all_results = [...md_data.results, ...json_data.results, ...cross_results, ...marker_results, ...size_results, ...cl_results, ...(cfg_results || []), ...dep_results]
228
+ const all_results = [...md_data.results, ...json_data.results, ...cross_results, ...marker_results, ...size_results, ...cl_results, ...(cfg_results || []), ...(sg_results || []), ...dep_results]
226
229
  const type_label = is_kit ? ' [kit]' : ''
227
230
 
228
231
  if (args.flags.json) {
@@ -60,6 +60,7 @@ const PASS_YES_FLAG = 'pass_yes_flag'
60
60
  const RANK_DIGESTS_INLINE = 'rank_digests_inline'
61
61
  const PRESENT_TO_USER = 'present_to_user'
62
62
  const ATTACH_SCREENSHOT = 'attach_screenshot' // § 15.3.4
63
+ const COMPLETE_MANUAL_SETUP = 'complete_manual_setup'
63
64
 
64
65
  // kind: routing
65
66
  const INSTALL_FIRST = 'install_first'
@@ -101,6 +102,7 @@ const ACTION_KIND = Object.freeze({
101
102
  [RANK_DIGESTS_INLINE]: CONTINUATION,
102
103
  [PRESENT_TO_USER]: CONTINUATION,
103
104
  [ATTACH_SCREENSHOT]: CONTINUATION,
105
+ [COMPLETE_MANUAL_SETUP]: CONTINUATION,
104
106
 
105
107
  [INSTALL_FIRST]: ROUTING,
106
108
  [DISCOVER_SCHEMA]: ROUTING,
@@ -117,7 +119,7 @@ const NEXT_STEP_ACTIONS = Object.freeze({
117
119
  RESOLVE_UNKNOWN_DRIFT, ORGANIZE_MATCHED_SKILLS,
118
120
  CONFIRM_DISCARD_OR_SNAPSHOT_FIRST, CONFIRM_CASCADE, CONFIRM_DESTRUCTIVE,
119
121
  PASS_YES_FLAG,
120
- RANK_DIGESTS_INLINE, PRESENT_TO_USER, ATTACH_SCREENSHOT,
122
+ RANK_DIGESTS_INLINE, PRESENT_TO_USER, ATTACH_SCREENSHOT, COMPLETE_MANUAL_SETUP,
121
123
  INSTALL_FIRST, DISCOVER_SCHEMA,
122
124
  })
123
125
 
@@ -400,6 +400,7 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
400
400
  )
401
401
  if (config_err) print_warn(`Skill configuration step failed: ${config_err[0]?.message || 'unknown error'}`)
402
402
  const config_warnings = config_result?.warnings || []
403
+ const config_setup_pending = config_result?.setup_pending || []
403
404
 
404
405
  const linked_count = downloaded.length - disabled_skills.size
405
406
  if (agents.length > 0 && linked_count > 0) {
@@ -458,7 +459,7 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
458
459
  const forced = forced_pkgs.map(p => ({ skill: p.skill, version: p.version }))
459
460
 
460
461
  const linked_agents = agents.map(a => a.id)
461
- return { skill, version: packages[0]?.version, no_op: false, installed, skipped, skipped_deps, warnings, forced, packages: packages_to_install.length, linked_agents }
462
+ return { skill, version: packages[0]?.version, no_op: false, installed, skipped, skipped_deps, warnings, forced, packages: packages_to_install.length, linked_agents, setup_pending: config_setup_pending }
462
463
  } catch (err) {
463
464
  await remove_dir(temp_dir)
464
465
  throw err
@@ -51,13 +51,13 @@ describe('happyskills schema --json', () => {
51
51
  const env = parse_envelope(stdout, 'schema --json')
52
52
  assert_success_envelope(env, 'schema --json')
53
53
  assert.strictEqual(env.meta.command, 'schema')
54
- assert.strictEqual(env.meta.envelope_schema_version, '1.0.0')
54
+ assert.strictEqual(env.meta.envelope_schema_version, '1.1.0')
55
55
  })
56
56
 
57
57
  it('data.envelope_schema_version + envelope_schema_uri are exposed', () => {
58
58
  const { stdout } = run(['schema', '--json'])
59
59
  const env = parse_envelope(stdout, 'schema --json envelope meta')
60
- assert.strictEqual(env.data.envelope_schema_version, '1.0.0')
60
+ assert.strictEqual(env.data.envelope_schema_version, '1.1.0')
61
61
  assert.strictEqual(env.data.envelope_schema_uri, 'https://schemas.happyskills.dev/envelope/v1.json')
62
62
  assert.ok(typeof env.data.cli_version === 'string')
63
63
  })
@@ -18,7 +18,7 @@ const {
18
18
  ACTION_KIND,
19
19
  } = require('../constants/next_step_actions')
20
20
 
21
- const ENVELOPE_SCHEMA_VERSION = '1.0.0'
21
+ const ENVELOPE_SCHEMA_VERSION = '1.1.0'
22
22
 
23
23
  const TOP_LEVEL_KEYS = ['ok', 'data', 'error', 'next_step', 'warnings', 'meta']
24
24
  const TOP_LEVEL_KEY_SET = new Set(TOP_LEVEL_KEYS)
@@ -72,6 +72,10 @@ const secret_is_set = (env_abs, name) => catch_errors('Failed to check secret pr
72
72
  const configure_installed_skills = (packages, options = {}) => catch_errors('Failed to configure installed skills', async () => {
73
73
  const { base_dir, config_root, interactive = false, only_required = false, ask = question_prompt } = options
74
74
  const warnings = []
75
+ // Manual-setup requirements the human must satisfy (a required field that declares a
76
+ // `setupGuide` and is still unset). The install command turns a non-empty list into a
77
+ // `next_step.action: complete_manual_setup` so the agent guides the principal through it.
78
+ const setup_pending = []
75
79
 
76
80
  const [, existing_config_file] = await read_skills_config(config_root)
77
81
 
@@ -157,7 +161,7 @@ const configure_installed_skills = (packages, options = {}) => catch_errors('Fai
157
161
  const secret_names = []
158
162
  for (const [var_name, spec] of Object.entries(env_schema || {})) {
159
163
  if (spec && spec.secret) {
160
- secret_names.push({ name: var_name, required: spec.required === true })
164
+ secret_names.push({ name: var_name, required: spec.required === true, setupGuide: spec.setupGuide, verify: spec.verify })
161
165
  continue
162
166
  }
163
167
  await maybe_prompt(var_name, spec)
@@ -179,7 +183,18 @@ const configure_installed_skills = (packages, options = {}) => catch_errors('Fai
179
183
 
180
184
  for (const secret of secret_names.filter(s => s.required)) {
181
185
  const [, is_set] = await secret_is_set(env_abs, secret.name)
182
- if (!is_set) warnings.push(`Required secret ${secret.name} for ${key} is not set — add it to ${env_file_rel}`)
186
+ if (is_set) continue
187
+ if (secret.setupGuide) {
188
+ // Human-only setup (e.g. mint a scoped API token in a dashboard) — record it so
189
+ // the install command can surface a `complete_manual_setup` next_step, and point
190
+ // the warning at the guide so the requirement re-fires (with the how-to) on every
191
+ // reinstall/update until it is satisfied.
192
+ const guide_abs = path.join(skill_install_dir(base_dir, name), secret.setupGuide)
193
+ setup_pending.push({ skill: key, var: secret.name, kind: 'env', guide: guide_abs, verify: secret.verify || null, target: env_file_rel })
194
+ warnings.push(`Required secret ${secret.name} for ${key} needs manual setup — follow ${secret.setupGuide}, then add it to ${env_file_rel}`)
195
+ } else {
196
+ warnings.push(`Required secret ${secret.name} for ${key} is not set — add it to ${env_file_rel}`)
197
+ }
183
198
  }
184
199
  }
185
200
 
@@ -190,7 +205,13 @@ const configure_installed_skills = (packages, options = {}) => catch_errors('Fai
190
205
  if (!spec || spec.required !== true) return
191
206
  if (spec.default !== undefined) return
192
207
  if (Object.prototype.hasOwnProperty.call(overrides, field)) return
193
- warnings.push(`Required config "${field}" for ${key} is not set — no value provided and the skill declares no default.`)
208
+ if (spec.setupGuide) {
209
+ const guide_abs = path.join(skill_install_dir(base_dir, name), spec.setupGuide)
210
+ setup_pending.push({ skill: key, var: field, kind: 'config', guide: guide_abs, verify: spec.verify || null, target: null })
211
+ warnings.push(`Required config "${field}" for ${key} needs manual setup — follow ${spec.setupGuide}.`)
212
+ } else {
213
+ warnings.push(`Required config "${field}" for ${key} is not set — no value provided and the skill declares no default.`)
214
+ }
194
215
  }
195
216
  for (const [field, spec] of Object.entries(config_schema || {})) warn_required_unset(field, spec)
196
217
  for (const [var_name, spec] of Object.entries(env_schema || {})) {
@@ -209,7 +230,7 @@ const configure_installed_skills = (packages, options = {}) => catch_errors('Fai
209
230
  }
210
231
  }
211
232
 
212
- return { warnings }
233
+ return { warnings, setup_pending }
213
234
  })
214
235
 
215
236
  module.exports = { configure_installed_skills }
@@ -127,6 +127,49 @@ describe('configure_installed_skills — interactive overrides', () => {
127
127
  })
128
128
  })
129
129
 
130
+ describe('configure_installed_skills — manual setup (setupGuide)', () => {
131
+ it('records setup_pending + a guide-pointed warning for an unmet required secret with a setupGuide', async () => {
132
+ write_skill(base_dir, 'cloudflare', {
133
+ env: { CLOUDFLARE_API_TOKEN: { required: true, secret: true, setupGuide: 'references/token-model.md', verify: 'node scripts/cf.js rest GET /user/tokens/verify' } }
134
+ })
135
+ const [err, result] = await configure_installed_skills(
136
+ [{ skill: 'nicolasdao/cloudflare' }],
137
+ { base_dir, config_root, interactive: false }
138
+ )
139
+ assert.ifError(err)
140
+ assert.strictEqual(result.setup_pending.length, 1)
141
+ const p = result.setup_pending[0]
142
+ assert.strictEqual(p.skill, 'nicolasdao/cloudflare')
143
+ assert.strictEqual(p.var, 'CLOUDFLARE_API_TOKEN')
144
+ assert.ok(p.guide.endsWith('references/token-model.md'))
145
+ assert.strictEqual(p.verify, 'node scripts/cf.js rest GET /user/tokens/verify')
146
+ // the warning points at the guide (so it re-fires with the how-to on reinstall)
147
+ assert.ok(result.warnings.some(w => /manual setup/.test(w) && /references\/token-model\.md/.test(w)))
148
+ })
149
+
150
+ it('does NOT record setup_pending once the secret is set (idempotent — no nagging)', async () => {
151
+ write_skill(base_dir, 'cloudflare', {
152
+ env: { CLOUDFLARE_API_TOKEN: { required: true, secret: true, setupGuide: 'references/token-model.md' } }
153
+ })
154
+ const [err] = await configure_installed_skills([{ skill: 'nicolasdao/cloudflare' }], { base_dir, config_root, interactive: false })
155
+ assert.ifError(err)
156
+ // set the secret in the scaffolded env file
157
+ const envPath = path.join(config_root, 'secrets', 'cloudflare.env')
158
+ fs.writeFileSync(envPath, 'CLOUDFLARE_API_TOKEN=abc123\n')
159
+ const [err2, result2] = await configure_installed_skills([{ skill: 'nicolasdao/cloudflare' }], { base_dir, config_root, interactive: false })
160
+ assert.ifError(err2)
161
+ assert.strictEqual(result2.setup_pending.length, 0)
162
+ })
163
+
164
+ it('a required secret WITHOUT a setupGuide keeps the plain unset warning and no setup_pending', async () => {
165
+ write_skill(base_dir, 'plain', { env: { API_KEY: { required: true, secret: true } } })
166
+ const [err, result] = await configure_installed_skills([{ skill: 'acme/plain' }], { base_dir, config_root, interactive: false })
167
+ assert.ifError(err)
168
+ assert.strictEqual(result.setup_pending.length, 0)
169
+ assert.ok(result.warnings.some(w => /API_KEY/.test(w) && /is not set/.test(w)))
170
+ })
171
+ })
172
+
130
173
  describe('configure_installed_skills — constellation sharedEnv', () => {
131
174
  const TOKEN_ENV = { env: { CLOUDFLARE_API_TOKEN: { required: true, secret: true, description: 'CF token' } } }
132
175
 
@@ -0,0 +1,96 @@
1
+ 'use strict'
2
+ // setupGuide validation — the post-install manual-setup feature (spec: setupGuide + complete_manual_setup).
3
+ //
4
+ // A skill can attach a `setupGuide` (a reference file) to a `config`/`env` field whose value a HUMAN
5
+ // must obtain manually (e.g. create a scoped API token in a provider dashboard — something the agent
6
+ // cannot do). On install, when such a required field is unmet, the CLI emits
7
+ // `next_step.action: complete_manual_setup` pointing at the guide. This validator does two things:
8
+ //
9
+ // 1. CORRECTNESS — every declared `setupGuide` must be a non-empty string that resolves to a file
10
+ // inside the skill. A dangling pointer would make the install-time next_step reference nothing.
11
+ // 2. AUTHORING FLOOR — a deterministic nudge: a required SECRET with no `setupGuide`, in a skill
12
+ // whose instructions mention manual credential setup (dashboard / token / OAuth / permissions),
13
+ // is flagged as an advisory `warning` so the author considers adding a guide. Gated on the text
14
+ // signal so simple skills (a token the user already has) are NOT nagged. The LLM design skill is
15
+ // the precision layer on top of this floor.
16
+
17
+ const path = require('path')
18
+ const fs = require('fs')
19
+ const { error: { catch_errors } } = require('puffy-core')
20
+ const { file_exists, read_file } = require('../utils/fs')
21
+
22
+ const as_object = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : null)
23
+
24
+ const result = (file, field, rule, severity, message, value) => ({
25
+ file, field, rule, severity, message, ...(value !== undefined ? { value } : {})
26
+ })
27
+
28
+ // Signals in the skill's own instructions that obtaining a credential is a manual, human-only chore.
29
+ const MANUAL_SETUP_SIGNAL = /dashboard|console|create a token|create an? api|api token|api key|oauth|sign in to|log ?in to|navigate to|grant .*permission|permission group|client secret|service account/i
30
+
31
+ // Recursively collect files under a directory (best-effort; references are text/markdown).
32
+ const collect_files = (dir) => {
33
+ const out = []
34
+ const walk = (d) => {
35
+ for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
36
+ const full = path.join(d, entry.name)
37
+ if (entry.isDirectory()) walk(full)
38
+ else out.push(full)
39
+ }
40
+ }
41
+ try { walk(dir) } catch { /* unreadable dir — nothing to scan */ }
42
+ return out
43
+ }
44
+
45
+ // Every declared config/env field, flattened to { name, spec, kind }.
46
+ const collect_fields = (manifest) => {
47
+ const out = []
48
+ const cfg = as_object(manifest && manifest.config) || {}
49
+ const env = as_object(manifest && manifest.env) || {}
50
+ for (const [name, spec] of Object.entries(cfg)) out.push({ name, spec: as_object(spec) || {}, kind: 'config' })
51
+ for (const [name, spec] of Object.entries(env)) out.push({ name, spec: as_object(spec) || {}, kind: 'env' })
52
+ return out
53
+ }
54
+
55
+ const validate_setup_guides = (skill_dir, manifest, skill_md_content) => catch_errors('Failed to validate setup guides', async () => {
56
+ if (!manifest) return []
57
+ const results = []
58
+ const fields = collect_fields(manifest)
59
+
60
+ // 1. Correctness — every declared setupGuide resolves to a real file inside the skill.
61
+ for (const { name, spec, kind } of fields) {
62
+ if (spec.setupGuide === undefined) continue
63
+ if (typeof spec.setupGuide !== 'string' || !spec.setupGuide.trim()) {
64
+ results.push(result('skill.json', 'setupGuide', 'setup_guide_type', 'error', `${kind} "${name}" setupGuide must be a non-empty string path`, name))
65
+ continue
66
+ }
67
+ const [, exists] = await file_exists(path.join(skill_dir, spec.setupGuide))
68
+ if (!exists) {
69
+ results.push(result('skill.json', 'setupGuide', 'setup_guide_missing', 'error', `${kind} "${name}" setupGuide points to "${spec.setupGuide}", which does not exist in the skill`, name))
70
+ }
71
+ }
72
+
73
+ // 2. Authoring floor — a required secret with no setupGuide, when the skill's text signals manual setup.
74
+ const has_required_secret_without_guide = fields.some(f => f.kind === 'env' && f.spec.secret === true && f.spec.required === true && !f.spec.setupGuide)
75
+ if (has_required_secret_without_guide) {
76
+ let text = String(skill_md_content || '')
77
+ const refs_dir = path.join(skill_dir, 'references')
78
+ const [, refs_exists] = await file_exists(refs_dir)
79
+ if (refs_exists) {
80
+ for (const file of collect_files(refs_dir)) {
81
+ const [, content] = await read_file(file)
82
+ if (content) text += '\n' + content
83
+ }
84
+ }
85
+ if (MANUAL_SETUP_SIGNAL.test(text)) {
86
+ for (const { name, spec, kind } of fields) {
87
+ if (kind !== 'env' || spec.secret !== true || spec.required !== true || spec.setupGuide) continue
88
+ results.push(result('skill.json', 'setupGuide', 'setup_guide_suggested', 'warning', `required secret "${name}" has no setupGuide, and the skill's instructions mention manual credential setup — if obtaining it needs human steps (a dashboard, creating a token, OAuth, granting permissions), add a setupGuide pointing to a reference that explains how, so install can surface it.`, name))
89
+ }
90
+ }
91
+ }
92
+
93
+ return results
94
+ })
95
+
96
+ module.exports = { validate_setup_guides }
@@ -0,0 +1,78 @@
1
+ 'use strict'
2
+ const { describe, it, beforeEach, afterEach } = require('node:test')
3
+ const assert = require('node:assert/strict')
4
+ const fs = require('fs')
5
+ const os = require('os')
6
+ const path = require('path')
7
+
8
+ const { validate_setup_guides } = require('./setup_guide_rules')
9
+
10
+ const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-setupguide-'))
11
+ let dir
12
+ beforeEach(() => { dir = make_tmp() })
13
+ afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }) })
14
+
15
+ const write_ref = (rel, body = 'guide\n') => {
16
+ const p = path.join(dir, rel)
17
+ fs.mkdirSync(path.dirname(p), { recursive: true })
18
+ fs.writeFileSync(p, body)
19
+ }
20
+
21
+ describe('validate_setup_guides — correctness', () => {
22
+ it('errors when a declared setupGuide points to a missing file', async () => {
23
+ const manifest = { env: { TOK: { required: true, secret: true, setupGuide: 'references/token.md' } } }
24
+ const [err, results] = await validate_setup_guides(dir, manifest, '# skill\n')
25
+ assert.ifError(err)
26
+ const r = results.find(x => x.rule === 'setup_guide_missing')
27
+ assert.ok(r && r.severity === 'error', 'missing setupGuide file must be an error')
28
+ })
29
+
30
+ it('passes when the setupGuide file exists', async () => {
31
+ write_ref('references/token.md')
32
+ const manifest = { env: { TOK: { required: true, secret: true, setupGuide: 'references/token.md' } } }
33
+ const [err, results] = await validate_setup_guides(dir, manifest, '# skill\n')
34
+ assert.ifError(err)
35
+ assert.ok(!results.some(x => x.rule === 'setup_guide_missing'), 'existing guide must not error')
36
+ assert.ok(!results.some(x => x.rule === 'setup_guide_suggested'), 'declared guide suppresses the floor')
37
+ })
38
+
39
+ it('errors when setupGuide is not a non-empty string', async () => {
40
+ const manifest = { env: { TOK: { required: true, secret: true, setupGuide: '' } } }
41
+ const [err, results] = await validate_setup_guides(dir, manifest, '# skill\n')
42
+ assert.ifError(err)
43
+ assert.ok(results.some(x => x.rule === 'setup_guide_type' && x.severity === 'error'))
44
+ })
45
+ })
46
+
47
+ describe('validate_setup_guides — authoring floor', () => {
48
+ it('warns when a required secret has no setupGuide AND the text signals manual setup', async () => {
49
+ const manifest = { env: { CLOUDFLARE_API_TOKEN: { required: true, secret: true } } }
50
+ const md = '# skill\nCreate an API token in the Cloudflare dashboard and grant these permissions.'
51
+ const [err, results] = await validate_setup_guides(dir, manifest, md)
52
+ assert.ifError(err)
53
+ const r = results.find(x => x.rule === 'setup_guide_suggested')
54
+ assert.ok(r && r.severity === 'warning' && /CLOUDFLARE_API_TOKEN/.test(r.value || r.message))
55
+ })
56
+
57
+ it('does NOT warn when there is no manual-setup signal in the text (avoid nagging simple skills)', async () => {
58
+ const manifest = { env: { SOME_TOKEN: { required: true, secret: true } } }
59
+ const md = '# skill\nThis skill formats text. It needs a token you already have.'
60
+ const [err, results] = await validate_setup_guides(dir, manifest, md)
61
+ assert.ifError(err)
62
+ assert.ok(!results.some(x => x.rule === 'setup_guide_suggested'), 'no signal → no floor warning')
63
+ })
64
+
65
+ it('does NOT warn for a required NON-secret (only secrets trigger the floor)', async () => {
66
+ const manifest = { env: { REGION: { required: true, secret: false } } }
67
+ const md = '# skill\nConfigure it in the dashboard console.'
68
+ const [err, results] = await validate_setup_guides(dir, manifest, md)
69
+ assert.ifError(err)
70
+ assert.ok(!results.some(x => x.rule === 'setup_guide_suggested'))
71
+ })
72
+
73
+ it('returns nothing for a skill with no config/env', async () => {
74
+ const [err, results] = await validate_setup_guides(dir, {}, '# skill\n')
75
+ assert.ifError(err)
76
+ assert.deepStrictEqual(results, [])
77
+ })
78
+ })