happyskills 1.18.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,18 @@ 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
+
16
+ ## [1.19.0] - 2026-07-06
17
+
18
+ ### Added
19
+
20
+ - Add constellation-aware shared secrets. A non-kit skill that declares `sharedEnv: true` in `skill.json` now shares a single `./secrets/<core>.env` across itself and its skill-dependencies on install, instead of scaffolding an identical per-skill secrets file for every member (the redundant, drift-prone case for related skills). A consumer-set `envFile` still wins, standalone installs and kits are unaffected, and a collision guard warns when two shared members declare the same secret name with a different type. `happyskills validate` now checks `sharedEnv` (must be a boolean; error on a kit; warning when `true` without dependencies). Documented in `docs/skill-format.md` §4.6.
21
+
10
22
  ## [1.18.0] - 2026-07-05
11
23
 
12
24
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "1.18.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,14 +72,61 @@ 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
 
82
+ // Constellation shared-env pre-pass (spec: `sharedEnv`). A non-kit package that declares
83
+ // `sharedEnv: true` is a constellation root: it and every skill-dependency being installed
84
+ // share ONE derived `.env` (`./secrets/<root>.env`) instead of a redundant, drift-prone file
85
+ // each. Kits (unrelated bundles) are excluded and keep per-member files. Deterministic: sorted
86
+ // iteration, first root wins, and a consumer's own envFile pointer (honored in the loop below)
87
+ // always overrides the shared default.
88
+ const pkg_keys = new Set((packages || []).map(p => p.skill))
89
+ const manifest_by_key = new Map()
90
+ for (const pkg of packages || []) {
91
+ const pkg_name = pkg.skill.split('/')[1]
92
+ const [, m] = await read_json(path.join(skill_install_dir(base_dir, pkg_name), SKILL_JSON))
93
+ if (m) manifest_by_key.set(pkg.skill, m)
94
+ }
95
+
96
+ const shared_env_file_by_key = new Map()
97
+ for (const root_key of [...pkg_keys].sort()) {
98
+ const m = manifest_by_key.get(root_key)
99
+ if (!m || m.sharedEnv !== true || m.type === 'kit') continue
100
+ const shared_file = `./secrets/${root_key.split('/')[1]}.env`
101
+ const dep_keys = Object.keys(as_object(m.dependencies) || {})
102
+ for (const member_key of [root_key, ...dep_keys]) {
103
+ if (pkg_keys.has(member_key) && !shared_env_file_by_key.has(member_key)) shared_env_file_by_key.set(member_key, shared_file)
104
+ }
105
+ }
106
+
107
+ // Collision guard: within one shared file, two members declaring the same secret NAME with a
108
+ // different `type` may not mean the same secret. Warn once so the author reconciles.
109
+ const shared_secret_seen = new Map() // `${file}\0${name}` -> { type, owner }
110
+ for (const member_key of [...shared_env_file_by_key.keys()].sort()) {
111
+ const shared_file = shared_env_file_by_key.get(member_key)
112
+ const env = as_object((manifest_by_key.get(member_key) || {}).env) || {}
113
+ for (const [var_name, spec] of Object.entries(env)) {
114
+ if (!spec || spec.secret !== true) continue
115
+ const seen_key = `${shared_file}\0${var_name}`
116
+ const this_type = spec.type === undefined ? null : spec.type
117
+ const prev = shared_secret_seen.get(seen_key)
118
+ if (prev) {
119
+ if (prev.type !== this_type) warnings.push(`Shared-env collision: secret "${var_name}" is declared by ${prev.owner} and ${member_key} with different types in ${shared_file} — verify they are the same secret.`)
120
+ } else {
121
+ shared_secret_seen.set(seen_key, { type: this_type, owner: member_key })
122
+ }
123
+ }
124
+ }
125
+
78
126
  for (const pkg of packages || []) {
79
127
  const key = pkg.skill
80
128
  const name = key.split('/')[1]
81
- const install_dir = skill_install_dir(base_dir, name)
82
- const [, manifest] = await read_json(path.join(install_dir, SKILL_JSON))
129
+ const manifest = manifest_by_key.get(key)
83
130
  if (!manifest) continue
84
131
 
85
132
  const config_schema = as_object(manifest.config)
@@ -114,7 +161,7 @@ const configure_installed_skills = (packages, options = {}) => catch_errors('Fai
114
161
  const secret_names = []
115
162
  for (const [var_name, spec] of Object.entries(env_schema || {})) {
116
163
  if (spec && spec.secret) {
117
- 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 })
118
165
  continue
119
166
  }
120
167
  await maybe_prompt(var_name, spec)
@@ -122,7 +169,7 @@ const configure_installed_skills = (packages, options = {}) => catch_errors('Fai
122
169
 
123
170
  let env_file_rel = (existing_entry && existing_entry.envFile) || null
124
171
  if (secret_names.length) {
125
- env_file_rel = env_file_rel || `./secrets/${name}.env`
172
+ env_file_rel = env_file_rel || shared_env_file_by_key.get(key) || `./secrets/${name}.env`
126
173
  // Resolve the (possibly consumer-chosen) envFile relative to the config root.
127
174
  const env_abs = path.resolve(config_root, env_file_rel)
128
175
  const [scaffold_err] = await ensure_env_names(env_abs, secret_names.map(s => s.name))
@@ -136,7 +183,18 @@ const configure_installed_skills = (packages, options = {}) => catch_errors('Fai
136
183
 
137
184
  for (const secret of secret_names.filter(s => s.required)) {
138
185
  const [, is_set] = await secret_is_set(env_abs, secret.name)
139
- 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
+ }
140
198
  }
141
199
  }
142
200
 
@@ -147,7 +205,13 @@ const configure_installed_skills = (packages, options = {}) => catch_errors('Fai
147
205
  if (!spec || spec.required !== true) return
148
206
  if (spec.default !== undefined) return
149
207
  if (Object.prototype.hasOwnProperty.call(overrides, field)) return
150
- 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
+ }
151
215
  }
152
216
  for (const [field, spec] of Object.entries(config_schema || {})) warn_required_unset(field, spec)
153
217
  for (const [var_name, spec] of Object.entries(env_schema || {})) {
@@ -166,7 +230,7 @@ const configure_installed_skills = (packages, options = {}) => catch_errors('Fai
166
230
  }
167
231
  }
168
232
 
169
- return { warnings }
233
+ return { warnings, setup_pending }
170
234
  })
171
235
 
172
236
  module.exports = { configure_installed_skills }
@@ -127,6 +127,147 @@ 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
+
173
+ describe('configure_installed_skills — constellation sharedEnv', () => {
174
+ const TOKEN_ENV = { env: { CLOUDFLARE_API_TOKEN: { required: true, secret: true, description: 'CF token' } } }
175
+
176
+ it('routes a shared-env core and its dependency members to ONE derived envFile', async () => {
177
+ write_skill(base_dir, 'cloudflare', {
178
+ sharedEnv: true,
179
+ dependencies: { 'nicolasdao/cloudflare-deploy': '^0.1.0', 'nicolasdao/cloudflare-config': '^0.1.0' },
180
+ ...TOKEN_ENV
181
+ })
182
+ write_skill(base_dir, 'cloudflare-deploy', TOKEN_ENV)
183
+ write_skill(base_dir, 'cloudflare-config', TOKEN_ENV)
184
+
185
+ const [err] = await configure_installed_skills(
186
+ [{ skill: 'nicolasdao/cloudflare' }, { skill: 'nicolasdao/cloudflare-deploy' }, { skill: 'nicolasdao/cloudflare-config' }],
187
+ { base_dir, config_root, interactive: false }
188
+ )
189
+ assert.ifError(err)
190
+
191
+ const cfg = JSON.parse(read(path.join(config_root, 'skills-config.json')))
192
+ assert.strictEqual(cfg['nicolasdao/cloudflare'].envFile, './secrets/cloudflare.env')
193
+ assert.strictEqual(cfg['nicolasdao/cloudflare-deploy'].envFile, './secrets/cloudflare.env')
194
+ assert.strictEqual(cfg['nicolasdao/cloudflare-config'].envFile, './secrets/cloudflare.env')
195
+
196
+ // exactly ONE shared secrets file — the redundant per-member files are not created
197
+ assert.ok(fs.existsSync(path.join(config_root, 'secrets', 'cloudflare.env')))
198
+ assert.ok(!fs.existsSync(path.join(config_root, 'secrets', 'cloudflare-deploy.env')))
199
+ assert.ok(!fs.existsSync(path.join(config_root, 'secrets', 'cloudflare-config.env')))
200
+
201
+ // the shared file carries the token name exactly once
202
+ const env_content = read(path.join(config_root, 'secrets', 'cloudflare.env'))
203
+ assert.strictEqual((env_content.match(/CLOUDFLARE_API_TOKEN=/g) || []).length, 1)
204
+ })
205
+
206
+ it('respects a consumer-set envFile over the shared default', async () => {
207
+ fs.writeFileSync(
208
+ path.join(config_root, 'skills-config.json'),
209
+ JSON.stringify({ 'nicolasdao/cloudflare-deploy': { envFile: './my-secrets/cf.env' } }, null, '\t')
210
+ )
211
+ write_skill(base_dir, 'cloudflare', { sharedEnv: true, dependencies: { 'nicolasdao/cloudflare-deploy': '^0.1.0' }, ...TOKEN_ENV })
212
+ write_skill(base_dir, 'cloudflare-deploy', TOKEN_ENV)
213
+
214
+ const [err] = await configure_installed_skills(
215
+ [{ skill: 'nicolasdao/cloudflare' }, { skill: 'nicolasdao/cloudflare-deploy' }],
216
+ { base_dir, config_root, interactive: false }
217
+ )
218
+ assert.ifError(err)
219
+
220
+ const cfg = JSON.parse(read(path.join(config_root, 'skills-config.json')))
221
+ assert.strictEqual(cfg['nicolasdao/cloudflare-deploy'].envFile, './my-secrets/cf.env') // consumer override wins
222
+ assert.strictEqual(cfg['nicolasdao/cloudflare'].envFile, './secrets/cloudflare.env')
223
+ })
224
+
225
+ it('does NOT share when the core lacks sharedEnv (regression: per-skill files)', async () => {
226
+ write_skill(base_dir, 'cloudflare', { dependencies: { 'nicolasdao/cloudflare-deploy': '^0.1.0' }, ...TOKEN_ENV })
227
+ write_skill(base_dir, 'cloudflare-deploy', TOKEN_ENV)
228
+
229
+ const [err] = await configure_installed_skills(
230
+ [{ skill: 'nicolasdao/cloudflare' }, { skill: 'nicolasdao/cloudflare-deploy' }],
231
+ { base_dir, config_root, interactive: false }
232
+ )
233
+ assert.ifError(err)
234
+
235
+ const cfg = JSON.parse(read(path.join(config_root, 'skills-config.json')))
236
+ assert.strictEqual(cfg['nicolasdao/cloudflare'].envFile, './secrets/cloudflare.env')
237
+ assert.strictEqual(cfg['nicolasdao/cloudflare-deploy'].envFile, './secrets/cloudflare-deploy.env')
238
+ })
239
+
240
+ it('does NOT share for a kit (kit members keep isolated secrets)', async () => {
241
+ write_skill(base_dir, '_kit-x', { type: 'kit', sharedEnv: true, dependencies: { 'acme/a': '^1.0.0' } })
242
+ write_skill(base_dir, 'a', TOKEN_ENV)
243
+
244
+ const [err] = await configure_installed_skills(
245
+ [{ skill: 'acme/_kit-x' }, { skill: 'acme/a' }],
246
+ { base_dir, config_root, interactive: false }
247
+ )
248
+ assert.ifError(err)
249
+
250
+ const cfg = JSON.parse(read(path.join(config_root, 'skills-config.json')))
251
+ assert.strictEqual(cfg['acme/a'].envFile, './secrets/a.env') // NOT the kit's file
252
+ })
253
+
254
+ it('warns when two shared members declare the same secret with a different type', async () => {
255
+ write_skill(base_dir, 'core', {
256
+ sharedEnv: true,
257
+ dependencies: { 'acme/sat': '^1.0.0' },
258
+ env: { TOK: { required: true, secret: true, type: 'string' } }
259
+ })
260
+ write_skill(base_dir, 'sat', { env: { TOK: { required: true, secret: true, type: 'integer' } } })
261
+
262
+ const [err, result] = await configure_installed_skills(
263
+ [{ skill: 'acme/core' }, { skill: 'acme/sat' }],
264
+ { base_dir, config_root, interactive: false }
265
+ )
266
+ assert.ifError(err)
267
+ assert.ok(result.warnings.some(w => /TOK/.test(w) && /shared/i.test(w)), 'a same-name/different-type shared secret must warn')
268
+ })
269
+ })
270
+
130
271
  describe('configure_installed_skills — reconcile on update (only_required)', () => {
131
272
  it('re-prompts exactly the newly-required field and leaves existing values untouched', async () => {
132
273
  // v1: only `channel` config. Consumer sets it.
@@ -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
+ })
@@ -277,6 +277,40 @@ const validate_env = (manifest) => {
277
277
  return results
278
278
  }
279
279
 
280
+ // sharedEnv (constellation-only). When a non-kit skill sets `sharedEnv: true`, the CLI points the
281
+ // skill AND its skill-dependencies at one shared `.env` on install — a constellation shares a
282
+ // config namespace, so its members should not each scaffold an identical secrets file. Forbidden
283
+ // on kits: a kit bundles unrelated skills, which keep isolated secrets. A no-op without deps.
284
+ const validate_shared_env = (manifest) => {
285
+ const results = []
286
+ if (manifest.sharedEnv === undefined) return results
287
+
288
+ if (typeof manifest.sharedEnv !== 'boolean') {
289
+ results.push(result('sharedEnv', 'type', 'error', 'sharedEnv must be a boolean'))
290
+ return results
291
+ }
292
+
293
+ if (manifest.sharedEnv !== true) {
294
+ results.push(result('sharedEnv', 'valid', 'pass', 'sharedEnv is false'))
295
+ return results
296
+ }
297
+
298
+ if (manifest.type === SKILL_TYPES.KIT) {
299
+ results.push(result('sharedEnv', 'kit_forbidden', 'error', 'sharedEnv is not allowed on a kit — kit members bundle unrelated skills and keep isolated secrets. Use sharedEnv only on a constellation core.'))
300
+ return results
301
+ }
302
+
303
+ const deps = manifest.dependencies
304
+ const dep_count = deps && typeof deps === 'object' && !Array.isArray(deps) ? Object.keys(deps).length : 0
305
+ if (dep_count === 0) {
306
+ results.push(result('sharedEnv', 'no_dependencies', 'warning', 'sharedEnv: true has no effect without dependencies — it shares one .env across the skill and its skill-dependencies (a constellation).'))
307
+ return results
308
+ }
309
+
310
+ results.push(result('sharedEnv', 'valid', 'pass', 'sharedEnv: true — the skill and its dependencies will share one .env on install'))
311
+ return results
312
+ }
313
+
280
314
  const validate_skill_json = (skill_dir) => catch_errors('Failed to validate skill.json', async () => {
281
315
  const results = []
282
316
  const json_path = path.join(skill_dir, SKILL_JSON)
@@ -308,6 +342,7 @@ const validate_skill_json = (skill_dir) => catch_errors('Failed to validate skil
308
342
  results.push(...validate_system_dependencies(manifest))
309
343
  results.push(...validate_config(manifest))
310
344
  results.push(...validate_env(manifest))
345
+ results.push(...validate_shared_env(manifest))
311
346
 
312
347
  const is_kit = manifest.type === SKILL_TYPES.KIT
313
348
  if (is_kit) {
@@ -69,6 +69,40 @@ describe('validate_skill_json — name', () => {
69
69
  })
70
70
  })
71
71
 
72
+ describe('validate_skill_json — sharedEnv', () => {
73
+ it('errors when sharedEnv is not a boolean', async () => {
74
+ write_json(tmp, { name: 'core', version: '1.0.0', sharedEnv: 'yes', dependencies: { 'a/b': '^1.0.0' } })
75
+ const [err, data] = await validate_skill_json(tmp)
76
+ assert.ifError(err)
77
+ const check = data.results.find(r => r.field === 'sharedEnv' && r.rule === 'type')
78
+ assert.strictEqual(check.severity, 'error')
79
+ })
80
+
81
+ it('errors when a kit declares sharedEnv: true', async () => {
82
+ write_json(tmp, { name: '_kit-x', version: '1.0.0', type: 'kit', sharedEnv: true, dependencies: { 'a/b': '^1.0.0' } })
83
+ const [err, data] = await validate_skill_json(tmp)
84
+ assert.ifError(err)
85
+ const check = data.results.find(r => r.field === 'sharedEnv' && r.rule === 'kit_forbidden')
86
+ assert.strictEqual(check.severity, 'error')
87
+ })
88
+
89
+ it('warns when sharedEnv: true has no dependencies', async () => {
90
+ write_json(tmp, { name: 'core', version: '1.0.0', sharedEnv: true })
91
+ const [err, data] = await validate_skill_json(tmp)
92
+ assert.ifError(err)
93
+ const check = data.results.find(r => r.field === 'sharedEnv' && r.rule === 'no_dependencies')
94
+ assert.strictEqual(check.severity, 'warning')
95
+ })
96
+
97
+ it('passes for sharedEnv: true with dependencies', async () => {
98
+ write_json(tmp, { name: 'core', version: '1.0.0', sharedEnv: true, dependencies: { 'a/b': '^1.0.0' } })
99
+ const [err, data] = await validate_skill_json(tmp)
100
+ assert.ifError(err)
101
+ const check = data.results.find(r => r.field === 'sharedEnv' && r.rule === 'valid')
102
+ assert.strictEqual(check.severity, 'pass')
103
+ })
104
+ })
105
+
72
106
  describe('validate_skill_json — version', () => {
73
107
  it('errors when version is missing', async () => {
74
108
  write_json(tmp, { name: 'my-skill' })