happyskills 1.16.0 → 1.16.3
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 +23 -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/update.js +16 -8
- package/src/commands/update.test.js +36 -0
- package/src/engine/installer.js +15 -3
- package/src/integration/install_dep_closure.test.js +122 -0
- package/src/lock/verify.js +54 -1
- package/src/lock/verify.test.js +109 -1
- package/src/ui/envelope.test.js +1 -1
- package/src/utils/skill_update_check.js +24 -9
- package/src/utils/skill_update_check.test.js +41 -4
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.16.3] - 2026-07-03
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- `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`.
|
|
15
|
+
|
|
16
|
+
## [1.16.2] - 2026-07-03
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- `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.
|
|
21
|
+
- `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.
|
|
22
|
+
|
|
23
|
+
## [1.16.1] - 2026-07-02
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- Make the passive daily skill-update check watch **every** installed skill, not just top-level (`__root__`) ones. Bundled family satellites installed as transitive dependencies (e.g. `happyskills-design`, `happyskills-help`) are recorded in the lock as `requested_by: [<parent>]`, so the previous `__root__`-only filter silently never nudged when they fell behind the registry — even though the check already spans both the project-local and global locks. Candidate selection now mirrors `happyskills check` (every installed skill), so an outdated bundled satellite is surfaced both in the stderr nudge and in the `SKILLS_UPDATE_AVAILABLE` envelope warning.
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- The "installed skills behind the registry" nudge now names the outdated skills in its suggested command (`npx happyskills update <owner/name> …`, grouped by scope so global skills get `-g`) instead of suggesting `update --all` — which only re-checks top-level skills and therefore could never refresh an outdated bundled satellite.
|
|
32
|
+
|
|
10
33
|
## [1.16.0] - 2026-07-01
|
|
11
34
|
|
|
12
35
|
### Changed
|
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/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
|
|
@@ -433,4 +441,4 @@ const schema = {
|
|
|
433
441
|
]
|
|
434
442
|
}
|
|
435
443
|
|
|
436
|
-
module.exports = { run, schema }
|
|
444
|
+
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/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
|
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
/**
|
|
3
|
+
* Integration test — install must not report "already installed" for a skill whose
|
|
4
|
+
* locked dependency closure is not materialized on disk.
|
|
5
|
+
*
|
|
6
|
+
* The production bug: a `skills-lock.json` recorded a kit as an installed root
|
|
7
|
+
* skill (`requested_by: ['__root__']`, valid integrity, present on disk) with a
|
|
8
|
+
* populated `dependencies` map — but those dependencies were never resolved into
|
|
9
|
+
* their own lock entries and never landed under `.agents/skills/`. Every
|
|
10
|
+
* subsequent `install` then hit the pre-resolve skip check, which inspected only
|
|
11
|
+
* the kit's own directory + integrity and returned "already installed" BEFORE
|
|
12
|
+
* dependency resolution ran. The missing deps were never backfilled: the lock and
|
|
13
|
+
* the on-disk `.agents/skills/` were permanently out of sync.
|
|
14
|
+
*
|
|
15
|
+
* These tests run the REAL CLI with an unreachable registry (localhost:0). That
|
|
16
|
+
* isolates the skip decision from the network:
|
|
17
|
+
* - If the skip fires, the command SUCCEEDS with "already installed" and never
|
|
18
|
+
* touches the network (the buggy behavior).
|
|
19
|
+
* - If the skip is correctly declined, the command falls through to resolution
|
|
20
|
+
* and FAILS on the unreachable registry (the fixed behavior).
|
|
21
|
+
* We assert on that difference, plus the inverse: a fully-materialized closure
|
|
22
|
+
* must still skip (no over-triggering, fast-path preserved).
|
|
23
|
+
*/
|
|
24
|
+
const { describe, it } = require('node:test')
|
|
25
|
+
const assert = require('node:assert/strict')
|
|
26
|
+
const { spawnSync } = require('child_process')
|
|
27
|
+
const fs = require('fs')
|
|
28
|
+
const os = require('os')
|
|
29
|
+
const path = require('path')
|
|
30
|
+
|
|
31
|
+
const CLI = path.resolve(__dirname, '../../bin/happyskills.js')
|
|
32
|
+
const NODE = process.execPath
|
|
33
|
+
|
|
34
|
+
const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-dep-closure-test-'))
|
|
35
|
+
const run = (args, opts) => {
|
|
36
|
+
const result = spawnSync(NODE, [CLI, ...args, '--text'], {
|
|
37
|
+
env: { ...process.env, NO_COLOR: '1', HAPPYSKILLS_API_URL: 'http://localhost:0', CI: '', ...(opts?.env || {}) },
|
|
38
|
+
encoding: 'utf-8',
|
|
39
|
+
timeout: 15000,
|
|
40
|
+
cwd: opts?.cwd
|
|
41
|
+
})
|
|
42
|
+
return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Write a skill directory under <root>/.agents/skills/<name> and return the dir.
|
|
46
|
+
const write_skill = (root, name, manifest) => {
|
|
47
|
+
const dir = path.join(root, '.agents', 'skills', name)
|
|
48
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
49
|
+
fs.writeFileSync(path.join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: dep-closure test skill\n---\nbody\n`)
|
|
50
|
+
fs.writeFileSync(path.join(dir, 'skill.json'), JSON.stringify(manifest, null, '\t'))
|
|
51
|
+
return dir
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Build a project whose lock records `kit` as a __root__ skill (valid integrity,
|
|
55
|
+
// on disk) with the given dependency map. `present_deps` names the deps that are
|
|
56
|
+
// ALSO created on disk + locked; any dep in the map but not in present_deps is
|
|
57
|
+
// declared-but-missing.
|
|
58
|
+
const scaffold = async ({ kit, kit_deps, present_deps }) => {
|
|
59
|
+
const { hash_directory, clear_integrity_cache } = require('../lock/integrity')
|
|
60
|
+
const root = make_tmp()
|
|
61
|
+
const skills = {}
|
|
62
|
+
|
|
63
|
+
const kit_dir = write_skill(root, kit, {
|
|
64
|
+
name: kit, version: '1.0.0', type: 'kit',
|
|
65
|
+
description: 'dep-closure test kit',
|
|
66
|
+
dependencies: Object.fromEntries(kit_deps.map(d => [`acme/${d}`, '*']))
|
|
67
|
+
})
|
|
68
|
+
clear_integrity_cache()
|
|
69
|
+
const [, kit_integrity] = await hash_directory(kit_dir)
|
|
70
|
+
skills[`acme/${kit}`] = {
|
|
71
|
+
version: '1.0.0', ref: 'refs/tags/v1.0.0', commit: 'c', integrity: kit_integrity,
|
|
72
|
+
base_commit: 'c', base_integrity: kit_integrity,
|
|
73
|
+
requested_by: ['__root__'],
|
|
74
|
+
dependencies: Object.fromEntries(kit_deps.map(d => [`acme/${d}`, '*'])),
|
|
75
|
+
type: 'kit'
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const dep of present_deps) {
|
|
79
|
+
const dep_dir = write_skill(root, dep, { name: dep, version: '1.0.0', type: 'skill', description: 'dep' })
|
|
80
|
+
clear_integrity_cache()
|
|
81
|
+
const [, dep_integrity] = await hash_directory(dep_dir)
|
|
82
|
+
skills[`acme/${dep}`] = {
|
|
83
|
+
version: '1.0.0', ref: 'refs/tags/v1.0.0', commit: 'c', integrity: dep_integrity,
|
|
84
|
+
base_commit: 'c', base_integrity: dep_integrity,
|
|
85
|
+
requested_by: [`acme/${kit}`], dependencies: {}, type: 'skill'
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
fs.writeFileSync(
|
|
90
|
+
path.join(root, 'skills-lock.json'),
|
|
91
|
+
JSON.stringify({ lockVersion: 2, generatedAt: new Date().toISOString(), skills }, null, '\t')
|
|
92
|
+
)
|
|
93
|
+
return { root, cleanup: () => fs.rmSync(root, { recursive: true, force: true }) }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
describe('install — dependency-closure skip guard', () => {
|
|
97
|
+
it('does NOT report "already installed" when a locked dependency was never materialized', async () => {
|
|
98
|
+
// kit declares acme/dep-a but dep-a is neither locked nor on disk.
|
|
99
|
+
const ctx = await scaffold({ kit: 'thekit', kit_deps: ['dep-a'], present_deps: [] })
|
|
100
|
+
try {
|
|
101
|
+
const { code, stdout, stderr } = run(['install', 'acme/thekit'], { cwd: ctx.root })
|
|
102
|
+
const out = stdout + stderr
|
|
103
|
+
// Fixed behavior: the skip is declined, so install falls through to
|
|
104
|
+
// resolution and fails against the unreachable registry.
|
|
105
|
+
assert.doesNotMatch(out, /already installed/i,
|
|
106
|
+
'must not claim already-installed while a declared dependency is missing')
|
|
107
|
+
assert.notStrictEqual(code, 0, 'must attempt resolution (and fail on the unreachable registry), not skip')
|
|
108
|
+
} finally { ctx.cleanup() }
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('still skips (already installed, no network) when the whole closure is present', async () => {
|
|
112
|
+
// Regression guard: a fully-materialized closure must keep the fast path —
|
|
113
|
+
// skip without any resolution/network call, even with the registry down.
|
|
114
|
+
const ctx = await scaffold({ kit: 'fullkit', kit_deps: ['dep-a'], present_deps: ['dep-a'] })
|
|
115
|
+
try {
|
|
116
|
+
const { code, stdout, stderr } = run(['install', 'acme/fullkit'], { cwd: ctx.root })
|
|
117
|
+
const out = stdout + stderr
|
|
118
|
+
assert.match(out, /already installed/i, 'a complete closure must still short-circuit')
|
|
119
|
+
assert.strictEqual(code, 0, 'skip path must not touch the network')
|
|
120
|
+
} finally { ctx.cleanup() }
|
|
121
|
+
})
|
|
122
|
+
})
|
package/src/lock/verify.js
CHANGED
|
@@ -118,6 +118,58 @@ const detect_ahead_state = (lock_entry, install_dir) => catch_errors('Failed to
|
|
|
118
118
|
}
|
|
119
119
|
})
|
|
120
120
|
|
|
121
|
+
// dependency_closure_satisfied — walk the locked dependency graph starting at
|
|
122
|
+
// `skill` and confirm every reachable skill is BOTH present in the lock AND
|
|
123
|
+
// materialized on disk. Returns { satisfied: bool, missing: [full skill names] }.
|
|
124
|
+
//
|
|
125
|
+
// This closes the "lock declares deps that were never installed" gap. A lock
|
|
126
|
+
// entry can carry a populated `dependencies` map whose referenced skills have no
|
|
127
|
+
// lock entry and no install directory — e.g. an entry authored by `publish`
|
|
128
|
+
// (which records `manifest.dependencies` but installs nothing), or any install
|
|
129
|
+
// that recorded a root skill while its transitive deps were skipped. In that
|
|
130
|
+
// state the lock and `.agents/skills/` are out of sync.
|
|
131
|
+
//
|
|
132
|
+
// The install skip-check ("already installed") uses this to decide whether a
|
|
133
|
+
// locked-and-present root is actually COMPLETE. If any transitive dependency is
|
|
134
|
+
// missing, the caller must decline the skip and re-resolve so the missing deps
|
|
135
|
+
// get backfilled — the lock must always match what is on disk.
|
|
136
|
+
//
|
|
137
|
+
// `resolve_dir(full_skill_name)` maps `owner/name` to its install directory.
|
|
138
|
+
// Cycle-safe via a visited set. A dependency that is referenced but absent from
|
|
139
|
+
// the lock, or locked but missing on disk, is reported in `missing`.
|
|
140
|
+
const dependency_closure_satisfied = (lock_data, skill, resolve_dir) => catch_errors('Failed to verify dependency closure', async () => {
|
|
141
|
+
const skills = (lock_data && lock_data.skills) || {}
|
|
142
|
+
const missing = []
|
|
143
|
+
const visited = new Set()
|
|
144
|
+
const queue = [skill]
|
|
145
|
+
|
|
146
|
+
while (queue.length > 0) {
|
|
147
|
+
const current = queue.shift()
|
|
148
|
+
if (visited.has(current)) continue
|
|
149
|
+
visited.add(current)
|
|
150
|
+
|
|
151
|
+
const entry = skills[current]
|
|
152
|
+
if (!entry) {
|
|
153
|
+
// Referenced as a dependency but never locked.
|
|
154
|
+
missing.push(current)
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const [, dir_present] = await file_exists(resolve_dir(current))
|
|
159
|
+
if (!dir_present) {
|
|
160
|
+
// Locked but not materialized on disk.
|
|
161
|
+
missing.push(current)
|
|
162
|
+
continue
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
for (const dep_name of Object.keys(entry.dependencies || {})) {
|
|
166
|
+
if (!visited.has(dep_name)) queue.push(dep_name)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return { satisfied: missing.length === 0, missing }
|
|
171
|
+
})
|
|
172
|
+
|
|
121
173
|
// Plain-language description of a drift result, suitable for the principal-facing
|
|
122
174
|
// table cell (status column). Returns null when ok or ahead.
|
|
123
175
|
const describe_drift = (verify_result) => {
|
|
@@ -179,5 +231,6 @@ module.exports = {
|
|
|
179
231
|
detect_ahead_state,
|
|
180
232
|
describe_drift,
|
|
181
233
|
classify_lock_disk,
|
|
182
|
-
parse_changelog_top_version
|
|
234
|
+
parse_changelog_top_version,
|
|
235
|
+
dependency_closure_satisfied
|
|
183
236
|
}
|
package/src/lock/verify.test.js
CHANGED
|
@@ -10,7 +10,8 @@ const {
|
|
|
10
10
|
detect_ahead_state,
|
|
11
11
|
describe_drift,
|
|
12
12
|
classify_lock_disk,
|
|
13
|
-
parse_changelog_top_version
|
|
13
|
+
parse_changelog_top_version,
|
|
14
|
+
dependency_closure_satisfied
|
|
14
15
|
} = require('./verify')
|
|
15
16
|
|
|
16
17
|
const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-verify-test-'))
|
|
@@ -276,6 +277,113 @@ describe('classify_lock_disk', () => {
|
|
|
276
277
|
})
|
|
277
278
|
})
|
|
278
279
|
|
|
280
|
+
describe('dependency_closure_satisfied', () => {
|
|
281
|
+
// A skill directory is "present" iff it exists on disk. We model the disk with a
|
|
282
|
+
// temp root and create a sub-dir per skill that is supposed to be installed.
|
|
283
|
+
const make_root = () => make_tmp()
|
|
284
|
+
const present = (root, skill) => {
|
|
285
|
+
const dir = path.join(root, skill.replace('/', '__'))
|
|
286
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
287
|
+
return dir
|
|
288
|
+
}
|
|
289
|
+
const resolver = (root) => (skill) => path.join(root, skill.replace('/', '__'))
|
|
290
|
+
|
|
291
|
+
const lock = (skills) => ({ lockVersion: 2, skills })
|
|
292
|
+
|
|
293
|
+
it('is satisfied when the root and its whole locked dep closure are present on disk', async () => {
|
|
294
|
+
const root = make_root()
|
|
295
|
+
try {
|
|
296
|
+
present(root, 'acme/kit')
|
|
297
|
+
present(root, 'acme/dep-a')
|
|
298
|
+
present(root, 'acme/dep-b')
|
|
299
|
+
const data = lock({
|
|
300
|
+
'acme/kit': { version: '1.0.0', dependencies: { 'acme/dep-a': '*', 'acme/dep-b': '*' } },
|
|
301
|
+
'acme/dep-a': { version: '1.0.0', dependencies: {} },
|
|
302
|
+
'acme/dep-b': { version: '1.0.0', dependencies: {} }
|
|
303
|
+
})
|
|
304
|
+
const [err, result] = await dependency_closure_satisfied(data, 'acme/kit', resolver(root))
|
|
305
|
+
assert.strictEqual(err, null)
|
|
306
|
+
assert.deepEqual(result, { satisfied: true, missing: [] })
|
|
307
|
+
} finally { cleanup(root) }
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
it('is NOT satisfied when a declared dependency has no lock entry (the kit-deps-never-installed bug)', async () => {
|
|
311
|
+
// This is the exact production shape: the kit is locked + on disk, its
|
|
312
|
+
// `dependencies` map is populated, but those deps were never resolved into
|
|
313
|
+
// their own lock entries and never landed on disk.
|
|
314
|
+
const root = make_root()
|
|
315
|
+
try {
|
|
316
|
+
present(root, 'acme/kit')
|
|
317
|
+
const data = lock({
|
|
318
|
+
'acme/kit': { version: '1.0.0', dependencies: { 'acme/dep-a': '*', 'acme/dep-b': '*' } }
|
|
319
|
+
})
|
|
320
|
+
const [err, result] = await dependency_closure_satisfied(data, 'acme/kit', resolver(root))
|
|
321
|
+
assert.strictEqual(err, null)
|
|
322
|
+
assert.strictEqual(result.satisfied, false)
|
|
323
|
+
assert.deepEqual(result.missing.sort(), ['acme/dep-a', 'acme/dep-b'])
|
|
324
|
+
} finally { cleanup(root) }
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it('is NOT satisfied when a dependency is locked but its directory is missing on disk', async () => {
|
|
328
|
+
const root = make_root()
|
|
329
|
+
try {
|
|
330
|
+
present(root, 'acme/kit')
|
|
331
|
+
// acme/dep-a is locked but NOT created on disk.
|
|
332
|
+
const data = lock({
|
|
333
|
+
'acme/kit': { version: '1.0.0', dependencies: { 'acme/dep-a': '*' } },
|
|
334
|
+
'acme/dep-a': { version: '1.0.0', dependencies: {} }
|
|
335
|
+
})
|
|
336
|
+
const [err, result] = await dependency_closure_satisfied(data, 'acme/kit', resolver(root))
|
|
337
|
+
assert.strictEqual(err, null)
|
|
338
|
+
assert.strictEqual(result.satisfied, false)
|
|
339
|
+
assert.deepEqual(result.missing, ['acme/dep-a'])
|
|
340
|
+
} finally { cleanup(root) }
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
it('walks transitively — a missing dep-of-a-dep still fails the closure', async () => {
|
|
344
|
+
const root = make_root()
|
|
345
|
+
try {
|
|
346
|
+
present(root, 'acme/kit')
|
|
347
|
+
present(root, 'acme/dep-a')
|
|
348
|
+
// acme/dep-a depends on acme/deep, which is neither locked nor on disk.
|
|
349
|
+
const data = lock({
|
|
350
|
+
'acme/kit': { version: '1.0.0', dependencies: { 'acme/dep-a': '*' } },
|
|
351
|
+
'acme/dep-a': { version: '1.0.0', dependencies: { 'acme/deep': '*' } }
|
|
352
|
+
})
|
|
353
|
+
const [err, result] = await dependency_closure_satisfied(data, 'acme/kit', resolver(root))
|
|
354
|
+
assert.strictEqual(err, null)
|
|
355
|
+
assert.strictEqual(result.satisfied, false)
|
|
356
|
+
assert.deepEqual(result.missing, ['acme/deep'])
|
|
357
|
+
} finally { cleanup(root) }
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
it('is cycle-safe (A → B → A) and does not loop forever', async () => {
|
|
361
|
+
const root = make_root()
|
|
362
|
+
try {
|
|
363
|
+
present(root, 'acme/a')
|
|
364
|
+
present(root, 'acme/b')
|
|
365
|
+
const data = lock({
|
|
366
|
+
'acme/a': { version: '1.0.0', dependencies: { 'acme/b': '*' } },
|
|
367
|
+
'acme/b': { version: '1.0.0', dependencies: { 'acme/a': '*' } }
|
|
368
|
+
})
|
|
369
|
+
const [err, result] = await dependency_closure_satisfied(data, 'acme/a', resolver(root))
|
|
370
|
+
assert.strictEqual(err, null)
|
|
371
|
+
assert.deepEqual(result, { satisfied: true, missing: [] })
|
|
372
|
+
} finally { cleanup(root) }
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
it('is satisfied for a leaf skill with no dependencies', async () => {
|
|
376
|
+
const root = make_root()
|
|
377
|
+
try {
|
|
378
|
+
present(root, 'acme/solo')
|
|
379
|
+
const data = lock({ 'acme/solo': { version: '1.0.0', dependencies: {} } })
|
|
380
|
+
const [err, result] = await dependency_closure_satisfied(data, 'acme/solo', resolver(root))
|
|
381
|
+
assert.strictEqual(err, null)
|
|
382
|
+
assert.deepEqual(result, { satisfied: true, missing: [] })
|
|
383
|
+
} finally { cleanup(root) }
|
|
384
|
+
})
|
|
385
|
+
})
|
|
386
|
+
|
|
279
387
|
describe('describe_drift', () => {
|
|
280
388
|
it('returns null for ok results', () => {
|
|
281
389
|
assert.strictEqual(describe_drift({ ok: true }), null)
|
package/src/ui/envelope.test.js
CHANGED
|
@@ -34,7 +34,7 @@ test('drift set → appends a SKILLS_UPDATE_AVAILABLE warning without touching d
|
|
|
34
34
|
assert.strictEqual(env.warnings[0].code, 'SKILLS_UPDATE_AVAILABLE')
|
|
35
35
|
assert.match(env.warnings[0].message, /2 installed skill\(s\) behind the registry/)
|
|
36
36
|
assert.match(env.warnings[0].message, /acme\/x, acme\/y/)
|
|
37
|
-
assert.match(env.warnings[0].message, /npx happyskills update
|
|
37
|
+
assert.match(env.warnings[0].message, /npx happyskills update acme\/x acme\/y/)
|
|
38
38
|
})
|
|
39
39
|
})
|
|
40
40
|
|
|
@@ -44,9 +44,19 @@ const write_scope = (key, outdated) => {
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
// Selects which locked skills to check for registry drift: EVERY installed skill,
|
|
48
|
+
// not just __root__ entries. A bundled family satellite (e.g. happyskills-help) is
|
|
49
|
+
// pulled in as a transitive dependency of core — its lock entry reads
|
|
50
|
+
// requested_by:['happyskillsai/happyskills'], NOT ['__root__'] — so a __root__-only
|
|
51
|
+
// filter silently never nudged about it even when it fell behind. Mirrors the
|
|
52
|
+
// "check every installed skill" enumeration in commands/check.js. A null value is a
|
|
53
|
+
// removed-skill tombstone — skip it. Extracted as a pure function for unit testing.
|
|
54
|
+
const select_drift_candidates = (skills) =>
|
|
55
|
+
Object.entries(skills || {}).filter(([, d]) => d && typeof d === 'object')
|
|
56
|
+
|
|
47
57
|
// Background refresh — NOT awaited, all errors swallowed. Reads the lock directly
|
|
48
58
|
// (NOT via lock/reader.read_lock, which prints a stderr warning on version mismatch
|
|
49
|
-
// — unwanted noise from a silent background task), batch-checks
|
|
59
|
+
// — unwanted noise from a silent background task), batch-checks every installed skill against
|
|
50
60
|
// the registry, and records ONLY the genuinely-outdated ones. It never reads the
|
|
51
61
|
// working tree, so it can never mistake an `ahead` (locally-bumped, unpublished) or
|
|
52
62
|
// regression-drifted skill for "outdated": a local bump leaves base_commit untouched,
|
|
@@ -60,8 +70,7 @@ const refresh_cache = (key, opts) => {
|
|
|
60
70
|
const lock_path = lock_file_path(lock_root(opts.is_global, opts.project_root))
|
|
61
71
|
const [, lock_data] = await read_json(lock_path)
|
|
62
72
|
const skills = (lock_data && lock_data.skills) || {}
|
|
63
|
-
const candidates =
|
|
64
|
-
.filter(([, d]) => d && Array.isArray(d.requested_by) && d.requested_by.includes('__root__'))
|
|
73
|
+
const candidates = select_drift_candidates(skills)
|
|
65
74
|
|
|
66
75
|
if (candidates.length === 0) { write_scope(key, []); return }
|
|
67
76
|
|
|
@@ -140,11 +149,17 @@ const format_skill_drift = (verdict) => {
|
|
|
140
149
|
const items = verdict.outdated
|
|
141
150
|
const names = items.slice(0, 3).map(s => s.skill).join(', ')
|
|
142
151
|
const more = items.length > 3 ? `, +${items.length - 3} more` : ''
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
152
|
+
// Name the outdated skills explicitly rather than suggesting `update --all`.
|
|
153
|
+
// `update --all` only re-checks __root__ skills, so it can never refresh a bundled
|
|
154
|
+
// satellite installed as a transitive dependency — but `update <owner/name>` fixes
|
|
155
|
+
// ANY locked skill. Group by scope: a global skill needs -g, and one update run
|
|
156
|
+
// writes a single scope. Items with no scope default to local.
|
|
157
|
+
const local = items.filter(i => i.scope !== 'global').map(i => i.skill)
|
|
158
|
+
const global = items.filter(i => i.scope === 'global').map(i => i.skill)
|
|
159
|
+
const cmds = []
|
|
160
|
+
if (local.length) cmds.push(`npx happyskills update ${local.join(' ')}`)
|
|
161
|
+
if (global.length) cmds.push(`npx happyskills update ${global.join(' ')} -g`)
|
|
162
|
+
return `${items.length} installed skill(s) behind the registry (${names}${more}). Run: ${cmds.join(' && ')}`
|
|
148
163
|
}
|
|
149
164
|
|
|
150
|
-
module.exports = { check_skills_for_update, format_skill_drift, get_cache_path, scope_key }
|
|
165
|
+
module.exports = { check_skills_for_update, format_skill_drift, select_drift_candidates, get_cache_path, scope_key }
|
|
@@ -4,7 +4,7 @@ const fs = require('fs')
|
|
|
4
4
|
const os = require('os')
|
|
5
5
|
const path = require('path')
|
|
6
6
|
|
|
7
|
-
const { check_skills_for_update, format_skill_drift, get_cache_path, scope_key } = require('./skill_update_check')
|
|
7
|
+
const { check_skills_for_update, format_skill_drift, select_drift_candidates, get_cache_path, scope_key } = require('./skill_update_check')
|
|
8
8
|
|
|
9
9
|
const PROJECT_ROOT = '/proj'
|
|
10
10
|
const PKEY = scope_key({ is_global: false, project_root: PROJECT_ROOT })
|
|
@@ -53,7 +53,7 @@ test('project-only drift → verdict flagged local, no -g in the message', () =>
|
|
|
53
53
|
assert.strictEqual(v.outdated.length, 1)
|
|
54
54
|
assert.strictEqual(v.has_local, true)
|
|
55
55
|
assert.strictEqual(v.has_global, false)
|
|
56
|
-
assert.match(format_skill_drift(v), /update
|
|
56
|
+
assert.match(format_skill_drift(v), /Run: npx happyskills update acme\/x$/)
|
|
57
57
|
})
|
|
58
58
|
})
|
|
59
59
|
|
|
@@ -64,7 +64,7 @@ test('global-only drift → verdict flagged global, message uses -g', () => {
|
|
|
64
64
|
assert.ok(v)
|
|
65
65
|
assert.strictEqual(v.has_local, false)
|
|
66
66
|
assert.strictEqual(v.has_global, true)
|
|
67
|
-
assert.match(format_skill_drift(v), /update
|
|
67
|
+
assert.match(format_skill_drift(v), /Run: npx happyskills update happyskillsai\/happyskills -g$/)
|
|
68
68
|
})
|
|
69
69
|
})
|
|
70
70
|
|
|
@@ -75,7 +75,9 @@ test('drift in BOTH scopes → merged, both flags set, message mentions -g for g
|
|
|
75
75
|
assert.strictEqual(v.outdated.length, 2)
|
|
76
76
|
assert.strictEqual(v.has_local, true)
|
|
77
77
|
assert.strictEqual(v.has_global, true)
|
|
78
|
-
|
|
78
|
+
const msg = format_skill_drift(v)
|
|
79
|
+
assert.match(msg, /npx happyskills update acme\/x/)
|
|
80
|
+
assert.match(msg, /npx happyskills update happyskillsai\/happyskills -g/)
|
|
79
81
|
})
|
|
80
82
|
})
|
|
81
83
|
|
|
@@ -116,3 +118,38 @@ test('localhost:0 test API sentinel short-circuits to null', () => {
|
|
|
116
118
|
assert.strictEqual(check_skills_for_update({ project_root: PROJECT_ROOT }), null)
|
|
117
119
|
})
|
|
118
120
|
})
|
|
121
|
+
|
|
122
|
+
// ─── Regression: the passive check must watch the WHOLE installed family ─────────
|
|
123
|
+
// A bundled family satellite (design/publish/sync/search/help) is installed as a
|
|
124
|
+
// transitive dependency of core, so its lock entry is requested_by:[core], NOT
|
|
125
|
+
// [__root__]. A __root__-only enumeration silently never nudged about it even when it
|
|
126
|
+
// fell behind the registry — the gap this suite pins shut. See docs/cli-overview.md § 3.
|
|
127
|
+
|
|
128
|
+
test('select_drift_candidates watches transitive dependencies, not just __root__ skills', () => {
|
|
129
|
+
const skills = {
|
|
130
|
+
'happyskillsai/happyskills': { version: '2.5.4', base_commit: 'aaa', requested_by: ['__root__'] },
|
|
131
|
+
'happyskillsai/happyskills-help': { version: '0.8.2', base_commit: 'bbb', requested_by: ['happyskillsai/happyskills'] },
|
|
132
|
+
}
|
|
133
|
+
const names = select_drift_candidates(skills).map(([n]) => n)
|
|
134
|
+
assert.ok(names.includes('happyskillsai/happyskills-help'),
|
|
135
|
+
'a bundled satellite pulled in as a transitive dependency must still be checked for updates')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('select_drift_candidates keeps __root__ skills and skips null (removed-skill) tombstones', () => {
|
|
139
|
+
const skills = {
|
|
140
|
+
'acme/root': { version: '1.0.0', requested_by: ['__root__'] },
|
|
141
|
+
'acme/removed': null,
|
|
142
|
+
}
|
|
143
|
+
const names = select_drift_candidates(skills).map(([n]) => n)
|
|
144
|
+
assert.ok(names.includes('acme/root'))
|
|
145
|
+
assert.ok(!names.includes('acme/removed'))
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('format_skill_drift names the skills (works for transitive deps), never suggests update --all', () => {
|
|
149
|
+
// `update --all` only re-checks __root__ skills, so it can never refresh a bundled
|
|
150
|
+
// satellite. The remedy must name the skill: `update <owner/name>` fixes ANY locked skill.
|
|
151
|
+
const verdict = { outdated: [{ skill: 'happyskillsai/happyskills-help', installed: '0.8.2', latest: '0.9.0', scope: 'local' }], has_local: true, has_global: false }
|
|
152
|
+
const msg = format_skill_drift(verdict)
|
|
153
|
+
assert.match(msg, /npx happyskills update happyskillsai\/happyskills-help/)
|
|
154
|
+
assert.doesNotMatch(msg, /update --all/)
|
|
155
|
+
})
|