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 +22 -0
- package/package.json +1 -1
- package/src/commands/fork.js +3 -1
- package/src/commands/init.js +6 -2
- package/src/commands/init.test.js +29 -0
- package/src/commands/install.js +7 -1
- package/src/commands/skills-config.js +140 -0
- package/src/commands/update.js +22 -10
- package/src/commands/update.test.js +36 -0
- package/src/config/paths.js +28 -0
- package/src/config/paths.test.js +36 -0
- package/src/constants.js +3 -0
- package/src/engine/installer.js +34 -4
- package/src/integration/install_dep_closure.test.js +122 -0
- package/src/integration/skills-config.test.js +98 -0
- package/src/lock/verify.js +54 -1
- package/src/lock/verify.test.js +109 -1
- package/src/skills_config/configure.js +172 -0
- package/src/skills_config/configure.test.js +165 -0
- package/src/skills_config/reader.js +27 -0
- package/src/skills_config/resolve.js +126 -0
- package/src/skills_config/resolve.test.js +112 -0
- package/src/skills_config/skills_config.test.js +92 -0
- package/src/skills_config/writer.js +59 -0
- package/src/utils/prompt.js +16 -1
- package/src/utils/skill_update_check.js +1 -1
- package/src/validation/skill_json_rules.js +99 -0
- package/src/validation/skill_json_rules.test.js +130 -0
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
package/src/commands/fork.js
CHANGED
|
@@ -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,
|
package/src/commands/init.js
CHANGED
|
@@ -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
|
+
})
|
package/src/commands/install.js
CHANGED
|
@@ -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 }
|
package/src/commands/update.js
CHANGED
|
@@ -25,7 +25,7 @@ Arguments:
|
|
|
25
25
|
owner/skill Update a specific skill (optional)
|
|
26
26
|
|
|
27
27
|
Options:
|
|
28
|
-
--all Update all installed
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
})
|
package/src/config/paths.js
CHANGED
|
@@ -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
|
package/src/config/paths.test.js
CHANGED
|
@@ -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
|
package/src/engine/installer.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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)
|