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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.18.0] - 2026-07-05
11
+
12
+ ### Added
13
+
14
+ - Add a static config-usage lint to `happyskills validate`. For a skill whose `skill.json` declares a `config`/`env` block, `validate` now reads the skill's instruction text (SKILL.md + everything under `references/`) and emits advisory **warnings** — never blocking — when the instructions don't reference `happyskills skills-config get` (rule `config_resolution_guidance`, i.e. the skill should embed the one canonical resolution recipe rather than improvise a resolver) or when a declared `config` field / `env` variable is never referenced anywhere in the skill's text (rule `declared_unused`). The check is purely static — it never executes skill code.
15
+
16
+ ### Fixed
17
+
18
+ - `happyskills uninstall` now cleans up a skill's consumer-side configuration instead of leaving it behind. Previously install wrote a skill's `skills-config.json` block and scaffolded its `.env` secrets, but uninstall removed neither — the config entry and secret lines persisted after the skill was gone. Uninstall (for the named skill and every pruned orphan) now removes the `owner/name` entry from `skills-config.json` and surgically strips the skill's declared secret names from the resolved `envFile` and `.env.example`, deleting the `envFile` if it is left empty. The strip is shared-`envFile` safe — a secret still declared by a skill that remains installed is preserved. Cleanup is best-effort: a failure warns but never fails the uninstall.
19
+
20
+ ## [1.17.0] - 2026-07-05
21
+
22
+ ### Added
23
+
24
+ - 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.
25
+ - 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.
26
+ - 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.
27
+ - 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).
28
+
10
29
  ## [1.16.3] - 2026-07-03
11
30
 
12
31
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "1.16.3",
3
+ "version": "1.18.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
 
@@ -7,6 +7,7 @@ const { validate_no_conflict_markers } = require('../validation/conflict_marker_
7
7
  const { validate_file_sizes } = require('../validation/file_size_rules')
8
8
  const { validate_changelog_version } = require('../validation/changelog_rules')
9
9
  const { validate_dependencies } = require('../validation/dependency_rules')
10
+ const { validate_config_usage } = require('../validation/config_usage_rules')
10
11
  const { file_exists, read_json } = require('../utils/fs')
11
12
  const { skills_dir, find_project_root, lock_root } = require('../config/paths')
12
13
  const { read_lock, get_all_locked_skills } = require('../lock/reader')
@@ -178,6 +179,8 @@ const run = (args) => catch_errors('Validate failed', async () => {
178
179
  if (size_err) throw size_err
179
180
  const [cl_err, cl_results] = await validate_changelog_version(skill_dir, json_data.manifest)
180
181
  if (cl_err) throw cl_err
182
+ const [cfg_err, cfg_results] = await validate_config_usage(skill_dir, json_data.manifest, md_data.content)
183
+ if (cfg_err) throw cfg_err
181
184
 
182
185
  // Dependency validation — registry checks when authenticated
183
186
  let dep_results = []
@@ -219,7 +222,7 @@ const run = (args) => catch_errors('Validate failed', async () => {
219
222
  }
220
223
  }
221
224
 
222
- const all_results = [...md_data.results, ...json_data.results, ...cross_results, ...marker_results, ...size_results, ...cl_results, ...dep_results]
225
+ const all_results = [...md_data.results, ...json_data.results, ...cross_results, ...marker_results, ...size_results, ...cl_results, ...(cfg_results || []), ...dep_results]
223
226
  const type_label = is_kit ? ' [kit]' : ''
224
227
 
225
228
  if (args.flags.json) {
@@ -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)
@@ -4,7 +4,8 @@ const { write_lock, update_lock_skills } = require('../lock/writer')
4
4
  const { skills_dir, skill_install_dir, lock_root } = require('../config/paths')
5
5
  const { remove_dir } = require('../utils/fs')
6
6
  const { resolve_agents, unlink_from_agents } = require('../agents')
7
- const { print_success, print_info } = require('../ui/output')
7
+ const { cleanup_uninstalled_configs, read_skill_secret_names } = require('../skills_config/cleanup')
8
+ const { print_success, print_info, print_warn } = require('../ui/output')
8
9
 
9
10
  const find_orphans = (skills, removed_skill) => {
10
11
  // A skill is still needed if a SURVIVING skill (anything other than the one
@@ -98,6 +99,15 @@ const uninstall = (skill, options = {}) => catch_errors('Uninstall failed', asyn
98
99
  const orphans = find_orphans_cascading(all_skills, skill)
99
100
  const to_remove = [skill, ...orphans]
100
101
 
102
+ // Capture each removed skill's declared secret NAMES BEFORE its install dir is deleted —
103
+ // the manifest is unreadable afterward, and the config cleanup below needs them to surgically
104
+ // strip the right .env lines. Non-secret / non-config skills yield an empty list.
105
+ const removed_config_info = []
106
+ for (const name of to_remove) {
107
+ const [, secret_names] = await read_skill_secret_names(base_dir, name)
108
+ removed_config_info.push({ key: name, name: name.split('/')[1], secret_names: secret_names || [] })
109
+ }
110
+
101
111
  for (const name of to_remove) {
102
112
  const install_dir = skill_install_dir(base_dir, name.split('/')[1])
103
113
  const [rm_errors] = await remove_dir(install_dir)
@@ -120,6 +130,19 @@ const uninstall = (skill, options = {}) => catch_errors('Uninstall failed', asyn
120
130
  const [write_errors] = await write_lock(lock_dir, new_skills)
121
131
  if (write_errors) throw e('Failed to update lock file', write_errors)
122
132
 
133
+ // Consumer-side config cleanup (spec 260704-01 Tier 6 — prune policy). Drop each removed
134
+ // skill's skills-config.json block and surgically strip its now-unused secret names from the
135
+ // .env / .env.example, preserving any secret a surviving skill still declares. Best-effort:
136
+ // a failure here warns but never fails the uninstall — the skill is already gone.
137
+ const surviving_keys = Object.keys(all_skills).filter(k => !to_remove.includes(k))
138
+ const [cleanup_err] = await cleanup_uninstalled_configs({
139
+ config_root: lock_dir,
140
+ base_dir,
141
+ removed: removed_config_info,
142
+ surviving_keys
143
+ })
144
+ if (cleanup_err) print_warn(`Config cleanup step failed: ${cleanup_err[0]?.message || 'unknown error'}`)
145
+
123
146
  print_success(`Removed ${skill}`)
124
147
  if (orphans.length > 0) {
125
148
  print_info(`Pruned ${orphans.length} orphaned dependency(s): ${orphans.join(', ')}`)
@@ -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,107 @@
1
+ const path = require('path')
2
+ const { error: { catch_errors } } = require('puffy-core')
3
+ const { read_json, read_file, write_file, file_exists, remove_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 } = require('./writer')
7
+ const { SKILL_JSON } = require('../constants')
8
+
9
+ const as_object = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : null)
10
+
11
+ // The env-var names an installed skill declares as SECRETS (secret: true). These are the only
12
+ // names install ever scaffolds into a .env, so they are the only names uninstall may strip.
13
+ const secret_names_from_manifest = (manifest) => {
14
+ const env = as_object(manifest && manifest.env) || {}
15
+ return Object.entries(env).filter(([, spec]) => spec && spec.secret).map(([name]) => name)
16
+ }
17
+
18
+ // Reads a skill's secret var names from its installed skill.json. Called BEFORE the install dir
19
+ // is removed (the manifest is unreadable afterward), so the uninstaller can hand them to cleanup.
20
+ const read_skill_secret_names = (base_dir, key) => catch_errors('Failed to read skill secret names', async () => {
21
+ const name = key.split('/')[1]
22
+ const [, manifest] = await read_json(path.join(skill_install_dir(base_dir, name), SKILL_JSON))
23
+ return secret_names_from_manifest(manifest)
24
+ })
25
+
26
+ // Pure: drop every `NAME=...` line whose key is in `names`, leaving all other lines (other
27
+ // assignments, comments, blanks) untouched. `all_blank` is true when nothing but whitespace
28
+ // survives — the signal to delete the file rather than leave an empty husk.
29
+ const strip_env_var_lines = (content, names) => {
30
+ const set = new Set(names)
31
+ const kept = []
32
+ let changed = false
33
+ for (const line of String(content || '').split('\n')) {
34
+ const idx = line.indexOf('=')
35
+ const key = idx === -1 ? null : line.slice(0, idx).trim()
36
+ if (key && set.has(key)) { changed = true; continue }
37
+ kept.push(line)
38
+ }
39
+ return { changed, next: kept.join('\n'), all_blank: kept.every(l => l.trim() === '') }
40
+ }
41
+
42
+ // Strip `names` from an env file in place. Deletes the file when it is left all-blank; otherwise
43
+ // rewrites it. A missing file or a no-op strip touches nothing.
44
+ const strip_names_from_env_file = (abs, names) => catch_errors('Failed to strip env file', async () => {
45
+ const [, exists] = await file_exists(abs)
46
+ if (!exists) return
47
+ const [, content] = await read_file(abs)
48
+ const { changed, next, all_blank } = strip_env_var_lines(content, names)
49
+ if (!changed) return
50
+ if (all_blank) { const [rm_err] = await remove_file(abs); if (rm_err) throw rm_err[0]; return }
51
+ const [w_err] = await write_file(abs, next.endsWith('\n') ? next : next + '\n')
52
+ if (w_err) throw w_err[0]
53
+ })
54
+
55
+ // Uninstall-time counterpart to configure_installed_skills: for every removed skill, drop its
56
+ // `owner/name` block from skills-config.json and surgically strip its secret NAMES from the
57
+ // resolved envFile (and .env.example). A secret name still declared by a SURVIVING installed
58
+ // skill is preserved (shared-envFile safe). Never touches the .gitignore line. Best-effort —
59
+ // the caller treats a failure here as a warning, not an uninstall failure.
60
+ const cleanup_uninstalled_configs = ({ config_root, base_dir, removed, surviving_keys } = {}) => catch_errors('Failed to clean up skills-config on uninstall', async () => {
61
+ if (!config_root || !(removed && removed.length)) return { config_keys_removed: [], secrets_stripped: [] }
62
+
63
+ const [, existing] = await read_skills_config(config_root)
64
+
65
+ // Union of secret names declared by every skill that REMAINS installed — the shared-safe guard.
66
+ const surviving_secrets = new Set()
67
+ for (const key of surviving_keys || []) {
68
+ const name = key.split('/')[1]
69
+ const [, manifest] = await read_json(path.join(skill_install_dir(base_dir, name), SKILL_JSON))
70
+ for (const secret of secret_names_from_manifest(manifest)) surviving_secrets.add(secret)
71
+ }
72
+
73
+ const config_keys_removed = []
74
+ const secrets_stripped = []
75
+ for (const { key, name, secret_names } of removed) {
76
+ const entry = get_skill_config_entry(existing, key)
77
+
78
+ // Remove the config block (committed, git-recoverable — safe to prune).
79
+ if (entry) {
80
+ const [set_err] = await set_skill_config_entry(config_root, key, null)
81
+ if (set_err) throw set_err[0]
82
+ config_keys_removed.push(key)
83
+ }
84
+
85
+ const names_to_strip = (secret_names || []).filter(n => !surviving_secrets.has(n))
86
+ if (!names_to_strip.length) continue
87
+
88
+ // envFile: the consumer's declared pointer, else the install-time default location.
89
+ const env_file_rel = (entry && entry.envFile) || `./secrets/${name}.env`
90
+ const env_abs = path.resolve(config_root, env_file_rel)
91
+ const [env_err] = await strip_names_from_env_file(env_abs, names_to_strip)
92
+ if (env_err) throw env_err[0]
93
+ // The committed name-only twin gets the same treatment.
94
+ const [ex_err] = await strip_names_from_env_file(path.join(config_root, '.env.example'), names_to_strip)
95
+ if (ex_err) throw ex_err[0]
96
+ secrets_stripped.push(...names_to_strip)
97
+ }
98
+
99
+ return { config_keys_removed, secrets_stripped }
100
+ })
101
+
102
+ module.exports = {
103
+ cleanup_uninstalled_configs,
104
+ read_skill_secret_names,
105
+ strip_env_var_lines,
106
+ secret_names_from_manifest
107
+ }