happyskills 1.16.3 → 1.18.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,123 @@
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 { cleanup_uninstalled_configs, strip_env_var_lines, read_skill_secret_names } = require('./cleanup')
9
+
10
+ describe('strip_env_var_lines (pure)', () => {
11
+ it('removes only the named assignment lines, keeps the rest', () => {
12
+ const content = 'SLACK_BOT_TOKEN=xoxb-123\nDB_URL=postgres://x\n'
13
+ const r = strip_env_var_lines(content, ['SLACK_BOT_TOKEN'])
14
+ assert.equal(r.changed, true)
15
+ assert.ok(r.next.includes('DB_URL=postgres://x'))
16
+ assert.ok(!r.next.includes('SLACK_BOT_TOKEN'))
17
+ assert.equal(r.all_blank, false)
18
+ })
19
+
20
+ it('reports all_blank when nothing but whitespace remains', () => {
21
+ const r = strip_env_var_lines('SLACK_BOT_TOKEN=xoxb\n', ['SLACK_BOT_TOKEN'])
22
+ assert.equal(r.changed, true)
23
+ assert.equal(r.all_blank, true)
24
+ })
25
+
26
+ it('no-op when no named line is present', () => {
27
+ const r = strip_env_var_lines('DB_URL=x\n', ['NOPE'])
28
+ assert.equal(r.changed, false)
29
+ })
30
+ })
31
+
32
+ // Build a fake project: base_dir holds installed skill dirs (each with skill.json),
33
+ // config_root holds skills-config.json + secrets/*.env + .env.example.
34
+ const scaffold = (root, { skills, config, envFiles, example }) => {
35
+ const base_dir = path.join(root, 'skills')
36
+ for (const [key, manifest] of Object.entries(skills)) {
37
+ const dir = path.join(base_dir, key.split('/')[1])
38
+ fs.mkdirSync(dir, { recursive: true })
39
+ fs.writeFileSync(path.join(dir, 'skill.json'), JSON.stringify(manifest))
40
+ }
41
+ fs.writeFileSync(path.join(root, 'skills-config.json'), JSON.stringify(config))
42
+ fs.mkdirSync(path.join(root, 'secrets'), { recursive: true })
43
+ for (const [rel, body] of Object.entries(envFiles || {})) {
44
+ fs.writeFileSync(path.join(root, rel), body)
45
+ }
46
+ if (example !== undefined) fs.writeFileSync(path.join(root, '.env.example'), example)
47
+ return base_dir
48
+ }
49
+
50
+ describe('cleanup_uninstalled_configs', () => {
51
+ let root
52
+ beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'hs-cfg-cleanup-')) })
53
+ afterEach(() => { fs.rmSync(root, { recursive: true, force: true }) })
54
+
55
+ it('removes the uninstalled skill config entry and deletes its now-empty secrets file', async () => {
56
+ const base_dir = scaffold(root, {
57
+ skills: {
58
+ 'acme/slack': { name: 'slack', env: { SLACK_BOT_TOKEN: { secret: true, required: true } } },
59
+ 'acme/keep': { name: 'keep', env: { DB_URL: { secret: true } } }
60
+ },
61
+ config: {
62
+ 'acme/slack': { envFile: './secrets/slack.env', config: { channel: '#ops' } },
63
+ 'acme/keep': { envFile: './secrets/keep.env' }
64
+ },
65
+ envFiles: { 'secrets/slack.env': 'SLACK_BOT_TOKEN=xoxb-123\n', 'secrets/keep.env': 'DB_URL=postgres://x\n' },
66
+ example: 'SLACK_BOT_TOKEN=\nDB_URL=\n'
67
+ })
68
+
69
+ const [err] = await cleanup_uninstalled_configs({
70
+ config_root: root,
71
+ base_dir,
72
+ removed: [{ key: 'acme/slack', name: 'slack', secret_names: ['SLACK_BOT_TOKEN'] }],
73
+ surviving_keys: ['acme/keep']
74
+ })
75
+ assert.ifError(err)
76
+
77
+ const cfg = JSON.parse(fs.readFileSync(path.join(root, 'skills-config.json'), 'utf-8'))
78
+ assert.ok(!cfg['acme/slack'], 'removed skill entry should be gone')
79
+ assert.ok(cfg['acme/keep'], 'surviving skill entry should remain')
80
+ assert.equal(fs.existsSync(path.join(root, 'secrets/slack.env')), false, 'empty secrets file should be deleted')
81
+ assert.equal(fs.existsSync(path.join(root, 'secrets/keep.env')), true, 'other skill secrets file untouched')
82
+ const example = fs.readFileSync(path.join(root, '.env.example'), 'utf-8')
83
+ assert.ok(!example.includes('SLACK_BOT_TOKEN'), 'removed secret name stripped from .env.example')
84
+ assert.ok(example.includes('DB_URL'), 'surviving secret name kept in .env.example')
85
+ })
86
+
87
+ it('preserves a secret still declared by a surviving skill (shared envFile)', async () => {
88
+ const base_dir = scaffold(root, {
89
+ skills: {
90
+ 'acme/slack': { name: 'slack', env: { SLACK_BOT_TOKEN: { secret: true }, SHARED: { secret: true } } },
91
+ 'acme/keep': { name: 'keep', env: { SHARED: { secret: true } } }
92
+ },
93
+ config: {
94
+ 'acme/slack': { envFile: './secrets/shared.env' },
95
+ 'acme/keep': { envFile: './secrets/shared.env' }
96
+ },
97
+ envFiles: { 'secrets/shared.env': 'SLACK_BOT_TOKEN=xoxb\nSHARED=zzz\n' }
98
+ })
99
+
100
+ const [err] = await cleanup_uninstalled_configs({
101
+ config_root: root,
102
+ base_dir,
103
+ removed: [{ key: 'acme/slack', name: 'slack', secret_names: ['SLACK_BOT_TOKEN', 'SHARED'] }],
104
+ surviving_keys: ['acme/keep']
105
+ })
106
+ assert.ifError(err)
107
+
108
+ const env = fs.readFileSync(path.join(root, 'secrets/shared.env'), 'utf-8')
109
+ assert.ok(!env.includes('SLACK_BOT_TOKEN'), 'unshared secret stripped')
110
+ assert.ok(env.includes('SHARED=zzz'), 'secret shared with a surviving skill is preserved')
111
+ })
112
+
113
+ it('read_skill_secret_names returns the manifest secret var names', async () => {
114
+ const base_dir = scaffold(root, {
115
+ skills: { 'acme/slack': { name: 'slack', env: { SLACK_BOT_TOKEN: { secret: true }, PUBLIC: { secret: false } } } },
116
+ config: {},
117
+ envFiles: {}
118
+ })
119
+ const [err, names] = await read_skill_secret_names(base_dir, 'acme/slack')
120
+ assert.ifError(err)
121
+ assert.deepEqual(names, ['SLACK_BOT_TOKEN'])
122
+ })
123
+ })
@@ -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 }
@@ -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 }