happyskills 1.19.0 → 1.20.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/CHANGELOG.md +12 -0
- package/package.json +1 -1
- package/src/commands/install.js +25 -1
- package/src/commands/validate.js +4 -1
- package/src/config/limits.js +1 -1
- package/src/constants/next_step_actions.js +3 -1
- package/src/engine/installer.js +2 -1
- package/src/integration/schema.test.js +2 -2
- package/src/schema/envelope_validator.js +1 -1
- package/src/skills_config/configure.js +25 -4
- package/src/skills_config/configure.test.js +43 -0
- package/src/validation/setup_guide_rules.js +96 -0
- package/src/validation/setup_guide_rules.test.js +78 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.20.1] - 2026-07-09
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Raise the publish bundle cap `MAX_TOTAL_SIZE` from 1 MB to 2 MB (`cli/src/config/limits.js`, mirroring `api/app/config/limits.js`; per-file `MAX_FILE_SIZE` unchanged at 1 MB). Affects the `validate`/`publish` pre-flight (`max_total_size` rule) and the streaming archive-install extraction cap. Guarded by the new root contract test `test/limits-mirror.test.js`. **Release after (or with) the API bump of the same constant so production never sees a CLI cap higher than the API's** (docs/architecture.md § 9).
|
|
15
|
+
|
|
16
|
+
## [1.20.0] - 2026-07-06
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- 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).
|
|
21
|
+
|
|
10
22
|
## [1.19.0] - 2026-07-06
|
|
11
23
|
|
|
12
24
|
### Added
|
package/package.json
CHANGED
package/src/commands/install.js
CHANGED
|
@@ -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({
|
|
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 } })
|
package/src/commands/validate.js
CHANGED
|
@@ -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) {
|
package/src/config/limits.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// validation will be rejected by the API.
|
|
4
4
|
|
|
5
5
|
const MAX_FILE_SIZE = 1 * 1024 * 1024 // 1 MB per file
|
|
6
|
-
const MAX_TOTAL_SIZE =
|
|
6
|
+
const MAX_TOTAL_SIZE = 2 * 1024 * 1024 // 2 MB per skill bundle (also the decompressed-archive cap — SUP-04)
|
|
7
7
|
const MAX_FILE_COUNT = 1000 // max entries per archive (zip-bomb / DoS guard; real skills have <100 files) — SUP-04
|
|
8
8
|
|
|
9
9
|
module.exports = {
|
|
@@ -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
|
|
package/src/engine/installer.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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 (
|
|
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
|
-
|
|
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
|
+
})
|