happyskills 1.16.3 → 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,165 @@
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 { configure_installed_skills } = require('./configure')
9
+
10
+ const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-configure-'))
11
+
12
+ const write_skill = (base_dir, name, manifest) => {
13
+ const dir = path.join(base_dir, name)
14
+ fs.mkdirSync(dir, { recursive: true })
15
+ fs.writeFileSync(path.join(dir, 'skill.json'), JSON.stringify({ name, version: '1.0.0', ...manifest }, null, '\t'))
16
+ fs.writeFileSync(path.join(dir, 'SKILL.md'), `# ${name}\n`)
17
+ }
18
+
19
+ const read = (p) => fs.readFileSync(p, 'utf-8')
20
+
21
+ let base_dir, config_root
22
+ beforeEach(() => {
23
+ base_dir = make_tmp()
24
+ config_root = make_tmp()
25
+ })
26
+ afterEach(() => {
27
+ fs.rmSync(base_dir, { recursive: true, force: true })
28
+ fs.rmSync(config_root, { recursive: true, force: true })
29
+ })
30
+
31
+ const SLACK = {
32
+ config: { channel: { type: 'string', default: '#general', description: 'Channel' } },
33
+ env: {
34
+ SLACK_BOT_TOKEN: { required: true, secret: true, description: 'Slack token' },
35
+ DEPLOY_TIMEOUT: { required: false, secret: false, type: 'integer', default: 300 }
36
+ }
37
+ }
38
+
39
+ describe('configure_installed_skills — non-interactive scaffold', () => {
40
+ it('scaffolds env + writes an envFile pointer with NO secret value', async () => {
41
+ write_skill(base_dir, 'slack-notify', SLACK)
42
+ const [err, result] = await configure_installed_skills(
43
+ [{ skill: 'acme/slack-notify' }],
44
+ { base_dir, config_root, interactive: false }
45
+ )
46
+ assert.ifError(err)
47
+
48
+ const cfg = JSON.parse(read(path.join(config_root, 'skills-config.json')))
49
+ const entry = cfg['acme/slack-notify']
50
+ assert.strictEqual(entry.envFile, './secrets/slack-notify.env')
51
+ // no config overrides written in non-interactive mode
52
+ assert.ok(!entry.config || Object.keys(entry.config).length === 0)
53
+
54
+ // .env scaffolded (gitignored), .env.example committed with NAME only
55
+ assert.ok(fs.existsSync(path.join(config_root, 'secrets', 'slack-notify.env')))
56
+ assert.match(read(path.join(config_root, '.env.example')), /SLACK_BOT_TOKEN=/)
57
+ // no secret value anywhere in committed files
58
+ assert.ok(!read(path.join(config_root, '.env.example')).match(/SLACK_BOT_TOKEN=\S/))
59
+
60
+ // .gitignore ignores the env glob
61
+ assert.match(read(path.join(config_root, '.gitignore')), /secrets\/\*\.env/)
62
+
63
+ // warning names the required, unset secret
64
+ assert.ok(result.warnings.some(w => w.includes('SLACK_BOT_TOKEN')))
65
+ })
66
+
67
+ it('is idempotent — re-running yields byte-identical skills-config.json', async () => {
68
+ write_skill(base_dir, 'slack-notify', SLACK)
69
+ await configure_installed_skills([{ skill: 'acme/slack-notify' }], { base_dir, config_root, interactive: false })
70
+ const first = read(path.join(config_root, 'skills-config.json'))
71
+ await configure_installed_skills([{ skill: 'acme/slack-notify' }], { base_dir, config_root, interactive: false })
72
+ assert.strictEqual(read(path.join(config_root, 'skills-config.json')), first)
73
+ })
74
+
75
+ it('warns when a required NON-secret field (no default) is left unset — fail-loud, §4.3.2', async () => {
76
+ write_skill(base_dir, 'deployer', {
77
+ config: { api_url: { type: 'string', required: true, description: 'API base URL' } },
78
+ env: { TIMEOUT: { required: true, secret: false, type: 'integer' } }
79
+ })
80
+ const [err, result] = await configure_installed_skills(
81
+ [{ skill: 'acme/deployer' }],
82
+ { base_dir, config_root, interactive: false }
83
+ )
84
+ assert.ifError(err)
85
+ assert.ok(result.warnings.some(w => w.includes('api_url')), 'required non-secret config field must be surfaced')
86
+ assert.ok(result.warnings.some(w => w.includes('TIMEOUT')), 'required non-secret env field must be surfaced')
87
+ })
88
+
89
+ it('does NOT warn for a required field that has a default (the default is its value)', async () => {
90
+ write_skill(base_dir, 'has-default', {
91
+ config: { retries: { type: 'integer', required: true, default: 3 } }
92
+ })
93
+ const [err, result] = await configure_installed_skills(
94
+ [{ skill: 'acme/has-default' }],
95
+ { base_dir, config_root, interactive: false }
96
+ )
97
+ assert.ifError(err)
98
+ assert.ok(!result.warnings.some(w => w.includes('retries')))
99
+ })
100
+
101
+ it('writes nothing for a skill with no config/env block', async () => {
102
+ write_skill(base_dir, 'plain', {})
103
+ await configure_installed_skills([{ skill: 'acme/plain' }], { base_dir, config_root, interactive: false })
104
+ assert.ok(!fs.existsSync(path.join(config_root, 'skills-config.json')))
105
+ })
106
+ })
107
+
108
+ describe('configure_installed_skills — interactive overrides', () => {
109
+ it('writes only non-default answers to the config map, never the secret value', async () => {
110
+ write_skill(base_dir, 'slack-notify', SLACK)
111
+ const answers = { channel: '#deploys', DEPLOY_TIMEOUT: '600' }
112
+ const ask = async (msg) => {
113
+ const field = msg.split(/[\s—[]/)[0].trim()
114
+ return answers[field] ?? ''
115
+ }
116
+ const [err] = await configure_installed_skills(
117
+ [{ skill: 'acme/slack-notify' }],
118
+ { base_dir, config_root, interactive: true, ask }
119
+ )
120
+ assert.ifError(err)
121
+
122
+ const cfg = JSON.parse(read(path.join(config_root, 'skills-config.json')))
123
+ const entry = cfg['acme/slack-notify']
124
+ assert.strictEqual(entry.config.channel, '#deploys')
125
+ assert.strictEqual(entry.config.DEPLOY_TIMEOUT, 600) // coerced to integer
126
+ assert.ok(!JSON.stringify(cfg).includes('xoxb')) // never prompted/stored a secret value
127
+ })
128
+ })
129
+
130
+ describe('configure_installed_skills — reconcile on update (only_required)', () => {
131
+ it('re-prompts exactly the newly-required field and leaves existing values untouched', async () => {
132
+ // v1: only `channel` config. Consumer sets it.
133
+ write_skill(base_dir, 'slack-notify', {
134
+ config: { channel: { type: 'string', default: '#general', description: 'Channel' } }
135
+ })
136
+ await configure_installed_skills(
137
+ [{ skill: 'acme/slack-notify' }],
138
+ { base_dir, config_root, interactive: true, ask: async () => '#deploys' }
139
+ )
140
+
141
+ // v2: adds a new REQUIRED config field `retries`.
142
+ write_skill(base_dir, 'slack-notify', {
143
+ config: {
144
+ channel: { type: 'string', default: '#general', description: 'Channel' },
145
+ retries: { type: 'integer', required: true, description: 'Retry count' }
146
+ }
147
+ })
148
+
149
+ const prompted = []
150
+ const ask = async (msg) => {
151
+ prompted.push(msg.split(/[\s—[]/)[0].trim())
152
+ return '3'
153
+ }
154
+ await configure_installed_skills(
155
+ [{ skill: 'acme/slack-notify' }],
156
+ { base_dir, config_root, interactive: true, only_required: true, ask }
157
+ )
158
+
159
+ // Only the new required field was asked
160
+ assert.deepStrictEqual(prompted, ['retries'])
161
+ const entry = JSON.parse(read(path.join(config_root, 'skills-config.json')))['acme/slack-notify']
162
+ assert.strictEqual(entry.config.channel, '#deploys') // untouched
163
+ assert.strictEqual(entry.config.retries, 3) // merged, coerced
164
+ })
165
+ })
@@ -0,0 +1,27 @@
1
+ const { error: { catch_errors } } = require('puffy-core')
2
+ const { read_json } = require('../utils/fs')
3
+ const { skills_config_file_path, global_skills_config_file_path } = require('../config/paths')
4
+
5
+ // skills-config.json is a flat map of `owner/name` -> { envFile?, config? }. Unlike the
6
+ // lock file it has no envelope wrapper — the top-level keys ARE the skill keys. A missing
7
+ // file is not an error: it means no skill in this project has been configured yet.
8
+ const read_skills_config = (project_root) => catch_errors('Failed to read skills-config.json', async () => {
9
+ const config_path = skills_config_file_path(project_root)
10
+ const [errors, data] = await read_json(config_path)
11
+ if (errors) return null
12
+ return data
13
+ })
14
+
15
+ const read_global_skills_config = () => catch_errors('Failed to read global skills-config.json', async () => {
16
+ const config_path = global_skills_config_file_path()
17
+ const [errors, data] = await read_json(config_path)
18
+ if (errors) return null
19
+ return data
20
+ })
21
+
22
+ const get_skill_config_entry = (config_data, key) => {
23
+ if (!config_data || typeof config_data !== 'object') return null
24
+ return config_data[key] || null
25
+ }
26
+
27
+ module.exports = { read_skills_config, read_global_skills_config, get_skill_config_entry }
@@ -0,0 +1,126 @@
1
+ const path = require('path')
2
+ const { error: { catch_errors } } = require('puffy-core')
3
+ const { read_json, file_exists, read_file } = require('../utils/fs')
4
+ const { skills_dir, skill_install_dir, lock_root } = require('../config/paths')
5
+ const { read_skills_config, read_global_skills_config, get_skill_config_entry } = require('./reader')
6
+ const { SKILL_JSON } = require('../constants')
7
+
8
+ const as_object = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : null)
9
+
10
+ // project ⊕ global ⊕ default deep-merge; later argument wins on key collision.
11
+ const deep_merge = (a, b) => {
12
+ const out = { ...(as_object(a) || {}) }
13
+ for (const [k, v] of Object.entries(as_object(b) || {})) {
14
+ if (as_object(v) && as_object(out[k])) out[k] = deep_merge(out[k], v)
15
+ else out[k] = v
16
+ }
17
+ return out
18
+ }
19
+
20
+ // Author defaults = config field defaults + NON-secret env defaults. Secrets never carry a
21
+ // committed default (validator warns on it), so they are structurally excluded here too —
22
+ // the resolver can never surface a secret value even via a stray default.
23
+ const author_defaults = (manifest) => {
24
+ const defaults = {}
25
+ const config = as_object(manifest && manifest.config)
26
+ if (config) for (const [field, spec] of Object.entries(config)) {
27
+ if (spec && spec.default !== undefined) defaults[field] = spec.default
28
+ }
29
+ const env = as_object(manifest && manifest.env)
30
+ if (env) for (const [name, spec] of Object.entries(env)) {
31
+ if (spec && !spec.secret && spec.default !== undefined) defaults[name] = spec.default
32
+ }
33
+ return defaults
34
+ }
35
+
36
+ const required_secret_names = (manifest) => {
37
+ const env = as_object(manifest && manifest.env)
38
+ if (!env) return []
39
+ return Object.entries(env)
40
+ .filter(([, spec]) => spec && spec.secret === true && spec.required === true)
41
+ .map(([name]) => name)
42
+ }
43
+
44
+ // Read the installed skill.json — project scope first, then global. Best-effort: an
45
+ // unresolved manifest just means no author defaults / no declared secrets.
46
+ const find_installed_manifest = (name, project_root) => catch_errors('Failed to read installed skill.json', async () => {
47
+ const candidates = [
48
+ skill_install_dir(skills_dir(false, project_root), name),
49
+ skill_install_dir(skills_dir(true), name)
50
+ ]
51
+ for (const dir of candidates) {
52
+ const [, manifest] = await read_json(path.join(dir, SKILL_JSON))
53
+ if (manifest) return manifest
54
+ }
55
+ return null
56
+ })
57
+
58
+ // A required secret counts as present when it is a non-empty value in ambient process.env
59
+ // (which takes precedence at load time) OR a non-empty value in the resolved .env. The VALUE
60
+ // is read only to decide presence — it is never returned.
61
+ const secret_present = (name, env_abs_path) => catch_errors('Failed to check secret presence', async () => {
62
+ if (process.env[name] && process.env[name].trim() !== '') return true
63
+ if (!env_abs_path) return false
64
+ const [, exists] = await file_exists(env_abs_path)
65
+ if (!exists) return false
66
+ const [, content] = await read_file(env_abs_path)
67
+ for (const line of (content || '').split('\n')) {
68
+ const idx = line.indexOf('=')
69
+ if (idx === -1) continue
70
+ if (line.slice(0, idx).trim() === name && line.slice(idx + 1).trim() !== '') return true
71
+ }
72
+ return false
73
+ })
74
+
75
+ // The deterministic resolver behind `happyskills skills-config get`. Merges the cascade and
76
+ // reports required-secret NAMES + a present/absent boolean. NEVER reads-and-returns a secret
77
+ // value (§0 hard rule). `options.manifest` / `options.global_config` are injectable for tests.
78
+ const resolve_skill_config = (key, options = {}) => catch_errors('Failed to resolve skill config', async () => {
79
+ const { project_root = process.cwd() } = options
80
+ const name = key.split('/')[1]
81
+
82
+ const manifest = options.manifest !== undefined
83
+ ? options.manifest
84
+ : (await find_installed_manifest(name, project_root))[1]
85
+
86
+ const [, project_file] = await read_skills_config(project_root)
87
+ const global_file = options.global_config !== undefined
88
+ ? options.global_config
89
+ : (await read_global_skills_config())[1]
90
+
91
+ const project_entry = get_skill_config_entry(project_file, key)
92
+ const global_entry = get_skill_config_entry(global_file, key)
93
+
94
+ const merged = deep_merge(
95
+ deep_merge(author_defaults(manifest), as_object(global_entry && global_entry.config) || {}),
96
+ as_object(project_entry && project_entry.config) || {}
97
+ )
98
+
99
+ // envFile: whichever layer declared it wins (project first), resolved against THAT layer's
100
+ // root so a global pointer stays correct even when read from a project cwd.
101
+ let env_file_resolved = null
102
+ if (project_entry && project_entry.envFile) {
103
+ env_file_resolved = path.resolve(project_root, project_entry.envFile)
104
+ } else if (global_entry && global_entry.envFile) {
105
+ env_file_resolved = path.resolve(lock_root(true), global_entry.envFile)
106
+ }
107
+
108
+ const requiredSecrets = required_secret_names(manifest)
109
+ let secretsPresent = true
110
+ for (const secret of requiredSecrets) {
111
+ const [, present] = await secret_present(secret, env_file_resolved)
112
+ if (!present) { secretsPresent = false; break }
113
+ }
114
+
115
+ return {
116
+ config: merged,
117
+ // Absolute internally — the agent-facing command relativizes this to the caller's cwd so
118
+ // the emitted path is usable from wherever the agent runs and leaks no absolute local path.
119
+ envFile: env_file_resolved,
120
+ requiredSecrets,
121
+ secretsPresent,
122
+ manifestFound: !!manifest
123
+ }
124
+ })
125
+
126
+ module.exports = { resolve_skill_config, deep_merge, author_defaults, required_secret_names }
@@ -0,0 +1,112 @@
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 { resolve_skill_config, deep_merge, author_defaults } = require('./resolve')
9
+ const { set_skill_config_entry } = require('./writer')
10
+
11
+ const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-resolve-'))
12
+
13
+ const MANIFEST = {
14
+ name: 'slack-notify',
15
+ config: {
16
+ channel: { type: 'string', default: '#general' },
17
+ max_chars: { type: 'integer', default: 500 }
18
+ },
19
+ env: {
20
+ SLACK_BOT_TOKEN: { required: true, secret: true },
21
+ DEPLOY_TIMEOUT: { required: false, secret: false, type: 'integer', default: 300 }
22
+ }
23
+ }
24
+
25
+ let root
26
+ beforeEach(() => { root = make_tmp() })
27
+ afterEach(() => { fs.rmSync(root, { recursive: true, force: true }) })
28
+
29
+ describe('deep_merge + author_defaults', () => {
30
+ it('project wins over global wins over defaults', () => {
31
+ const merged = deep_merge(deep_merge({ a: 1, b: 1, c: 1 }, { b: 2, c: 2 }), { c: 3 })
32
+ assert.deepStrictEqual(merged, { a: 1, b: 2, c: 3 })
33
+ })
34
+
35
+ it('author_defaults pulls config defaults + NON-secret env defaults, never secret defaults', () => {
36
+ const d = author_defaults(MANIFEST)
37
+ assert.strictEqual(d.channel, '#general')
38
+ assert.strictEqual(d.max_chars, 500)
39
+ assert.strictEqual(d.DEPLOY_TIMEOUT, 300)
40
+ assert.ok(!('SLACK_BOT_TOKEN' in d))
41
+ })
42
+ })
43
+
44
+ describe('resolve_skill_config — cascade + secrets', () => {
45
+ it('merges project ⊕ global ⊕ author-default and returns required secret NAMES only', async () => {
46
+ await set_skill_config_entry(root, 'acme/slack-notify', {
47
+ envFile: './secrets/slack-notify.env',
48
+ config: { channel: '#deploys' } // project override
49
+ })
50
+
51
+ const [err, resolved] = await resolve_skill_config('acme/slack-notify', {
52
+ project_root: root,
53
+ manifest: MANIFEST,
54
+ global_config: { 'acme/slack-notify': { config: { max_chars: 900 } } } // global override
55
+ })
56
+ assert.ifError(err)
57
+
58
+ // project channel wins; global max_chars wins over default; DEPLOY_TIMEOUT falls to default
59
+ assert.strictEqual(resolved.config.channel, '#deploys')
60
+ assert.strictEqual(resolved.config.max_chars, 900)
61
+ assert.strictEqual(resolved.config.DEPLOY_TIMEOUT, 300)
62
+
63
+ // required secret NAME only — never a value
64
+ assert.deepStrictEqual(resolved.requiredSecrets, ['SLACK_BOT_TOKEN'])
65
+ assert.strictEqual(resolved.secretsPresent, false) // .env has no value
66
+
67
+ // resolver returns the ABSOLUTE envFile internally; the command relativizes it to the
68
+ // caller's cwd for output (see skills-config.test.js)
69
+ assert.strictEqual(resolved.envFile, path.resolve(root, 'secrets/slack-notify.env'))
70
+
71
+ // the whole resolved object never contains a secret value
72
+ assert.ok(!JSON.stringify(resolved).includes('xoxb'))
73
+ })
74
+
75
+ it('reports secretsPresent:true when the secret is set in the .env', async () => {
76
+ fs.mkdirSync(path.join(root, 'secrets'), { recursive: true })
77
+ fs.writeFileSync(path.join(root, 'secrets', 'slack-notify.env'), 'SLACK_BOT_TOKEN=xoxb-real\n')
78
+ await set_skill_config_entry(root, 'acme/slack-notify', { envFile: './secrets/slack-notify.env', config: {} })
79
+
80
+ const [err, resolved] = await resolve_skill_config('acme/slack-notify', {
81
+ project_root: root, manifest: MANIFEST, global_config: null
82
+ })
83
+ assert.ifError(err)
84
+ assert.strictEqual(resolved.secretsPresent, true)
85
+ // value is read for presence, but never surfaced
86
+ assert.ok(!JSON.stringify(resolved).includes('xoxb'))
87
+ })
88
+
89
+ it('NEVER surfaces a secret value even when the author wrongly gives a secret a default (§0 hard rule)', async () => {
90
+ const bad_manifest = {
91
+ name: 'leaky',
92
+ env: { API_KEY: { required: true, secret: true, default: 'sk-LEAKED-DEFAULT' } }
93
+ }
94
+ const [err, resolved] = await resolve_skill_config('acme/leaky', {
95
+ project_root: root, manifest: bad_manifest, global_config: null
96
+ })
97
+ assert.ifError(err)
98
+ assert.ok(!('API_KEY' in resolved.config), 'secret default must not appear in resolved config')
99
+ assert.ok(!JSON.stringify(resolved).includes('sk-LEAKED-DEFAULT'), 'secret value must never appear anywhere in the resolved object')
100
+ assert.deepStrictEqual(resolved.requiredSecrets, ['API_KEY'])
101
+ })
102
+
103
+ it('resolves to author defaults when no consumer config exists', async () => {
104
+ const [err, resolved] = await resolve_skill_config('acme/slack-notify', {
105
+ project_root: root, manifest: MANIFEST, global_config: null
106
+ })
107
+ assert.ifError(err)
108
+ assert.strictEqual(resolved.config.channel, '#general')
109
+ assert.strictEqual(resolved.envFile, null)
110
+ assert.strictEqual(resolved.secretsPresent, false) // required secret, none set
111
+ })
112
+ })
@@ -0,0 +1,92 @@
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 { read_skills_config, get_skill_config_entry } = require('./reader')
9
+ const { write_skills_config, upsert_skill_config, set_skill_config_entry, build_config_entry } = require('./writer')
10
+ const { SKILLS_CONFIG_FILE } = require('../constants')
11
+
12
+ const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-skills-config-'))
13
+ const read_raw = (root) => fs.readFileSync(path.join(root, SKILLS_CONFIG_FILE), 'utf-8')
14
+
15
+ let root
16
+ beforeEach(() => { root = make_tmp() })
17
+ afterEach(() => { fs.rmSync(root, { recursive: true, force: true }) })
18
+
19
+ describe('skills_config reader', () => {
20
+ it('returns null when the file does not exist', async () => {
21
+ const [err, data] = await read_skills_config(root)
22
+ assert.ifError(err)
23
+ assert.strictEqual(data, null)
24
+ })
25
+
26
+ it('reads back a written entry', async () => {
27
+ await set_skill_config_entry(root, 'acme/slack-notify', { envFile: './secrets/x.env', config: { channel: '#deploys' } })
28
+ const [err, data] = await read_skills_config(root)
29
+ assert.ifError(err)
30
+ const entry = get_skill_config_entry(data, 'acme/slack-notify')
31
+ assert.strictEqual(entry.envFile, './secrets/x.env')
32
+ assert.deepStrictEqual(entry.config, { channel: '#deploys' })
33
+ })
34
+ })
35
+
36
+ describe('skills_config writer — key-scoped writes', () => {
37
+ it('adding skill B leaves skill A byte-identical', async () => {
38
+ await set_skill_config_entry(root, 'acme/aaa', { envFile: './secrets/a.env', config: { x: 1 } })
39
+ const raw_a_before = read_raw(root)
40
+ const block_a_before = JSON.stringify(JSON.parse(raw_a_before)['acme/aaa'])
41
+
42
+ await set_skill_config_entry(root, 'acme/bbb', { envFile: './secrets/b.env', config: { y: 2 } })
43
+ const parsed = JSON.parse(read_raw(root))
44
+ assert.strictEqual(JSON.stringify(parsed['acme/aaa']), block_a_before)
45
+ assert.ok(parsed['acme/bbb'])
46
+ })
47
+
48
+ it('produces sorted, byte-stable output across two identical writes', async () => {
49
+ await set_skill_config_entry(root, 'acme/zzz', { envFile: './z.env', config: { b: 2, a: 1 } })
50
+ await set_skill_config_entry(root, 'acme/aaa', { envFile: './a.env', config: { d: 4, c: 3 } })
51
+ const first = read_raw(root)
52
+
53
+ // Top-level keys sorted: acme/aaa before acme/zzz
54
+ assert.ok(first.indexOf('acme/aaa') < first.indexOf('acme/zzz'))
55
+ // Nested config keys sorted too
56
+ assert.ok(first.indexOf('"c"') < first.indexOf('"d"'))
57
+
58
+ // Re-writing the same entry yields identical bytes
59
+ await set_skill_config_entry(root, 'acme/aaa', { envFile: './a.env', config: { d: 4, c: 3 } })
60
+ assert.strictEqual(read_raw(root), first)
61
+ })
62
+
63
+ it('never persists a secret value — build_config_entry drops everything but envFile and config', () => {
64
+ const entry = build_config_entry({
65
+ envFile: './secrets/x.env',
66
+ config: { channel: '#deploys' },
67
+ SLACK_BOT_TOKEN: 'xoxb-super-secret',
68
+ secrets: { SLACK_BOT_TOKEN: 'xoxb-super-secret' }
69
+ })
70
+ assert.deepStrictEqual(Object.keys(entry).sort(), ['config', 'envFile'])
71
+ assert.ok(!JSON.stringify(entry).includes('xoxb-super-secret'))
72
+ })
73
+
74
+ it('upsert with null removes the key', () => {
75
+ const next = upsert_skill_config({ 'acme/a': { config: {} }, 'acme/b': { config: {} } }, 'acme/a', null)
76
+ assert.deepStrictEqual(Object.keys(next), ['acme/b'])
77
+ })
78
+
79
+ it('refuses to clobber an existing but corrupt skills-config.json (data-loss guard)', async () => {
80
+ fs.writeFileSync(path.join(root, SKILLS_CONFIG_FILE), '{ this is not valid json ')
81
+ const [err] = await set_skill_config_entry(root, 'acme/new', { config: { x: 1 } })
82
+ assert.ok(err, 'a corrupt existing file must surface an error, not be silently overwritten')
83
+ // original corrupt content is preserved, not replaced
84
+ assert.strictEqual(read_raw(root), '{ this is not valid json ')
85
+ })
86
+
87
+ it('write_skills_config sorts keys deeply', async () => {
88
+ await write_skills_config(root, { 'z/z': { config: { b: 1, a: 2 } }, 'a/a': { envFile: './a.env' } })
89
+ const raw = read_raw(root)
90
+ assert.ok(raw.indexOf('a/a') < raw.indexOf('z/z'))
91
+ })
92
+ })
@@ -0,0 +1,59 @@
1
+ const { error: { catch_errors } } = require('puffy-core')
2
+ const { atomic_write_json, read_json, file_exists } = require('../utils/fs')
3
+ const { skills_config_file_path } = require('../config/paths')
4
+
5
+ // Deterministic serialization: recursively sort object keys so concurrent git additions
6
+ // merge cleanly and re-writing an unchanged entry is byte-stable. Arrays and scalars pass
7
+ // through untouched.
8
+ const sort_keys_deep = (value) => {
9
+ if (Array.isArray(value)) return value.map(sort_keys_deep)
10
+ if (value === null || typeof value !== 'object') return value
11
+ const out = {}
12
+ for (const key of Object.keys(value).sort()) out[key] = sort_keys_deep(value[key])
13
+ return out
14
+ }
15
+
16
+ // A skills-config.json entry carries ONLY a non-secret `envFile` pointer and non-secret
17
+ // `config` overrides. This helper is the structural guard that a secret value can never
18
+ // leak into the committed file — anything but envFile/config is dropped here.
19
+ const build_config_entry = ({ envFile, config } = {}) => {
20
+ const entry = {}
21
+ if (envFile !== undefined) entry.envFile = envFile
22
+ if (config !== undefined) entry.config = config
23
+ return entry
24
+ }
25
+
26
+ const upsert_skill_config = (existing, key, entry) => {
27
+ const out = { ...(existing || {}) }
28
+ if (entry === null) delete out[key]
29
+ else out[key] = entry
30
+ return out
31
+ }
32
+
33
+ const write_skills_config = (project_root, config_data) => catch_errors('Failed to write skills-config.json', async () => {
34
+ const config_path = skills_config_file_path(project_root)
35
+ const [errors] = await atomic_write_json(config_path, sort_keys_deep(config_data || {}))
36
+ if (errors) throw errors[0]
37
+ })
38
+
39
+ // Read-modify-write a single `owner/name` subtree: read the current file, replace only that
40
+ // key's block (leaving every other skill's block untouched), and atomically re-serialize with
41
+ // sorted keys. Pass `entry: null` to remove the key.
42
+ const set_skill_config_entry = (project_root, key, entry) => catch_errors('Failed to set skills-config.json entry', async () => {
43
+ const config_path = skills_config_file_path(project_root)
44
+ const [read_errors, existing] = await read_json(config_path)
45
+ // Distinguish "no file yet" (start fresh) from "file exists but is corrupt". Clobbering a
46
+ // corrupt file would silently discard every OTHER skill's block — the exact opposite of the
47
+ // key-scoped guarantee. On a corrupt existing file, fail loud instead of overwriting.
48
+ if (read_errors) {
49
+ const [, exists] = await file_exists(config_path)
50
+ if (exists) throw new Error(`skills-config.json exists but could not be parsed at ${config_path} — refusing to overwrite it. Fix or remove the file, then retry.`)
51
+ }
52
+ const current = read_errors ? {} : (existing || {})
53
+ const next = upsert_skill_config(current, key, entry)
54
+ const [write_errors] = await atomic_write_json(config_path, sort_keys_deep(next))
55
+ if (write_errors) throw write_errors[0]
56
+ return next
57
+ })
58
+
59
+ module.exports = { write_skills_config, upsert_skill_config, set_skill_config_entry, build_config_entry, sort_keys_deep }
@@ -13,4 +13,19 @@ const confirm_prompt = (message) => new Promise((resolve) => {
13
13
  })
14
14
  })
15
15
 
16
- module.exports = { confirm_prompt }
16
+ // Free-text prompt. Returns the trimmed answer, or '' when there is no TTY (so callers
17
+ // fall back to the schema default). Writes to stderr to keep stdout envelope-clean.
18
+ const question_prompt = (message) => new Promise((resolve) => {
19
+ if (!process.stdin.isTTY) {
20
+ resolve('')
21
+ return
22
+ }
23
+
24
+ const rl = createInterface({ input: process.stdin, output: process.stderr })
25
+ rl.question(`${message} `, (answer) => {
26
+ rl.close()
27
+ resolve(answer.trim())
28
+ })
29
+ })
30
+
31
+ module.exports = { confirm_prompt, question_prompt }