happyskills 1.16.1 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,28 @@ 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
+
19
+ ## [1.16.3] - 2026-07-03
20
+
21
+ ### Fixed
22
+
23
+ - `happyskills install` no longer reports a skill as "already installed" when its **declared dependency closure is not materialized on disk**. The pre-resolve skip check previously inspected only the requested skill's own directory + integrity, so a lock entry carrying a populated `dependencies` map whose skills were never installed (e.g. a kit entry written by `publish`, which records `manifest.dependencies` but installs nothing) would short-circuit every `install` — the missing transitive dependencies were never backfilled and the lock stayed permanently out of sync with `.agents/skills/`. The skip check now walks the locked dependency graph (cycle-safe, transitive) via `dependency_closure_satisfied` and only skips when every reachable dependency is both locked **and** present on disk; otherwise it declines the skip and re-resolves so the missing skills are installed. This makes `install` self-healing: re-running it repairs a partial lock without needing `--fresh`.
24
+
25
+ ## [1.16.2] - 2026-07-03
26
+
27
+ ### Changed
28
+
29
+ - `happyskills init` and `happyskills fork` no longer scaffold a `keywords` field in the generated `skill.json`. `keywords` is a deprecated, platform-ignored manifest field — discovery labels are derived server-side from the skill's `description` + `SKILL.md` (the platform-owned taxonomy), so an author-supplied keyword list has no effect on search, ranking, or filtering. The scaffold previously emitted an empty `keywords: []`, which baited enrichment agents into populating a no-op field; omitting it removes the bait. The field remains accepted on publish for backward compatibility.
30
+ - `happyskills update --all` now checks **every installed skill**, not just top-level (`__root__`) ones. Previously a bundled dependency — e.g. a HappySkills family satellite (`happyskills-design/publish/sync/search/help`) installed transitively under `happyskills` — was skipped by `--all` and only refreshed when its parent happened to be reinstalled, so a satellite that advanced on the registry while its parent didn't stayed stale even though `check` and the daily update nudge flagged it. `update --all` now mirrors `check` and the nudge and refreshes those drifted transitive skills directly. `update <owner/name>` and the `--all-scopes` per-scope behavior are unchanged.
31
+
10
32
  ## [1.16.1] - 2026-07-02
11
33
 
12
34
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "1.16.1",
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)",
@@ -83,12 +83,14 @@ const run = (args) => catch_errors('Fork failed', async () => {
83
83
  const [, original_manifest] = await read_manifest(dest)
84
84
  const is_kit = (original_manifest?.type) === SKILL_TYPES.KIT
85
85
  const forked_name = is_kit && !name.startsWith(KIT_PREFIX) ? `${KIT_PREFIX}${name}` : name
86
+ // No `keywords` field — it is a deprecated, platform-ignored skill.json field
87
+ // (docs/skill-format.md § 4.3). A fork starts clean; discovery labels are derived
88
+ // server-side from description + SKILL.md, not from an author-supplied keyword list.
86
89
  const forked_manifest = {
87
90
  name: forked_name,
88
91
  version: '0.1.0',
89
92
  type: is_kit ? SKILL_TYPES.KIT : SKILL_TYPES.SKILL,
90
93
  description: '',
91
- keywords: [],
92
94
  forked_from: {
93
95
  repo: skill,
94
96
  version: latest_version,
@@ -58,11 +58,15 @@ Add the skills this kit bundles and why they work well together.
58
58
  Describe the project type or workflow this kit is designed for.
59
59
  `
60
60
 
61
+ // Note: no `keywords` field. It is a deprecated skill.json field (docs/skill-format.md
62
+ // § 4.3) — the platform-owned taxonomy derives discovery labels server-side from the
63
+ // skill's description + SKILL.md, so an author-supplied keyword list is ignored by
64
+ // search, ranking, and filtering. Emitting an empty `keywords: []` here only baited the
65
+ // enrichment agent into populating a field with no effect; omitting it removes the bait.
61
66
  const create_manifest = (name, is_kit = false) => ({
62
67
  name,
63
68
  version: '0.1.0',
64
69
  description: '',
65
- keywords: [],
66
70
  type: is_kit ? SKILL_TYPES.KIT : SKILL_TYPES.SKILL,
67
71
  dependencies: {}
68
72
  })
@@ -181,4 +185,4 @@ const schema = {
181
185
  ],
182
186
  }
183
187
 
184
- module.exports = { run, schema }
188
+ module.exports = { run, schema, create_manifest }
@@ -0,0 +1,29 @@
1
+ 'use strict'
2
+ const { describe, it } = require('node:test')
3
+ const assert = require('node:assert/strict')
4
+
5
+ const { create_manifest } = require('./init')
6
+
7
+ // `keywords` is a deprecated skill.json field — not read by search, ranking, or
8
+ // the platform-owned taxonomy (docs/skill-format.md § 4.3, docs/taxonomy.md § 1).
9
+ // The scaffold must NOT emit an empty `keywords` key: a present-but-empty field is
10
+ // bait that leads the enrichment agent to populate a field the platform ignores.
11
+ describe('create_manifest', () => {
12
+ it('does not emit a keywords key for a skill scaffold', () => {
13
+ const manifest = create_manifest('my-skill')
14
+ assert.ok(!('keywords' in manifest), 'scaffold must not include the deprecated keywords field')
15
+ })
16
+
17
+ it('does not emit a keywords key for a kit scaffold', () => {
18
+ const manifest = create_manifest('_kit-my-kit', true)
19
+ assert.ok(!('keywords' in manifest), 'kit scaffold must not include the deprecated keywords field')
20
+ })
21
+
22
+ it('still emits the fields the enrichment workflow fills', () => {
23
+ const manifest = create_manifest('my-skill')
24
+ assert.strictEqual(manifest.name, 'my-skill')
25
+ assert.strictEqual(manifest.version, '0.1.0')
26
+ assert.strictEqual(manifest.description, '')
27
+ assert.deepStrictEqual(manifest.dependencies, {})
28
+ })
29
+ })
@@ -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 }
@@ -25,7 +25,7 @@ Arguments:
25
25
  owner/skill Update a specific skill (optional)
26
26
 
27
27
  Options:
28
- --all Update all installed root-level skills
28
+ --all Update all installed skills (roots and their dependencies)
29
29
  --all-scopes Update BOTH project-local and global skills (in that order),
30
30
  reporting per-scope results. Overrides -g.
31
31
  --force Re-install regardless of version. Also overwrites skills
@@ -66,6 +66,18 @@ const confirm_prompt = (question) => new Promise((resolve) => {
66
66
  })
67
67
  })
68
68
 
69
+ // Selects which locked skills `update` should consider. With an explicit target, only
70
+ // that skill (if locked). For --all, EVERY installed skill — not just top-level
71
+ // (__root__) ones — so a bundled family satellite installed as a transitive dependency
72
+ // (e.g. happyskills-help under happyskills) is refreshed too. Without this, `update --all`
73
+ // would report staleness (via check/the nudge) it could not resolve. Keeps --all
74
+ // consistent with `check` and the passive drift nudge. A null value is a removed-skill
75
+ // tombstone — skip it. Pure + exported for unit testing.
76
+ const select_update_candidates = (skills, target_skill) =>
77
+ target_skill
78
+ ? (skills && skills[target_skill] ? [[target_skill, skills[target_skill]]] : [])
79
+ : Object.entries(skills || {}).filter(([, d]) => d && typeof d === 'object')
80
+
69
81
  /**
70
82
  * Run the full update flow for ONE scope (project-local or global) and return
71
83
  * `{ scope, data }` — where `data` is exactly the JSON `data` payload that
@@ -89,12 +101,8 @@ const run_scope = (scope_is_global, ctx) => catch_errors('Update scope failed',
89
101
  const skills = get_all_locked_skills(lock_data)
90
102
  const base_dir = skills_dir(scope_is_global, project_root)
91
103
 
92
- // Determine which skills to consider.
93
- // For --all, only root-level skills (transitive deps come along through their parents).
94
- // For a specific target, allow any locked skill.
95
- const candidates = target_skill
96
- ? (skills[target_skill] ? [[target_skill, skills[target_skill]]] : [])
97
- : Object.entries(skills).filter(([, data]) => data && data.requested_by?.includes('__root__'))
104
+ // Determine which skills to consider (see select_update_candidates).
105
+ const candidates = select_update_candidates(skills, target_skill)
98
106
 
99
107
  if (candidates.length === 0) {
100
108
  // A missing target is only a hard error in single-scope mode. Across
@@ -110,7 +118,10 @@ const run_scope = (scope_is_global, ctx) => catch_errors('Update scope failed',
110
118
  // ─── --force path: skip the version check, re-install everything ─────────
111
119
  if (force) {
112
120
  print_header()
113
- 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 }
114
125
  const updated = []
115
126
  for (const [name, data] of candidates) {
116
127
  const before_version = data?.version || null
@@ -281,7 +292,8 @@ const run_scope = (scope_is_global, ctx) => catch_errors('Update scope failed',
281
292
  }
282
293
 
283
294
  // Install only the outdated, non-modified skills
284
- 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 }
285
297
  const updated = []
286
298
  const update_errors = []
287
299
 
@@ -433,4 +445,4 @@ const schema = {
433
445
  ]
434
446
  }
435
447
 
436
- module.exports = { run, schema }
448
+ module.exports = { run, schema, select_update_candidates }
@@ -0,0 +1,36 @@
1
+ const { test } = require('node:test')
2
+ const assert = require('node:assert')
3
+
4
+ const { select_update_candidates } = require('./update')
5
+
6
+ // A normal-user family install: core is __root__, the bundled satellites are transitive
7
+ // dependencies of core (requested_by: [core]). This is what a plain `happyskills setup`
8
+ // produces — the case where the __root__-only filter used to hide the satellites.
9
+ const FAMILY_LOCK = {
10
+ 'happyskillsai/happyskills': { version: '2.5.4', requested_by: ['__root__'] },
11
+ 'happyskillsai/happyskills-help': { version: '0.8.2', requested_by: ['happyskillsai/happyskills'] },
12
+ 'happyskillsai/happyskills-sync': { version: '0.6.3', requested_by: ['happyskillsai/happyskills'] },
13
+ }
14
+
15
+ test('update --all considers transitive family satellites, not just __root__ skills', () => {
16
+ // Coherence fix: `check` and the passive nudge already watch every installed skill;
17
+ // `update --all` must too, or it reports staleness it cannot resolve via the obvious verb.
18
+ const names = select_update_candidates(FAMILY_LOCK, undefined).map(([n]) => n)
19
+ assert.ok(names.includes('happyskillsai/happyskills-help'),
20
+ '`update --all` must be able to refresh a bundled satellite installed as a transitive dependency')
21
+ assert.ok(names.includes('happyskillsai/happyskills'), 'root skills are still included')
22
+ })
23
+
24
+ test('update <target> selects only that skill (root or transitive), or none if unlocked', () => {
25
+ assert.deepStrictEqual(
26
+ select_update_candidates(FAMILY_LOCK, 'happyskillsai/happyskills-help').map(([n]) => n),
27
+ ['happyskillsai/happyskills-help'])
28
+ assert.deepStrictEqual(select_update_candidates(FAMILY_LOCK, 'acme/not-installed'), [])
29
+ })
30
+
31
+ test('select_update_candidates skips null (removed-skill) tombstones', () => {
32
+ const lock = { 'acme/root': { version: '1.0.0', requested_by: ['__root__'] }, 'acme/removed': null }
33
+ const names = select_update_candidates(lock, undefined).map(([n]) => n)
34
+ assert.ok(names.includes('acme/root'))
35
+ assert.ok(!names.includes('acme/removed'))
36
+ })
@@ -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
@@ -8,7 +8,7 @@ const { check_system_dependencies } = require('./system_deps')
8
8
  const { hash_directory, verify_integrity } = require('../lock/integrity')
9
9
  const { read_lock, get_locked_skill } = require('../lock/reader')
10
10
  const { write_lock, update_lock_skills } = require('../lock/writer')
11
- const { verify_lock_disk_consistency } = require('../lock/verify')
11
+ const { verify_lock_disk_consistency, dependency_closure_satisfied } = require('../lock/verify')
12
12
  const { skills_dir, tmp_dir, skill_install_dir, lock_root } = require('../config/paths')
13
13
  const { ensure_dir, remove_dir, file_exists, read_json } = require('../utils/fs')
14
14
  const { SKILL_JSON, SKILL_TYPES } = require('../constants')
@@ -110,7 +110,18 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
110
110
  const [, exists] = await file_exists(install_dir)
111
111
  if (exists) {
112
112
  const [, valid] = locked.integrity ? await verify_integrity(install_dir, locked.integrity) : [null, true]
113
- if (valid !== false) {
113
+ // The skill itself is present and intact — but "already installed"
114
+ // must also mean its declared dependency closure is materialized on
115
+ // disk. A lock entry can carry a `dependencies` map whose skills were
116
+ // never installed (e.g. a kit entry written by `publish`, which
117
+ // records deps but installs nothing). Skipping such an entry leaves
118
+ // the lock and `.agents/skills/` permanently out of sync. If any
119
+ // transitive dep is missing, decline the skip and fall through to
120
+ // resolution so the missing deps get backfilled.
121
+ const [, closure] = valid !== false
122
+ ? await dependency_closure_satisfied(lock_data, skill, (s) => skill_install_dir(base_dir, s.split('/')[1]))
123
+ : [null, { satisfied: false, missing: [] }]
124
+ if (valid !== false && closure && closure.satisfied) {
114
125
  // Verify and repair symlinks even for already-installed skills (skip kits — not agent-invocable)
115
126
  if (agents.length > 0 && locked.type !== SKILL_TYPES.KIT) {
116
127
  const name = skill.split('/')[1]
@@ -134,7 +145,8 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
134
145
  forced: []
135
146
  }
136
147
  }
137
- // integrity mismatch fall through to reinstall at locked version
148
+ // integrity mismatch or an incomplete dependency closure fall
149
+ // through to re-resolve and reinstall/backfill at the locked version
138
150
  }
139
151
  }
140
152
  }
@@ -371,6 +383,24 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
371
383
 
372
384
  spinner.succeed(`Installed ${packages_to_install.length} package(s)`)
373
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
+
374
404
  const linked_count = downloaded.length - disabled_skills.size
375
405
  if (agents.length > 0 && linked_count > 0) {
376
406
  if (agents_source === 'default') {
@@ -424,7 +454,7 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
424
454
  const installed = packages_to_install.map(p => ({ skill: p.skill, version: p.version }))
425
455
  const skipped = packages.filter(p => !installed_set.has(p.skill)).map(p => ({ skill: p.skill, version: p.version, reason: 'already installed' }))
426
456
  const default_agent_warnings = _format_default_agent_warnings(agents_source, linked_count)
427
- 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]
428
458
  const forced = forced_pkgs.map(p => ({ skill: p.skill, version: p.version }))
429
459
 
430
460
  const linked_agents = agents.map(a => a.id)