happyskills 1.16.1 → 1.17.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.
@@ -0,0 +1,122 @@
1
+ 'use strict'
2
+ /**
3
+ * Integration test — install must not report "already installed" for a skill whose
4
+ * locked dependency closure is not materialized on disk.
5
+ *
6
+ * The production bug: a `skills-lock.json` recorded a kit as an installed root
7
+ * skill (`requested_by: ['__root__']`, valid integrity, present on disk) with a
8
+ * populated `dependencies` map — but those dependencies were never resolved into
9
+ * their own lock entries and never landed under `.agents/skills/`. Every
10
+ * subsequent `install` then hit the pre-resolve skip check, which inspected only
11
+ * the kit's own directory + integrity and returned "already installed" BEFORE
12
+ * dependency resolution ran. The missing deps were never backfilled: the lock and
13
+ * the on-disk `.agents/skills/` were permanently out of sync.
14
+ *
15
+ * These tests run the REAL CLI with an unreachable registry (localhost:0). That
16
+ * isolates the skip decision from the network:
17
+ * - If the skip fires, the command SUCCEEDS with "already installed" and never
18
+ * touches the network (the buggy behavior).
19
+ * - If the skip is correctly declined, the command falls through to resolution
20
+ * and FAILS on the unreachable registry (the fixed behavior).
21
+ * We assert on that difference, plus the inverse: a fully-materialized closure
22
+ * must still skip (no over-triggering, fast-path preserved).
23
+ */
24
+ const { describe, it } = require('node:test')
25
+ const assert = require('node:assert/strict')
26
+ const { spawnSync } = require('child_process')
27
+ const fs = require('fs')
28
+ const os = require('os')
29
+ const path = require('path')
30
+
31
+ const CLI = path.resolve(__dirname, '../../bin/happyskills.js')
32
+ const NODE = process.execPath
33
+
34
+ const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-dep-closure-test-'))
35
+ const run = (args, opts) => {
36
+ const result = spawnSync(NODE, [CLI, ...args, '--text'], {
37
+ env: { ...process.env, NO_COLOR: '1', HAPPYSKILLS_API_URL: 'http://localhost:0', CI: '', ...(opts?.env || {}) },
38
+ encoding: 'utf-8',
39
+ timeout: 15000,
40
+ cwd: opts?.cwd
41
+ })
42
+ return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status }
43
+ }
44
+
45
+ // Write a skill directory under <root>/.agents/skills/<name> and return the dir.
46
+ const write_skill = (root, name, manifest) => {
47
+ const dir = path.join(root, '.agents', 'skills', name)
48
+ fs.mkdirSync(dir, { recursive: true })
49
+ fs.writeFileSync(path.join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: dep-closure test skill\n---\nbody\n`)
50
+ fs.writeFileSync(path.join(dir, 'skill.json'), JSON.stringify(manifest, null, '\t'))
51
+ return dir
52
+ }
53
+
54
+ // Build a project whose lock records `kit` as a __root__ skill (valid integrity,
55
+ // on disk) with the given dependency map. `present_deps` names the deps that are
56
+ // ALSO created on disk + locked; any dep in the map but not in present_deps is
57
+ // declared-but-missing.
58
+ const scaffold = async ({ kit, kit_deps, present_deps }) => {
59
+ const { hash_directory, clear_integrity_cache } = require('../lock/integrity')
60
+ const root = make_tmp()
61
+ const skills = {}
62
+
63
+ const kit_dir = write_skill(root, kit, {
64
+ name: kit, version: '1.0.0', type: 'kit',
65
+ description: 'dep-closure test kit',
66
+ dependencies: Object.fromEntries(kit_deps.map(d => [`acme/${d}`, '*']))
67
+ })
68
+ clear_integrity_cache()
69
+ const [, kit_integrity] = await hash_directory(kit_dir)
70
+ skills[`acme/${kit}`] = {
71
+ version: '1.0.0', ref: 'refs/tags/v1.0.0', commit: 'c', integrity: kit_integrity,
72
+ base_commit: 'c', base_integrity: kit_integrity,
73
+ requested_by: ['__root__'],
74
+ dependencies: Object.fromEntries(kit_deps.map(d => [`acme/${d}`, '*'])),
75
+ type: 'kit'
76
+ }
77
+
78
+ for (const dep of present_deps) {
79
+ const dep_dir = write_skill(root, dep, { name: dep, version: '1.0.0', type: 'skill', description: 'dep' })
80
+ clear_integrity_cache()
81
+ const [, dep_integrity] = await hash_directory(dep_dir)
82
+ skills[`acme/${dep}`] = {
83
+ version: '1.0.0', ref: 'refs/tags/v1.0.0', commit: 'c', integrity: dep_integrity,
84
+ base_commit: 'c', base_integrity: dep_integrity,
85
+ requested_by: [`acme/${kit}`], dependencies: {}, type: 'skill'
86
+ }
87
+ }
88
+
89
+ fs.writeFileSync(
90
+ path.join(root, 'skills-lock.json'),
91
+ JSON.stringify({ lockVersion: 2, generatedAt: new Date().toISOString(), skills }, null, '\t')
92
+ )
93
+ return { root, cleanup: () => fs.rmSync(root, { recursive: true, force: true }) }
94
+ }
95
+
96
+ describe('install — dependency-closure skip guard', () => {
97
+ it('does NOT report "already installed" when a locked dependency was never materialized', async () => {
98
+ // kit declares acme/dep-a but dep-a is neither locked nor on disk.
99
+ const ctx = await scaffold({ kit: 'thekit', kit_deps: ['dep-a'], present_deps: [] })
100
+ try {
101
+ const { code, stdout, stderr } = run(['install', 'acme/thekit'], { cwd: ctx.root })
102
+ const out = stdout + stderr
103
+ // Fixed behavior: the skip is declined, so install falls through to
104
+ // resolution and fails against the unreachable registry.
105
+ assert.doesNotMatch(out, /already installed/i,
106
+ 'must not claim already-installed while a declared dependency is missing')
107
+ assert.notStrictEqual(code, 0, 'must attempt resolution (and fail on the unreachable registry), not skip')
108
+ } finally { ctx.cleanup() }
109
+ })
110
+
111
+ it('still skips (already installed, no network) when the whole closure is present', async () => {
112
+ // Regression guard: a fully-materialized closure must keep the fast path —
113
+ // skip without any resolution/network call, even with the registry down.
114
+ const ctx = await scaffold({ kit: 'fullkit', kit_deps: ['dep-a'], present_deps: ['dep-a'] })
115
+ try {
116
+ const { code, stdout, stderr } = run(['install', 'acme/fullkit'], { cwd: ctx.root })
117
+ const out = stdout + stderr
118
+ assert.match(out, /already installed/i, 'a complete closure must still short-circuit')
119
+ assert.strictEqual(code, 0, 'skip path must not touch the network')
120
+ } finally { ctx.cleanup() }
121
+ })
122
+ })
@@ -0,0 +1,98 @@
1
+ 'use strict'
2
+ // Conformance test for `happyskills skills-config get` — spec 260704-01 Tier 3 §4.4.
3
+ // Exercises the real command end-to-end (spawned CLI) against a tmp project: envelope
4
+ // conformance (STRICT), owner/name validation, and the §0 hard rule that no secret VALUE
5
+ // ever reaches stdout/stderr.
6
+
7
+ const { describe, it, beforeEach, afterEach } = require('node:test')
8
+ const assert = require('node:assert/strict')
9
+ const { spawnSync } = require('child_process')
10
+ const fs = require('fs')
11
+ const os = require('os')
12
+ const path = require('path')
13
+
14
+ const CLI = path.resolve(__dirname, '../../bin/happyskills.js')
15
+ const NODE = process.execPath
16
+
17
+ const run = (args, cwd) => {
18
+ const result = spawnSync(NODE, [CLI, ...args], {
19
+ cwd,
20
+ env: { ...process.env, NO_COLOR: '1', HAPPYSKILLS_ENVELOPE_STRICT: '1', HAPPYSKILLS_API_URL: 'http://localhost:0', CI: '' },
21
+ encoding: 'utf-8',
22
+ timeout: 10000,
23
+ })
24
+ return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status }
25
+ }
26
+
27
+ const SECRET = 'xoxb-SUPER-SECRET-DO-NOT-LEAK'
28
+
29
+ let proj
30
+ beforeEach(() => {
31
+ proj = fs.mkdtempSync(path.join(os.tmpdir(), 'hs-sc-cmd-'))
32
+ const skill_dir = path.join(proj, '.agents', 'skills', 'slack-notify')
33
+ fs.mkdirSync(skill_dir, { recursive: true })
34
+ fs.writeFileSync(path.join(skill_dir, 'skill.json'), JSON.stringify({
35
+ name: 'slack-notify', version: '1.0.0',
36
+ config: { channel: { type: 'string', default: '#general' }, max_chars: { type: 'integer', default: 500 } },
37
+ env: { SLACK_BOT_TOKEN: { required: true, secret: true }, DEPLOY_TIMEOUT: { required: false, secret: false, type: 'integer', default: 300 } }
38
+ }))
39
+ fs.writeFileSync(path.join(skill_dir, 'SKILL.md'), '# slack\n')
40
+ fs.writeFileSync(path.join(proj, 'skills-config.json'), JSON.stringify({
41
+ 'acme/slack-notify': { envFile: './secrets/slack-notify.env', config: { channel: '#deploys' } }
42
+ }))
43
+ fs.mkdirSync(path.join(proj, 'secrets'), { recursive: true })
44
+ fs.writeFileSync(path.join(proj, 'secrets', 'slack-notify.env'), `SLACK_BOT_TOKEN=${SECRET}\n`)
45
+ })
46
+ afterEach(() => { fs.rmSync(proj, { recursive: true, force: true }) })
47
+
48
+ describe('happyskills skills-config get', () => {
49
+ it('emits a valid success envelope with the resolved non-secret config — and NO secret value', () => {
50
+ const { stdout, stderr, code } = run(['skills-config', 'get', 'acme/slack-notify', '--json'], proj)
51
+ assert.strictEqual(code, 0)
52
+ const env = JSON.parse(stdout)
53
+ assert.strictEqual(env.ok, true)
54
+ assert.strictEqual(env.meta.command, 'skills-config')
55
+ assert.strictEqual(typeof env.data, 'object')
56
+ assert.ok(!Array.isArray(env.data))
57
+ assert.strictEqual(env.data.config.channel, '#deploys') // project override wins
58
+ assert.strictEqual(env.data.config.max_chars, 500) // author default
59
+ assert.deepStrictEqual(env.data.requiredSecrets, ['SLACK_BOT_TOKEN'])
60
+ assert.strictEqual(env.data.secretsPresent, true)
61
+ assert.strictEqual(env.data.envFile, './secrets/slack-notify.env')
62
+
63
+ // §0 hard rule: the secret VALUE must never appear in any output stream
64
+ assert.ok(!stdout.includes(SECRET), 'secret leaked to stdout')
65
+ assert.ok(!stderr.includes(SECRET), 'secret leaked to stderr')
66
+ })
67
+
68
+ it('resolves from a SUBDIRECTORY by walking up to the project root (§6 #7)', () => {
69
+ const sub = path.join(proj, 'services', 'api')
70
+ fs.mkdirSync(sub, { recursive: true })
71
+ const { stdout, code } = run(['skills-config', 'get', 'acme/slack-notify', '--json'], sub)
72
+ assert.strictEqual(code, 0)
73
+ const env = JSON.parse(stdout)
74
+ assert.strictEqual(env.ok, true)
75
+ // same resolution as from the root — proves the CLI now matches the file-fallback walk-up
76
+ assert.strictEqual(env.data.config.channel, '#deploys')
77
+ assert.strictEqual(env.data.secretsPresent, true)
78
+ // envFile is relative to the caller's cwd (the subdir), so it stays usable from here
79
+ assert.strictEqual(env.data.envFile, '../../secrets/slack-notify.env')
80
+ })
81
+
82
+ it('rejects a non-owner/name argument with a USAGE_ERROR envelope', () => {
83
+ const { stdout, code } = run(['skills-config', 'get', 'not-a-scoped-name', '--json'], proj)
84
+ assert.strictEqual(code, 2)
85
+ const env = JSON.parse(stdout)
86
+ assert.strictEqual(env.ok, false)
87
+ assert.strictEqual(env.error.code, 'USAGE_ERROR')
88
+ assert.strictEqual(typeof env.data, 'object')
89
+ })
90
+
91
+ it('rejects an unknown subcommand', () => {
92
+ const { stdout, code } = run(['skills-config', 'wat', '--json'], proj)
93
+ assert.strictEqual(code, 2)
94
+ const env = JSON.parse(stdout)
95
+ assert.strictEqual(env.ok, false)
96
+ assert.strictEqual(env.error.code, 'USAGE_ERROR')
97
+ })
98
+ })
@@ -118,6 +118,58 @@ const detect_ahead_state = (lock_entry, install_dir) => catch_errors('Failed to
118
118
  }
119
119
  })
120
120
 
121
+ // dependency_closure_satisfied — walk the locked dependency graph starting at
122
+ // `skill` and confirm every reachable skill is BOTH present in the lock AND
123
+ // materialized on disk. Returns { satisfied: bool, missing: [full skill names] }.
124
+ //
125
+ // This closes the "lock declares deps that were never installed" gap. A lock
126
+ // entry can carry a populated `dependencies` map whose referenced skills have no
127
+ // lock entry and no install directory — e.g. an entry authored by `publish`
128
+ // (which records `manifest.dependencies` but installs nothing), or any install
129
+ // that recorded a root skill while its transitive deps were skipped. In that
130
+ // state the lock and `.agents/skills/` are out of sync.
131
+ //
132
+ // The install skip-check ("already installed") uses this to decide whether a
133
+ // locked-and-present root is actually COMPLETE. If any transitive dependency is
134
+ // missing, the caller must decline the skip and re-resolve so the missing deps
135
+ // get backfilled — the lock must always match what is on disk.
136
+ //
137
+ // `resolve_dir(full_skill_name)` maps `owner/name` to its install directory.
138
+ // Cycle-safe via a visited set. A dependency that is referenced but absent from
139
+ // the lock, or locked but missing on disk, is reported in `missing`.
140
+ const dependency_closure_satisfied = (lock_data, skill, resolve_dir) => catch_errors('Failed to verify dependency closure', async () => {
141
+ const skills = (lock_data && lock_data.skills) || {}
142
+ const missing = []
143
+ const visited = new Set()
144
+ const queue = [skill]
145
+
146
+ while (queue.length > 0) {
147
+ const current = queue.shift()
148
+ if (visited.has(current)) continue
149
+ visited.add(current)
150
+
151
+ const entry = skills[current]
152
+ if (!entry) {
153
+ // Referenced as a dependency but never locked.
154
+ missing.push(current)
155
+ continue
156
+ }
157
+
158
+ const [, dir_present] = await file_exists(resolve_dir(current))
159
+ if (!dir_present) {
160
+ // Locked but not materialized on disk.
161
+ missing.push(current)
162
+ continue
163
+ }
164
+
165
+ for (const dep_name of Object.keys(entry.dependencies || {})) {
166
+ if (!visited.has(dep_name)) queue.push(dep_name)
167
+ }
168
+ }
169
+
170
+ return { satisfied: missing.length === 0, missing }
171
+ })
172
+
121
173
  // Plain-language description of a drift result, suitable for the principal-facing
122
174
  // table cell (status column). Returns null when ok or ahead.
123
175
  const describe_drift = (verify_result) => {
@@ -179,5 +231,6 @@ module.exports = {
179
231
  detect_ahead_state,
180
232
  describe_drift,
181
233
  classify_lock_disk,
182
- parse_changelog_top_version
234
+ parse_changelog_top_version,
235
+ dependency_closure_satisfied
183
236
  }
@@ -10,7 +10,8 @@ const {
10
10
  detect_ahead_state,
11
11
  describe_drift,
12
12
  classify_lock_disk,
13
- parse_changelog_top_version
13
+ parse_changelog_top_version,
14
+ dependency_closure_satisfied
14
15
  } = require('./verify')
15
16
 
16
17
  const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-verify-test-'))
@@ -276,6 +277,113 @@ describe('classify_lock_disk', () => {
276
277
  })
277
278
  })
278
279
 
280
+ describe('dependency_closure_satisfied', () => {
281
+ // A skill directory is "present" iff it exists on disk. We model the disk with a
282
+ // temp root and create a sub-dir per skill that is supposed to be installed.
283
+ const make_root = () => make_tmp()
284
+ const present = (root, skill) => {
285
+ const dir = path.join(root, skill.replace('/', '__'))
286
+ fs.mkdirSync(dir, { recursive: true })
287
+ return dir
288
+ }
289
+ const resolver = (root) => (skill) => path.join(root, skill.replace('/', '__'))
290
+
291
+ const lock = (skills) => ({ lockVersion: 2, skills })
292
+
293
+ it('is satisfied when the root and its whole locked dep closure are present on disk', async () => {
294
+ const root = make_root()
295
+ try {
296
+ present(root, 'acme/kit')
297
+ present(root, 'acme/dep-a')
298
+ present(root, 'acme/dep-b')
299
+ const data = lock({
300
+ 'acme/kit': { version: '1.0.0', dependencies: { 'acme/dep-a': '*', 'acme/dep-b': '*' } },
301
+ 'acme/dep-a': { version: '1.0.0', dependencies: {} },
302
+ 'acme/dep-b': { version: '1.0.0', dependencies: {} }
303
+ })
304
+ const [err, result] = await dependency_closure_satisfied(data, 'acme/kit', resolver(root))
305
+ assert.strictEqual(err, null)
306
+ assert.deepEqual(result, { satisfied: true, missing: [] })
307
+ } finally { cleanup(root) }
308
+ })
309
+
310
+ it('is NOT satisfied when a declared dependency has no lock entry (the kit-deps-never-installed bug)', async () => {
311
+ // This is the exact production shape: the kit is locked + on disk, its
312
+ // `dependencies` map is populated, but those deps were never resolved into
313
+ // their own lock entries and never landed on disk.
314
+ const root = make_root()
315
+ try {
316
+ present(root, 'acme/kit')
317
+ const data = lock({
318
+ 'acme/kit': { version: '1.0.0', dependencies: { 'acme/dep-a': '*', 'acme/dep-b': '*' } }
319
+ })
320
+ const [err, result] = await dependency_closure_satisfied(data, 'acme/kit', resolver(root))
321
+ assert.strictEqual(err, null)
322
+ assert.strictEqual(result.satisfied, false)
323
+ assert.deepEqual(result.missing.sort(), ['acme/dep-a', 'acme/dep-b'])
324
+ } finally { cleanup(root) }
325
+ })
326
+
327
+ it('is NOT satisfied when a dependency is locked but its directory is missing on disk', async () => {
328
+ const root = make_root()
329
+ try {
330
+ present(root, 'acme/kit')
331
+ // acme/dep-a is locked but NOT created on disk.
332
+ const data = lock({
333
+ 'acme/kit': { version: '1.0.0', dependencies: { 'acme/dep-a': '*' } },
334
+ 'acme/dep-a': { version: '1.0.0', dependencies: {} }
335
+ })
336
+ const [err, result] = await dependency_closure_satisfied(data, 'acme/kit', resolver(root))
337
+ assert.strictEqual(err, null)
338
+ assert.strictEqual(result.satisfied, false)
339
+ assert.deepEqual(result.missing, ['acme/dep-a'])
340
+ } finally { cleanup(root) }
341
+ })
342
+
343
+ it('walks transitively — a missing dep-of-a-dep still fails the closure', async () => {
344
+ const root = make_root()
345
+ try {
346
+ present(root, 'acme/kit')
347
+ present(root, 'acme/dep-a')
348
+ // acme/dep-a depends on acme/deep, which is neither locked nor on disk.
349
+ const data = lock({
350
+ 'acme/kit': { version: '1.0.0', dependencies: { 'acme/dep-a': '*' } },
351
+ 'acme/dep-a': { version: '1.0.0', dependencies: { 'acme/deep': '*' } }
352
+ })
353
+ const [err, result] = await dependency_closure_satisfied(data, 'acme/kit', resolver(root))
354
+ assert.strictEqual(err, null)
355
+ assert.strictEqual(result.satisfied, false)
356
+ assert.deepEqual(result.missing, ['acme/deep'])
357
+ } finally { cleanup(root) }
358
+ })
359
+
360
+ it('is cycle-safe (A → B → A) and does not loop forever', async () => {
361
+ const root = make_root()
362
+ try {
363
+ present(root, 'acme/a')
364
+ present(root, 'acme/b')
365
+ const data = lock({
366
+ 'acme/a': { version: '1.0.0', dependencies: { 'acme/b': '*' } },
367
+ 'acme/b': { version: '1.0.0', dependencies: { 'acme/a': '*' } }
368
+ })
369
+ const [err, result] = await dependency_closure_satisfied(data, 'acme/a', resolver(root))
370
+ assert.strictEqual(err, null)
371
+ assert.deepEqual(result, { satisfied: true, missing: [] })
372
+ } finally { cleanup(root) }
373
+ })
374
+
375
+ it('is satisfied for a leaf skill with no dependencies', async () => {
376
+ const root = make_root()
377
+ try {
378
+ present(root, 'acme/solo')
379
+ const data = lock({ 'acme/solo': { version: '1.0.0', dependencies: {} } })
380
+ const [err, result] = await dependency_closure_satisfied(data, 'acme/solo', resolver(root))
381
+ assert.strictEqual(err, null)
382
+ assert.deepEqual(result, { satisfied: true, missing: [] })
383
+ } finally { cleanup(root) }
384
+ })
385
+ })
386
+
279
387
  describe('describe_drift', () => {
280
388
  it('returns null for ok results', () => {
281
389
  assert.strictEqual(describe_drift({ ok: true }), null)
@@ -0,0 +1,172 @@
1
+ const path = require('path')
2
+ const { error: { catch_errors } } = require('puffy-core')
3
+ const { file_exists, read_json, ensure_dir, read_file, write_file } = require('../utils/fs')
4
+ const { skill_install_dir } = require('../config/paths')
5
+ const { read_skills_config, get_skill_config_entry } = require('./reader')
6
+ const { set_skill_config_entry, build_config_entry } = require('./writer')
7
+ const { question_prompt } = require('../utils/prompt')
8
+ const { SKILL_JSON } = require('../constants')
9
+
10
+ const to_posix = (p) => p.split(path.sep).join('/')
11
+
12
+ const coerce = (raw, type) => {
13
+ if (type === 'integer' || type === 'number') {
14
+ const n = Number(raw)
15
+ return Number.isNaN(n) ? raw : n
16
+ }
17
+ if (type === 'boolean') return /^(y|yes|true|1)$/i.test(raw)
18
+ return raw
19
+ }
20
+
21
+ const as_object = (value) => (value && typeof value === 'object' && !Array.isArray(value) ? value : null)
22
+
23
+ // Append missing `NAME=` lines to an env file, never touching lines that already exist —
24
+ // so a value the consumer already set is preserved on reinstall/update.
25
+ const ensure_env_names = (file_path, names) => catch_errors('Failed to scaffold env file', async () => {
26
+ const [, exists] = await file_exists(file_path)
27
+ let content = ''
28
+ if (exists) { const [, c] = await read_file(file_path); content = c || '' }
29
+
30
+ const present = new Set(content.split('\n').map(l => l.split('=')[0].trim()).filter(Boolean))
31
+ const additions = names.filter(n => !present.has(n)).map(n => `${n}=`)
32
+ if (!additions.length && exists) return
33
+
34
+ await ensure_dir(path.dirname(file_path))
35
+ const base = content.length && !content.endsWith('\n') ? content + '\n' : content
36
+ await write_file(file_path, `${base}${additions.join('\n')}${additions.length ? '\n' : ''}`)
37
+ })
38
+
39
+ const ensure_gitignore_entry = (root, pattern) => catch_errors('Failed to update .gitignore', async () => {
40
+ const gitignore = path.join(root, '.gitignore')
41
+ const [, exists] = await file_exists(gitignore)
42
+ let content = ''
43
+ if (exists) { const [, c] = await read_file(gitignore); content = c || '' }
44
+
45
+ const lines = content.split('\n').map(l => l.trim())
46
+ if (lines.includes(pattern)) return
47
+
48
+ const base = content.length && !content.endsWith('\n') ? content + '\n' : content
49
+ await write_file(gitignore, `${base}${pattern}\n`)
50
+ })
51
+
52
+ // A required secret is "unset" unless it is present with a non-empty value in ambient
53
+ // process.env or in the scaffolded .env file. Presence gates the fail-loud warning.
54
+ const secret_is_set = (env_abs, name) => catch_errors('Failed to check secret presence', async () => {
55
+ if (process.env[name] && process.env[name].trim() !== '') return true
56
+ const [, exists] = await file_exists(env_abs)
57
+ if (!exists) return false
58
+ const [, content] = await read_file(env_abs)
59
+ for (const line of (content || '').split('\n')) {
60
+ const idx = line.indexOf('=')
61
+ if (idx === -1) continue
62
+ if (line.slice(0, idx).trim() === name && line.slice(idx + 1).trim() !== '') return true
63
+ }
64
+ return false
65
+ })
66
+
67
+ // For every installed package that declares a `config`/`env` schema, resolve the consumer's
68
+ // choices and persist them: non-secret overrides → skills-config.json `config`; secret names →
69
+ // the gitignored .env (+ committed .env.example, name only); the pointer → `envFile`. Secret
70
+ // VALUES are never written anywhere by this function — build_config_entry structurally drops
71
+ // anything but envFile/config. Returns warnings naming required secrets still unset.
72
+ const configure_installed_skills = (packages, options = {}) => catch_errors('Failed to configure installed skills', async () => {
73
+ const { base_dir, config_root, interactive = false, only_required = false, ask = question_prompt } = options
74
+ const warnings = []
75
+
76
+ const [, existing_config_file] = await read_skills_config(config_root)
77
+
78
+ for (const pkg of packages || []) {
79
+ const key = pkg.skill
80
+ 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))
83
+ if (!manifest) continue
84
+
85
+ const config_schema = as_object(manifest.config)
86
+ const env_schema = as_object(manifest.env)
87
+ if (!config_schema && !env_schema) continue
88
+
89
+ // Existing consumer choices are preserved verbatim; we only ever ADD to them.
90
+ // `only_required` (update/reconcile) narrows prompting to newly-required fields
91
+ // absent from the existing entry, so unrelated values are never re-asked.
92
+ const existing_entry = get_skill_config_entry(existing_config_file, key)
93
+ const existing_overrides = as_object(existing_entry && existing_entry.config) || {}
94
+ const overrides = { ...existing_overrides }
95
+
96
+ const maybe_prompt = async (field, spec) => {
97
+ if (!interactive) return
98
+ const already_set = Object.prototype.hasOwnProperty.call(existing_overrides, field)
99
+ const is_required = spec && spec.required === true
100
+ if (already_set) return
101
+ if (only_required && !is_required) return
102
+ const def = spec && spec.default
103
+ const label = `${field}${spec && spec.description ? ` — ${spec.description}` : ''} [${def ?? ''}]:`
104
+ const answer = await ask(label)
105
+ if (answer !== '' && answer !== String(def ?? '')) overrides[field] = coerce(answer, spec && spec.type)
106
+ }
107
+
108
+ // config fields — prompt (per the rules above); store non-default answers.
109
+ for (const [field, spec] of Object.entries(config_schema || {})) {
110
+ await maybe_prompt(field, spec)
111
+ }
112
+
113
+ // env fields — secrets route to the .env; non-secret env is treated like config.
114
+ const secret_names = []
115
+ for (const [var_name, spec] of Object.entries(env_schema || {})) {
116
+ if (spec && spec.secret) {
117
+ secret_names.push({ name: var_name, required: spec.required === true })
118
+ continue
119
+ }
120
+ await maybe_prompt(var_name, spec)
121
+ }
122
+
123
+ let env_file_rel = (existing_entry && existing_entry.envFile) || null
124
+ if (secret_names.length) {
125
+ env_file_rel = env_file_rel || `./secrets/${name}.env`
126
+ // Resolve the (possibly consumer-chosen) envFile relative to the config root.
127
+ const env_abs = path.resolve(config_root, env_file_rel)
128
+ const [scaffold_err] = await ensure_env_names(env_abs, secret_names.map(s => s.name))
129
+ if (scaffold_err) throw scaffold_err[0]
130
+ const [example_err] = await ensure_env_names(path.join(config_root, '.env.example'), secret_names.map(s => s.name))
131
+ if (example_err) throw example_err[0]
132
+
133
+ const dir_rel = to_posix(path.relative(config_root, path.dirname(env_abs)))
134
+ const [gi_err] = await ensure_gitignore_entry(config_root, dir_rel ? `${dir_rel}/*.env` : '*.env')
135
+ if (gi_err) throw gi_err[0]
136
+
137
+ for (const secret of secret_names.filter(s => s.required)) {
138
+ 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}`)
140
+ }
141
+ }
142
+
143
+ // Fail-loud (§4.3.2): a required NON-secret config/env field with no value and no
144
+ // author default would be silently undefined at runtime. Surface it as a warning —
145
+ // the same discipline the secret loop above applies to required secrets.
146
+ const warn_required_unset = (field, spec) => {
147
+ if (!spec || spec.required !== true) return
148
+ if (spec.default !== undefined) return
149
+ 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.`)
151
+ }
152
+ for (const [field, spec] of Object.entries(config_schema || {})) warn_required_unset(field, spec)
153
+ for (const [var_name, spec] of Object.entries(env_schema || {})) {
154
+ if (spec && spec.secret) continue
155
+ warn_required_unset(var_name, spec)
156
+ }
157
+
158
+ const entry = build_config_entry({
159
+ ...(env_file_rel ? { envFile: env_file_rel } : {}),
160
+ ...(Object.keys(overrides).length ? { config: overrides } : {})
161
+ })
162
+
163
+ if (Object.keys(entry).length) {
164
+ const [set_err] = await set_skill_config_entry(config_root, key, entry)
165
+ if (set_err) throw set_err[0]
166
+ }
167
+ }
168
+
169
+ return { warnings }
170
+ })
171
+
172
+ module.exports = { configure_installed_skills }