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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.17.0] - 2026-07-05
11
+
12
+ ### Added
13
+
14
+ - Add consumer-side per-skill configuration via a project-root `skills-config.json` (a committed sibling of `skills-lock.json`, keyed by `owner/name`). A skill author declares optional `config` (typed, defaulted non-secret tunables) and `env` (an environment-variable contract, with `required: true` as the activation gate and `secret: true` for credentials) blocks in `skill.json`; the consumer's overrides and an `envFile` pointer live in `skills-config.json`. Secret **values** are never written to any committed file.
15
+ - Add `happyskills skills-config get <owner/name>` — a deterministic resolver that deep-merges the project `skills-config.json`, the global `~/.agents/skills-config.json`, and the skill's author defaults (project wins) and returns the effective non-secret config, the `envFile` path (relative to the caller's cwd), and required-secret **names** plus a `secretsPresent` boolean — never a secret value. Resolves from a subdirectory by walking up to the nearest `skills-config.json`/`skills-lock.json` (stopping at a `.git` boundary); reads are CLI-preferred, plain-file-fallback.
16
+ - Prompt for a configurable skill's declared `config`/`env` on `install` and `update` (interactive terminal only — `--json`, `--yes`, non-TTY, and lockfile/manifest reinstalls scaffold silently). Secrets are routed to a git-ignored `./secrets/<skill>.env` with a committed `.env.example` (names only) and a `.gitignore` entry; non-secret overrides are written to `skills-config.json`. `update` re-prompts only newly-required fields and leaves existing values untouched; any required-but-unset field is surfaced as a warning.
17
+ - Validate the `skill.json` `config`/`env` schema in `happyskills validate` (unknown types, non-boolean `required`/`secret` flags, a secret carrying a committed default, an uncompilable `validate` regex, and case-only env-name collisions).
18
+
10
19
  ## [1.16.3] - 2026-07-03
11
20
 
12
21
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "1.16.3",
3
+ "version": "1.17.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)",
@@ -200,12 +200,18 @@ const run = (args) => catch_errors('Install failed', async () => {
200
200
  }
201
201
  }
202
202
 
203
+ // Prompt for skill config only on a direct, interactive install — never on --json,
204
+ // --yes, or a non-TTY invocation (spec 260704-01 §4.3.1 rule 4). The no-arg / lock /
205
+ // manifest reinstall paths use base_options (which omits prompt_config) so they never
206
+ // prompt (spec §6 #8).
207
+ const prompt_config = !!process.stdin.isTTY && !base_options.yes && !args.flags.json
208
+
203
209
  const results = []
204
210
  const failures = []
205
211
  for (const { skill, version: inline_version } of parsed) {
206
212
  const version = flag_version || inline_version
207
213
  emit_analytics('install.started', { cli_version: CLI_VERSION })
208
- const [errors, result] = await install(skill, { ...base_options, version })
214
+ const [errors, result] = await install(skill, { ...base_options, version, prompt_config })
209
215
  if (errors) {
210
216
  const chain = errors?.map ? errors.map(x => x?.message).filter(Boolean) : [errors?.message || String(errors)]
211
217
  const msg = chain[chain.length - 1] || chain[0] || 'Unknown error'
@@ -0,0 +1,140 @@
1
+ 'use strict'
2
+ // happyskills skills-config get <owner/name> — spec 260704-01 Tier 3 §4.4.
3
+ //
4
+ // The deterministic resolver for consumer-side skill configuration. Reads the
5
+ // project skills-config.json ⊕ global ~/.agents/skills-config.json ⊕ the installed
6
+ // skill.json author defaults, deep-merges them (project winning), and returns the
7
+ // resolved NON-secret config, the resolved envFile path, the names of required
8
+ // secrets, and a present/absent boolean.
9
+ //
10
+ // It NEVER reads-and-returns a secret VALUE (§0 hard rule) — only names and a
11
+ // boolean. The agent does the merge via this command so it never hand-parses or
12
+ // hallucinates config; a CLI-less context falls back to reading the file directly
13
+ // (see the canonical SKILL.md snippet in happyskills-design).
14
+
15
+ const path = require('path')
16
+ const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
17
+ const { EXIT_CODES } = require('../constants')
18
+ const { print_help, print_info, print_table } = require('../ui/output')
19
+ const { emit_envelope } = require('../ui/envelope')
20
+ const { exit_with_error, UsageError } = require('../utils/errors')
21
+ const { find_config_root } = require('../config/paths')
22
+ const { resolve_skill_config } = require('../skills_config/resolve')
23
+
24
+ // Emit envFile as a forward-slash path relative to the caller's cwd — usable directly by the
25
+ // agent wherever it runs, and never an absolute local path (which would leak the home dir).
26
+ const to_cwd_relative = (abs) => {
27
+ if (!abs) return null
28
+ const rel = path.relative(process.cwd(), abs).split(path.sep).join('/')
29
+ return rel.startsWith('.') ? rel : `./${rel}`
30
+ }
31
+
32
+ const HELP_TEXT = `Usage: happyskills skills-config get <owner/name> [options]
33
+
34
+ Resolve the effective configuration for an installed skill. Deep-merges the
35
+ project skills-config.json, the global ~/.agents/skills-config.json, and the
36
+ skill's own author defaults (project winning) and reports the required secret
37
+ names plus whether they are set — never the secret values themselves.
38
+
39
+ Arguments:
40
+ owner/name The skill to resolve (e.g. acme/slack-notify)
41
+
42
+ Options:
43
+ --json Output as JSON envelope (default for non-TTY callers)
44
+
45
+ Examples:
46
+ happyskills skills-config get acme/slack-notify --json`
47
+
48
+ const run = (args) => catch_errors('skills-config failed', async () => {
49
+ if (args.flags._show_help) {
50
+ print_help(HELP_TEXT)
51
+ return process.exit(EXIT_CODES.SUCCESS)
52
+ }
53
+
54
+ const subcommand = args._[0]
55
+ const key = args._[1]
56
+
57
+ if (subcommand !== 'get') {
58
+ throw new UsageError(`Unknown skills-config subcommand "${subcommand || ''}". Expected: get <owner/name>.`)
59
+ }
60
+ if (!key || !key.includes('/')) {
61
+ throw new UsageError(`Skill must be in owner/name format (e.g. acme/slack-notify). Got: ${key || '(none)'}`)
62
+ }
63
+
64
+ // Walk up to the nearest project marker so the command works from a subdirectory —
65
+ // the same root the CLI-less fallback resolves (spec 260704-01 §6 #7).
66
+ const project_root = find_config_root()
67
+ const [resolve_err, resolved] = await resolve_skill_config(key, { project_root })
68
+ if (resolve_err) throw e(`Failed to resolve config for ${key}`, resolve_err)
69
+
70
+ const env_file = to_cwd_relative(resolved.envFile)
71
+
72
+ const data = {
73
+ skill: key,
74
+ config: resolved.config,
75
+ envFile: env_file,
76
+ requiredSecrets: resolved.requiredSecrets,
77
+ secretsPresent: resolved.secretsPresent
78
+ }
79
+
80
+ const warnings = []
81
+ if (!resolved.manifestFound) {
82
+ warnings.push(`${key} is not installed — returning consumer overrides without author defaults.`)
83
+ }
84
+ if (resolved.requiredSecrets.length && !resolved.secretsPresent) {
85
+ warnings.push(`Required secret(s) not set: ${resolved.requiredSecrets.join(', ')}${env_file ? ` — set them in ${env_file}` : ''}.`)
86
+ }
87
+
88
+ if (args.flags.json) {
89
+ emit_envelope({
90
+ data,
91
+ next_step: { kind: 'continuation', action: 'present_to_user' },
92
+ warnings
93
+ })
94
+ return
95
+ }
96
+
97
+ print_info(`Resolved config for ${key}:`)
98
+ const rows = Object.entries(resolved.config).map(([k, v]) => [k, String(v)])
99
+ if (rows.length) print_table(['key', 'value'], rows)
100
+ else print_info(' (no config — using author defaults)')
101
+ if (env_file) print_info(`envFile: ${env_file}`)
102
+ if (resolved.requiredSecrets.length) {
103
+ print_info(`required secrets: ${resolved.requiredSecrets.join(', ')} — ${resolved.secretsPresent ? 'all set' : 'NOT all set'}`)
104
+ }
105
+ for (const w of warnings) print_info(`⚠ ${w}`)
106
+ }).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
107
+
108
+ const schema = {
109
+ name: 'skills-config',
110
+ audience: 'consumer',
111
+ purpose: 'Resolve the effective configuration for an installed skill (project ⊕ global ⊕ author defaults), reporting required secret names and whether they are set — never secret values.',
112
+ mutation: false,
113
+ interactive_in_text_mode: false,
114
+ input: {
115
+ positional: [
116
+ { name: 'subcommand', required: true, type: 'string', description: 'Currently only "get"' },
117
+ { name: 'owner/name', required: true, type: 'string', description: 'The skill to resolve (owner/name)' }
118
+ ],
119
+ flags: [
120
+ { name: 'json', type: 'boolean', default: false, description: 'Output as JSON envelope' }
121
+ ]
122
+ },
123
+ output: {
124
+ data_shape: {
125
+ skill: 'string',
126
+ config: 'object',
127
+ envFile: 'string|null',
128
+ requiredSecrets: 'string[]',
129
+ secretsPresent: 'boolean'
130
+ }
131
+ },
132
+ errors: [
133
+ { code: 'USAGE_ERROR', next_step: { kind: 'routing', action: 'discover_schema' } }
134
+ ],
135
+ examples: [
136
+ 'happyskills skills-config get acme/slack-notify --json'
137
+ ]
138
+ }
139
+
140
+ module.exports = { run, schema }
@@ -118,7 +118,10 @@ const run_scope = (scope_is_global, ctx) => catch_errors('Update scope failed',
118
118
  // ─── --force path: skip the version check, re-install everything ─────────
119
119
  if (force) {
120
120
  print_header()
121
- const options = { global: scope_is_global, fresh: true, force: true, agents: agents_flag || undefined, project_root }
121
+ // Reconcile skills-config.json on update: prompt only for newly-required fields
122
+ // absent from the existing entry, leaving other values untouched (spec 260704-01 §4.3.2).
123
+ const config_reconcile = { prompt_config: !!process.stdin.isTTY && !json_mode && !auto_yes, config_only_required: true }
124
+ const options = { global: scope_is_global, fresh: true, force: true, agents: agents_flag || undefined, project_root, ...config_reconcile }
122
125
  const updated = []
123
126
  for (const [name, data] of candidates) {
124
127
  const before_version = data?.version || null
@@ -289,7 +292,8 @@ const run_scope = (scope_is_global, ctx) => catch_errors('Update scope failed',
289
292
  }
290
293
 
291
294
  // Install only the outdated, non-modified skills
292
- const options = { global: scope_is_global, fresh: true, agents: agents_flag || undefined, project_root }
295
+ const config_reconcile = { prompt_config: !!process.stdin.isTTY && !json_mode && !auto_yes, config_only_required: true }
296
+ const options = { global: scope_is_global, fresh: true, agents: agents_flag || undefined, project_root, ...config_reconcile }
293
297
  const updated = []
294
298
  const update_errors = []
295
299
 
@@ -1,6 +1,7 @@
1
1
  const fs = require('fs')
2
2
  const path = require('path')
3
3
  const os = require('os')
4
+ const { SKILLS_CONFIG_FILE, LOCK_FILE } = require('../constants')
4
5
 
5
6
  const home_dir = os.homedir()
6
7
 
@@ -44,10 +45,34 @@ const lock_root = (is_global, project_root) => {
44
45
 
45
46
  const lock_file_path = (project_root = process.cwd()) => path.join(project_root, 'skills-lock.json')
46
47
 
48
+ // Consumer-side per-skill config (spec 260704-01). Sibling of skills-lock.json at the
49
+ // project root; the global copy is co-located with the global lock root (~/.agents),
50
+ // derived from lock_root so it tracks wherever the lock actually lives.
51
+ const skills_config_file_path = (project_root = process.cwd()) => path.join(project_root, SKILLS_CONFIG_FILE)
52
+
53
+ const global_skills_config_file_path = () => path.join(lock_root(true), SKILLS_CONFIG_FILE)
54
+
47
55
  const skill_install_dir = (base_skills_dir, name) => assert_within(base_skills_dir, path.join(base_skills_dir, name))
48
56
 
49
57
  const find_project_root = (start_dir = process.cwd()) => path.resolve(start_dir)
50
58
 
59
+ // Config read-path root discovery (spec 260704-01 §6 #7). Unlike find_project_root (cwd-only,
60
+ // used by install/publish/etc.), the config RESOLVER walks upward for the nearest project marker
61
+ // — skills-config.json or skills-lock.json — stopping at a .git boundary, so `skills-config get`
62
+ // finds the same root a CLI-less agent would via the canonical SKILL.md fallback. Falls back to
63
+ // the start dir when no marker is found (preserving today's behavior for a bare directory).
64
+ const find_config_root = (start_dir = process.cwd()) => {
65
+ let dir = path.resolve(start_dir)
66
+ // eslint-disable-next-line no-constant-condition
67
+ while (true) {
68
+ if (fs.existsSync(path.join(dir, SKILLS_CONFIG_FILE)) || fs.existsSync(path.join(dir, LOCK_FILE))) return dir
69
+ if (fs.existsSync(path.join(dir, '.git'))) return dir
70
+ const parent = path.dirname(dir)
71
+ if (parent === dir) return path.resolve(start_dir)
72
+ dir = parent
73
+ }
74
+ }
75
+
51
76
  const agent_skills_dir = (agent, global = false, project_root) => {
52
77
  if (global) return path.join(home_dir, agent.global_skills_dir)
53
78
  return path.join(project_root || process.cwd(), agent.skills_dir)
@@ -70,8 +95,11 @@ module.exports = {
70
95
  install_lock_path,
71
96
  lock_root,
72
97
  lock_file_path,
98
+ skills_config_file_path,
99
+ global_skills_config_file_path,
73
100
  skill_install_dir,
74
101
  find_project_root,
102
+ find_config_root,
75
103
  agent_skills_dir,
76
104
  agent_skill_install_dir,
77
105
  assert_within
@@ -144,6 +144,42 @@ describe('paths', () => {
144
144
  })
145
145
  })
146
146
 
147
+ // spec 260704-01 §6 #7 — the config read path walks up to the nearest
148
+ // skills-config.json / skills-lock.json, stopping at a .git boundary, so
149
+ // `skills-config get` resolves the same root the CLI-less fallback would.
150
+ describe('find_config_root (walk-up)', () => {
151
+ let root
152
+ beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'hs-find-config-')) })
153
+ afterEach(() => { fs.rmSync(root, { recursive: true, force: true }) })
154
+
155
+ it('returns the nearest ancestor holding skills-config.json', () => {
156
+ fs.writeFileSync(path.join(root, 'skills-config.json'), '{}')
157
+ const sub = path.join(root, 'a', 'b')
158
+ fs.mkdirSync(sub, { recursive: true })
159
+ assert.strictEqual(paths.find_config_root(sub), root)
160
+ })
161
+
162
+ it('finds the root via skills-lock.json when no config file exists yet', () => {
163
+ fs.writeFileSync(path.join(root, 'skills-lock.json'), '{}')
164
+ const sub = path.join(root, 'sub')
165
+ fs.mkdirSync(sub, { recursive: true })
166
+ assert.strictEqual(paths.find_config_root(sub), root)
167
+ })
168
+
169
+ it('stops at a .git boundary even without a config/lock file', () => {
170
+ fs.mkdirSync(path.join(root, '.git'))
171
+ const sub = path.join(root, 'x', 'y')
172
+ fs.mkdirSync(sub, { recursive: true })
173
+ assert.strictEqual(paths.find_config_root(sub), root)
174
+ })
175
+
176
+ it('falls back to the start dir when no marker is found', () => {
177
+ const sub = path.join(root, 'lonely')
178
+ fs.mkdirSync(sub, { recursive: true })
179
+ assert.strictEqual(paths.find_config_root(sub), sub)
180
+ })
181
+ })
182
+
147
183
  // NEW-C3 — the server-derived install-dir name must not escape the skills dir.
148
184
  describe('install-dir path safety (NEW-C3)', () => {
149
185
  const base = path.join(os.tmpdir(), 'hs-skills-base')
package/src/constants.js CHANGED
@@ -22,6 +22,7 @@ const SKILL_JSON = 'skill.json'
22
22
  const SKILL_MD = 'SKILL.md'
23
23
  const README_MD = 'README.md'
24
24
  const LOCK_FILE = 'skills-lock.json'
25
+ const SKILLS_CONFIG_FILE = 'skills-config.json'
25
26
  const INSTALL_LOCK = '.install.lock'
26
27
 
27
28
  const COMMAND_ALIASES = {
@@ -71,6 +72,7 @@ const COMMANDS = [
71
72
  'validate',
72
73
  'self-update',
73
74
  'config',
75
+ 'skills-config',
74
76
  'people',
75
77
  'groups',
76
78
  'access',
@@ -100,6 +102,7 @@ module.exports = {
100
102
  SKILL_MD,
101
103
  README_MD,
102
104
  LOCK_FILE,
105
+ SKILLS_CONFIG_FILE,
103
106
  INSTALL_LOCK,
104
107
  COMMAND_ALIASES,
105
108
  COMMANDS
@@ -383,6 +383,24 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
383
383
 
384
384
  spinner.succeed(`Installed ${packages_to_install.length} package(s)`)
385
385
 
386
+ // Consumer-side skill configuration (spec 260704-01). For any installed package
387
+ // that declares a config/env schema, scaffold its .env and write skills-config.json
388
+ // (beside the lock file). Prompting is opt-in (direct interactive install only); the
389
+ // no-arg/lock/manifest reinstall paths never set prompt_config, so they scaffold
390
+ // defaults silently and surface unset required secrets as warnings.
391
+ const { configure_installed_skills } = require('../skills_config/configure')
392
+ const [config_err, config_result] = await configure_installed_skills(
393
+ downloaded.map(({ pkg }) => pkg),
394
+ {
395
+ base_dir,
396
+ config_root: lock_dir,
397
+ interactive: options.prompt_config === true,
398
+ only_required: options.config_only_required === true
399
+ }
400
+ )
401
+ if (config_err) print_warn(`Skill configuration step failed: ${config_err[0]?.message || 'unknown error'}`)
402
+ const config_warnings = config_result?.warnings || []
403
+
386
404
  const linked_count = downloaded.length - disabled_skills.size
387
405
  if (agents.length > 0 && linked_count > 0) {
388
406
  if (agents_source === 'default') {
@@ -436,7 +454,7 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
436
454
  const installed = packages_to_install.map(p => ({ skill: p.skill, version: p.version }))
437
455
  const skipped = packages.filter(p => !installed_set.has(p.skill)).map(p => ({ skill: p.skill, version: p.version, reason: 'already installed' }))
438
456
  const default_agent_warnings = _format_default_agent_warnings(agents_source, linked_count)
439
- const warnings = [..._format_warnings(missing_deps), ...cycle_warnings, ...default_agent_warnings]
457
+ const warnings = [..._format_warnings(missing_deps), ...cycle_warnings, ...default_agent_warnings, ...config_warnings]
440
458
  const forced = forced_pkgs.map(p => ({ skill: p.skill, version: p.version }))
441
459
 
442
460
  const linked_agents = agents.map(a => a.id)
@@ -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
+ })
@@ -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 }