happyskills 1.12.0 → 1.13.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.
@@ -0,0 +1,171 @@
1
+ 'use strict'
2
+ /**
3
+ * Integration tests for `happyskills list --all-scopes`.
4
+ *
5
+ * `list` and `list -g` each show ONE scope (project or global). `--all-scopes`
6
+ * merges both into a single view, tagging every entry with:
7
+ * - `scope` → "local" (project) | "global" (~/.agents)
8
+ * - `native` → true for HappySkills-managed/draft skills, false for
9
+ * manually-added foreign skills (external / agent-orphan)
10
+ *
11
+ * The global scope is isolated per-test by pointing $HOME at a temp dir — Node's
12
+ * os.homedir() honours $HOME on POSIX, and paths.js derives ~/.agents from it.
13
+ *
14
+ * Guards:
15
+ * - --all-scopes returns `data.skills` as an ARRAY carrying both scopes.
16
+ * - every entry carries the right `scope` + `native` flags.
17
+ * - foreign (no skill.json) skills land in `external` with native:false.
18
+ * - the default (single-scope) shape is unchanged: `data.skills` is an OBJECT
19
+ * and a global-only skill does NOT leak into the project listing.
20
+ */
21
+ const { describe, it } = require('node:test')
22
+ const assert = require('node:assert/strict')
23
+ const { spawnSync } = require('child_process')
24
+ const fs = require('fs')
25
+ const os = require('os')
26
+ const path = require('path')
27
+ const { parse_envelope, assert_success_envelope } = require('../schema/envelope_test_helpers')
28
+
29
+ const CLI = path.resolve(__dirname, '../../bin/happyskills.js')
30
+ const NODE = process.execPath
31
+
32
+ const make_tmp = (tag) => fs.mkdtempSync(path.join(os.tmpdir(), `happyskills-${tag}-`))
33
+
34
+ const run = (args, opts) => {
35
+ const has_mode_flag = args.includes('--json') || args.includes('--text')
36
+ const final_args = has_mode_flag ? args : [...args, '--text']
37
+ const result = spawnSync(NODE, [CLI, ...final_args], {
38
+ env: {
39
+ ...process.env,
40
+ NO_COLOR: '1', HAPPYSKILLS_ENVELOPE_STRICT: '1',
41
+ HAPPYSKILLS_API_URL: 'http://localhost:0',
42
+ CI: '',
43
+ ...(opts?.env || {}),
44
+ },
45
+ encoding: 'utf-8',
46
+ timeout: 10000,
47
+ cwd: opts?.cwd,
48
+ })
49
+ return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status }
50
+ }
51
+
52
+ const parse_json = (stdout, label) => {
53
+ const env = parse_envelope(stdout, label)
54
+ assert_success_envelope(env, label)
55
+ return env
56
+ }
57
+
58
+ // Write a HappySkills-shaped (native) skill onto disk under <skills_base>/<short>.
59
+ const write_native_skill = (skills_base, short, version) => {
60
+ const dir = path.join(skills_base, short)
61
+ fs.mkdirSync(dir, { recursive: true })
62
+ fs.writeFileSync(path.join(dir, 'SKILL.md'), `---\nname: ${short}\ndescription: test skill\n---\nBody\n`)
63
+ fs.writeFileSync(path.join(dir, 'skill.json'), JSON.stringify({ name: short, version, type: 'skill', description: 'test skill' }, null, '\t'))
64
+ }
65
+
66
+ // Write a foreign skill (SKILL.md, NO skill.json) → classified external/native:false.
67
+ const write_foreign_skill = (skills_base, short) => {
68
+ const dir = path.join(skills_base, short)
69
+ fs.mkdirSync(dir, { recursive: true })
70
+ fs.writeFileSync(path.join(dir, 'SKILL.md'), `---\nname: ${short}\ndescription: foreign skill\n---\nBody\n`)
71
+ }
72
+
73
+ const write_lock = (lock_path, entries) => {
74
+ const skills = {}
75
+ for (const [full, version] of entries) {
76
+ skills[full] = {
77
+ version, type: 'skill', ref: `refs/tags/v${version}`, commit: 'c',
78
+ integrity: null, base_commit: 'c', base_integrity: null,
79
+ requested_by: ['__root__'], dependencies: {},
80
+ }
81
+ }
82
+ fs.writeFileSync(lock_path, JSON.stringify({ lockVersion: 2, generatedAt: new Date().toISOString(), skills }, null, '\t'))
83
+ }
84
+
85
+ // Scaffold both scopes: a project root (cwd) and an isolated home ($HOME).
86
+ const scaffold = () => {
87
+ const project_root = make_tmp('list-proj')
88
+ const home_dir = make_tmp('list-home')
89
+
90
+ // Project scope: one managed native skill + one foreign skill.
91
+ const proj_skills = path.join(project_root, '.agents', 'skills')
92
+ write_native_skill(proj_skills, 'local-skill', '1.0.0')
93
+ write_foreign_skill(proj_skills, 'foreign-local')
94
+ write_lock(path.join(project_root, 'skills-lock.json'), [['acme/local-skill', '1.0.0']])
95
+
96
+ // Global scope: one managed native skill (lock lives at ~/.agents/skills-lock.json).
97
+ const global_skills = path.join(home_dir, '.agents', 'skills')
98
+ write_native_skill(global_skills, 'global-skill', '2.1.0')
99
+ write_lock(path.join(home_dir, '.agents', 'skills-lock.json'), [['acme/global-skill', '2.1.0']])
100
+
101
+ return {
102
+ project_root,
103
+ home_dir,
104
+ cleanup: () => {
105
+ fs.rmSync(project_root, { recursive: true, force: true })
106
+ fs.rmSync(home_dir, { recursive: true, force: true })
107
+ },
108
+ }
109
+ }
110
+
111
+ describe('list --all-scopes', () => {
112
+ it('merges project + global skills into an array, each tagged with scope + native', () => {
113
+ const { project_root, home_dir, cleanup } = scaffold()
114
+ try {
115
+ const { code, stdout } = run(['list', '--all-scopes', '--json'], { cwd: project_root, env: { HOME: home_dir } })
116
+ assert.strictEqual(code, 0, 'list --all-scopes should exit 0')
117
+ const out = parse_json(stdout, 'list --all-scopes --json')
118
+
119
+ assert.ok(Array.isArray(out.data.skills), 'data.skills must be an ARRAY in --all-scopes mode')
120
+
121
+ const local = out.data.skills.find(s => s.name === 'acme/local-skill')
122
+ assert.ok(local, 'project-local managed skill must be present')
123
+ assert.strictEqual(local.scope, 'local')
124
+ assert.strictEqual(local.native, true)
125
+ assert.strictEqual(local.version, '1.0.0')
126
+
127
+ const global = out.data.skills.find(s => s.name === 'acme/global-skill')
128
+ assert.ok(global, 'global managed skill must be present in the SAME view')
129
+ assert.strictEqual(global.scope, 'global')
130
+ assert.strictEqual(global.native, true)
131
+ assert.strictEqual(global.version, '2.1.0')
132
+ } finally { cleanup() }
133
+ })
134
+
135
+ it('classifies a foreign (no skill.json) skill as external + native:false, scoped local', () => {
136
+ const { project_root, home_dir, cleanup } = scaffold()
137
+ try {
138
+ const { stdout } = run(['list', '--all-scopes', '--json'], { cwd: project_root, env: { HOME: home_dir } })
139
+ const out = parse_json(stdout, 'list --all-scopes --json external')
140
+ assert.ok(Array.isArray(out.data.external))
141
+ const foreign = out.data.external.find(s => s.name === 'foreign-local')
142
+ assert.ok(foreign, 'foreign skill must appear under data.external')
143
+ assert.strictEqual(foreign.scope, 'local')
144
+ assert.strictEqual(foreign.native, false)
145
+ } finally { cleanup() }
146
+ })
147
+
148
+ it('default (single-scope) list keeps the object shape and does NOT leak global skills', () => {
149
+ const { project_root, home_dir, cleanup } = scaffold()
150
+ try {
151
+ const { stdout } = run(['list', '--json'], { cwd: project_root, env: { HOME: home_dir } })
152
+ const out = parse_json(stdout, 'list --json default')
153
+ assert.ok(!Array.isArray(out.data.skills) && typeof out.data.skills === 'object', 'default data.skills must remain an OBJECT')
154
+ assert.ok(out.data.skills['acme/local-skill'], 'project skill present')
155
+ assert.ok(!out.data.skills['acme/global-skill'], 'global skill must NOT appear in the project-scoped listing')
156
+ // Backward-compat: no scope/native fields injected in single-scope mode.
157
+ assert.strictEqual(out.data.skills['acme/local-skill'].scope, undefined)
158
+ assert.strictEqual(out.data.skills['acme/local-skill'].native, undefined)
159
+ } finally { cleanup() }
160
+ })
161
+
162
+ it('list -g shows only the global scope (object shape)', () => {
163
+ const { project_root, home_dir, cleanup } = scaffold()
164
+ try {
165
+ const { stdout } = run(['list', '-g', '--json'], { cwd: project_root, env: { HOME: home_dir } })
166
+ const out = parse_json(stdout, 'list -g --json')
167
+ assert.ok(out.data.skills['acme/global-skill'], 'global skill present under -g')
168
+ assert.ok(!out.data.skills['acme/local-skill'], 'project skill must NOT appear under -g')
169
+ } finally { cleanup() }
170
+ })
171
+ })
@@ -0,0 +1,100 @@
1
+ 'use strict'
2
+ /**
3
+ * Integration tests for `happyskills update --all-scopes`.
4
+ *
5
+ * `update` / `update -g` act on a single scope. `--all-scopes` runs the update
6
+ * for BOTH project-local and global skills (local first, then global) and
7
+ * reports a per-scope result envelope — the mission's "transparency over magic"
8
+ * applied to a cross-scope write: the caller can see exactly what happened in
9
+ * each scope, including partial failure.
10
+ *
11
+ * The global scope is isolated per-test via $HOME (os.homedir() honours it on
12
+ * POSIX). With no skills installed in either scope, the update short-circuits
13
+ * before any registry call, so these tests are deterministic and offline.
14
+ *
15
+ * Guards:
16
+ * - --all-scopes returns `{ all_scopes: true, scopes: [local, global], ... }`.
17
+ * - each scope entry is tagged with its `scope` and carries the per-scope payload.
18
+ * - the single-scope shape is unchanged (no `all_scopes` / `scopes` keys).
19
+ */
20
+ const { describe, it } = require('node:test')
21
+ const assert = require('node:assert/strict')
22
+ const { spawnSync } = require('child_process')
23
+ const fs = require('fs')
24
+ const os = require('os')
25
+ const path = require('path')
26
+ const { parse_envelope, assert_success_envelope } = require('../schema/envelope_test_helpers')
27
+
28
+ const CLI = path.resolve(__dirname, '../../bin/happyskills.js')
29
+ const NODE = process.execPath
30
+
31
+ const make_tmp = (tag) => fs.mkdtempSync(path.join(os.tmpdir(), `happyskills-${tag}-`))
32
+
33
+ const run = (args, opts) => {
34
+ const has_mode_flag = args.includes('--json') || args.includes('--text')
35
+ const final_args = has_mode_flag ? args : [...args, '--text']
36
+ const result = spawnSync(NODE, [CLI, ...final_args], {
37
+ env: {
38
+ ...process.env,
39
+ NO_COLOR: '1', HAPPYSKILLS_ENVELOPE_STRICT: '1',
40
+ HAPPYSKILLS_API_URL: 'http://localhost:0',
41
+ CI: '',
42
+ ...(opts?.env || {}),
43
+ },
44
+ encoding: 'utf-8',
45
+ timeout: 10000,
46
+ cwd: opts?.cwd,
47
+ })
48
+ return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status }
49
+ }
50
+
51
+ const parse_json = (stdout, label) => {
52
+ const env = parse_envelope(stdout, label)
53
+ assert_success_envelope(env, label)
54
+ return env
55
+ }
56
+
57
+ describe('update --all-scopes', () => {
58
+ it('returns a per-scope envelope covering both local and global', () => {
59
+ const project_root = make_tmp('up-proj')
60
+ const home_dir = make_tmp('up-home')
61
+ try {
62
+ const { code, stdout } = run(['update', '--all', '--all-scopes', '--json'], { cwd: project_root, env: { HOME: home_dir } })
63
+ assert.strictEqual(code, 0, 'update --all-scopes should exit 0')
64
+ const out = parse_json(stdout, 'update --all --all-scopes --json')
65
+
66
+ assert.strictEqual(out.data.all_scopes, true)
67
+ assert.ok(Array.isArray(out.data.scopes), 'data.scopes must be an array')
68
+ assert.strictEqual(out.data.scopes.length, 2)
69
+ assert.deepStrictEqual(out.data.scopes.map(s => s.scope), ['local', 'global'])
70
+ // Each scope carries its own per-scope payload.
71
+ for (const s of out.data.scopes) {
72
+ assert.ok(Array.isArray(s.results), `scope ${s.scope} must carry results[]`)
73
+ assert.strictEqual(typeof s.outdated_count, 'number')
74
+ }
75
+ // Convenience aggregates.
76
+ assert.strictEqual(out.data.updated_count, 0)
77
+ assert.strictEqual(out.data.outdated_count, 0)
78
+ } finally {
79
+ fs.rmSync(project_root, { recursive: true, force: true })
80
+ fs.rmSync(home_dir, { recursive: true, force: true })
81
+ }
82
+ })
83
+
84
+ it('single-scope update keeps the historical shape (no all_scopes / scopes keys)', () => {
85
+ const project_root = make_tmp('up-proj')
86
+ const home_dir = make_tmp('up-home')
87
+ try {
88
+ const { code, stdout } = run(['update', '--all', '--json'], { cwd: project_root, env: { HOME: home_dir } })
89
+ assert.strictEqual(code, 0)
90
+ const out = parse_json(stdout, 'update --all --json single')
91
+ assert.strictEqual(out.data.all_scopes, undefined, 'single-scope output must not carry all_scopes')
92
+ assert.strictEqual(out.data.scopes, undefined, 'single-scope output must not carry scopes[]')
93
+ assert.ok(Array.isArray(out.data.results))
94
+ assert.strictEqual(out.data.outdated_count, 0)
95
+ } finally {
96
+ fs.rmSync(project_root, { recursive: true, force: true })
97
+ fs.rmSync(home_dir, { recursive: true, force: true })
98
+ }
99
+ })
100
+ })
@@ -18,11 +18,23 @@ const { validate_envelope } = require('../schema/envelope_validator')
18
18
  let command_name = '<unknown>'
19
19
  let workspace = null
20
20
  let request_id = null
21
+ // Cross-cutting drift verdict — set once by index.js (from the cached, fire-and-forget
22
+ // skill-update probe) and appended as a warnings[] entry to EVERY command's envelope.
23
+ // Same module-state pattern as command/workspace: avoids threading it through call
24
+ // sites and is what makes the nudge fire "regardless of command". null = nothing to say.
25
+ let skill_drift = null
21
26
  const set_command = (name) => { command_name = name || '<unknown>' }
22
27
  const set_workspace = (slug) => { workspace = slug || null }
23
28
  const set_request_id = (id) => { request_id = id || null }
29
+ const set_skill_drift = (verdict) => {
30
+ skill_drift = (verdict && Array.isArray(verdict.outdated) && verdict.outdated.length) ? verdict : null
31
+ }
24
32
  const get_command = () => command_name
25
33
 
34
+ // Human-readable advisory for the SKILLS_UPDATE_AVAILABLE warning — shared with the
35
+ // stderr nudge in index.js so the two faces of the verdict never diverge.
36
+ const { format_skill_drift } = require('../utils/skill_update_check')
37
+
26
38
  const is_plain_object = (x) => x !== null && typeof x === 'object' && !Array.isArray(x)
27
39
 
28
40
  // Generic instructions per action — used to auto-fill the dependentRequired
@@ -102,6 +114,13 @@ const build_envelope = ({
102
114
  const raw_next_step = is_plain_object(next_step) && Object.keys(next_step).length > 0 ? next_step : {}
103
115
  const safe_next_step = enrich_next_step(raw_next_step)
104
116
  const safe_warnings = Array.isArray(warnings) ? warnings : []
117
+ // Append the cross-cutting drift advisory (if any) to the command's own
118
+ // warnings — never touches `data` (the command's actual response), so it
119
+ // cannot collide with it. `code` is a free-form Warning string, not a closed
120
+ // enum, so this needs no constants mirror and no schema-version bump.
121
+ const drift_warnings = skill_drift
122
+ ? [{ code: 'SKILLS_UPDATE_AVAILABLE', message: format_skill_drift(skill_drift) }]
123
+ : []
105
124
 
106
125
  const ok = !('code' in safe_error)
107
126
  const exit_code = ok
@@ -125,7 +144,7 @@ const build_envelope = ({
125
144
  data: safe_data,
126
145
  error: safe_error,
127
146
  next_step: safe_next_step,
128
- warnings: safe_warnings,
147
+ warnings: [...safe_warnings, ...drift_warnings],
129
148
  meta,
130
149
  }
131
150
 
@@ -167,6 +186,7 @@ module.exports = {
167
186
  set_command,
168
187
  set_workspace,
169
188
  set_request_id,
189
+ set_skill_drift,
170
190
  get_command,
171
191
  json_active,
172
192
  ENVELOPE_SCHEMA_VERSION,
@@ -0,0 +1,63 @@
1
+ const { test } = require('node:test')
2
+ const assert = require('node:assert')
3
+
4
+ const { build_envelope, set_skill_drift } = require('./envelope')
5
+
6
+ // set_skill_drift mutates module state shared across the process, so every test
7
+ // resets it to null on the way out to stay independent of ordering.
8
+ const with_drift = (verdict, fn) => {
9
+ set_skill_drift(verdict)
10
+ try { fn() }
11
+ finally { set_skill_drift(null) }
12
+ }
13
+
14
+ test('no drift set → warnings stays exactly as the command passed it', () => {
15
+ with_drift(null, () => {
16
+ const env = build_envelope({ data: { skills: {} } })
17
+ assert.deepStrictEqual(env.warnings, [])
18
+ })
19
+ })
20
+
21
+ test('drift set → appends a SKILLS_UPDATE_AVAILABLE warning without touching data', () => {
22
+ const verdict = { outdated: [
23
+ { skill: 'acme/x', installed: '1.0.0', latest: '1.1.0' },
24
+ { skill: 'acme/y', installed: '2.0.0', latest: '2.1.0' },
25
+ ] }
26
+ with_drift(verdict, () => {
27
+ const env = build_envelope({ data: { skills: { a: 1 } } })
28
+ // data (the command's real response) is untouched
29
+ assert.deepStrictEqual(env.data, { skills: { a: 1 } })
30
+ // the envelope still has exactly the six canonical keys
31
+ assert.deepStrictEqual(Object.keys(env).sort(), ['data', 'error', 'meta', 'next_step', 'ok', 'warnings'])
32
+ // the advisory rode along in warnings
33
+ assert.strictEqual(env.warnings.length, 1)
34
+ assert.strictEqual(env.warnings[0].code, 'SKILLS_UPDATE_AVAILABLE')
35
+ assert.match(env.warnings[0].message, /2 installed skill\(s\) behind the registry/)
36
+ assert.match(env.warnings[0].message, /acme\/x, acme\/y/)
37
+ assert.match(env.warnings[0].message, /npx happyskills update --all/)
38
+ })
39
+ })
40
+
41
+ test('drift advisory is appended after the command-supplied warnings', () => {
42
+ with_drift({ outdated: [{ skill: 'acme/x', installed: '1.0.0', latest: '1.1.0' }] }, () => {
43
+ const env = build_envelope({ warnings: [{ code: 'OWN_WARNING', message: 'from the command' }] })
44
+ assert.strictEqual(env.warnings.length, 2)
45
+ assert.strictEqual(env.warnings[0].code, 'OWN_WARNING')
46
+ assert.strictEqual(env.warnings[1].code, 'SKILLS_UPDATE_AVAILABLE')
47
+ })
48
+ })
49
+
50
+ test('more than three outdated skills collapse to a "+N more" summary', () => {
51
+ const outdated = ['a', 'b', 'c', 'd', 'e'].map(n => ({ skill: `acme/${n}`, installed: '1.0.0', latest: '1.1.0' }))
52
+ with_drift({ outdated }, () => {
53
+ const env = build_envelope({})
54
+ assert.match(env.warnings[0].message, /\+2 more/)
55
+ })
56
+ })
57
+
58
+ test('set_skill_drift ignores an empty outdated list', () => {
59
+ with_drift({ outdated: [] }, () => {
60
+ const env = build_envelope({})
61
+ assert.deepStrictEqual(env.warnings, [])
62
+ })
63
+ })
@@ -0,0 +1,150 @@
1
+ const os = require('os')
2
+ const fs = require('fs')
3
+ const path = require('path')
4
+
5
+ // Passive, non-blocking drift awareness for INSTALLED skills — the registry-side
6
+ // analogue of utils/update_check.js (which checks npm for a newer CLI). The shown
7
+ // verdict is ALWAYS read synchronously from a 24h cache (zero latency); a stale
8
+ // cache fires a fire-and-forget background refresh whose result is used next time.
9
+ // The current command never waits on the network. Mirrors update_check.js: this is
10
+ // the second sanctioned exception to the catch_errors golden rule — every failure
11
+ // path is swallowed because a drift probe must never break the command it rode in on.
12
+
13
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000
14
+
15
+ const get_cache_path = () => {
16
+ const base = process.env.XDG_CONFIG_HOME
17
+ ? path.join(process.env.XDG_CONFIG_HOME, 'happyskills')
18
+ : path.join(os.homedir(), '.config', 'happyskills')
19
+ return path.join(base, 'skill-update-check.json')
20
+ }
21
+
22
+ // The cache is a map keyed by scope, because a project's installed skills differ
23
+ // from the global set and from another project's — unlike the CLI self-check, which
24
+ // is one-version-per-machine. Each scope entry: { outdated: [...], checked_at }.
25
+ const scope_key = ({ is_global, project_root }) => is_global ? 'global' : `project:${project_root}`
26
+
27
+ const read_cache = () => {
28
+ try {
29
+ return JSON.parse(fs.readFileSync(get_cache_path(), 'utf8'))
30
+ } catch {
31
+ return null
32
+ }
33
+ }
34
+
35
+ const write_scope = (key, outdated) => {
36
+ try {
37
+ const cache = read_cache() || {}
38
+ cache[key] = { outdated, checked_at: Date.now() }
39
+ const cache_path = get_cache_path()
40
+ fs.mkdirSync(path.dirname(cache_path), { recursive: true })
41
+ fs.writeFileSync(cache_path, JSON.stringify(cache), 'utf8')
42
+ } catch {
43
+ // silently ignore write errors
44
+ }
45
+ }
46
+
47
+ // Background refresh — NOT awaited, all errors swallowed. Reads the lock directly
48
+ // (NOT via lock/reader.read_lock, which prints a stderr warning on version mismatch
49
+ // — unwanted noise from a silent background task), batch-checks root skills against
50
+ // the registry, and records ONLY the genuinely-outdated ones. It never reads the
51
+ // working tree, so it can never mistake an `ahead` (locally-bumped, unpublished) or
52
+ // regression-drifted skill for "outdated": a local bump leaves base_commit untouched,
53
+ // so only a real registry advance is recorded (the §2.4 guardrail, enforced here).
54
+ const refresh_cache = (key, opts) => {
55
+ ;(async () => {
56
+ const { read_json } = require('./fs')
57
+ const { lock_file_path, lock_root } = require('../config/paths')
58
+ const repos_api = require('../api/repos')
59
+
60
+ const lock_path = lock_file_path(lock_root(opts.is_global, opts.project_root))
61
+ const [, lock_data] = await read_json(lock_path)
62
+ const skills = (lock_data && lock_data.skills) || {}
63
+ const candidates = Object.entries(skills)
64
+ .filter(([, d]) => d && Array.isArray(d.requested_by) && d.requested_by.includes('__root__'))
65
+
66
+ if (candidates.length === 0) { write_scope(key, []); return }
67
+
68
+ const [err, batch] = await repos_api.check_updates(candidates.map(([name]) => name))
69
+ if (err) return // leave the cache untouched; retry on the next stale invocation
70
+
71
+ const outdated = []
72
+ for (const [name, data] of candidates) {
73
+ const info = batch && batch.results && batch.results[name]
74
+ if (!info || info.access_denied || !info.latest_version) continue
75
+ const registry_advanced = data.base_commit && info.commit
76
+ ? data.base_commit !== info.commit
77
+ : info.latest_version !== data.version // fallback for pre-base_commit locks
78
+ if (registry_advanced) outdated.push({ skill: name, installed: data.version, latest: info.latest_version })
79
+ }
80
+ write_scope(key, outdated)
81
+ })().catch(() => {})
82
+ }
83
+
84
+ // Synchronous — reads the cache and returns the verdict immediately, then fires a
85
+ // background refresh for any stale scope. ALWAYS checks BOTH the project-local and the
86
+ // global lock, because a globally-installed constellation is "always there" and should
87
+ // be nudged about even during project-scoped work (and vice versa). The shown verdict
88
+ // is the merged, name-deduped set of outdated skills across both scopes; `has_local` /
89
+ // `has_global` record which scopes contributed, so the message can point at the right
90
+ // command (`update --all` vs `update --all -g`). Returns null when nothing is outdated.
91
+ const check_skills_for_update = (opts) => {
92
+ if (process.env.HAPPYSKILLS_NO_SKILL_UPDATE_CHECK === '1') return null
93
+ // Integration tests spawn the CLI against http://localhost:0; never probe there
94
+ // (and never let a developer's real cache leak into an envelope-shape assertion).
95
+ const api = process.env.HAPPYSKILLS_API_URL || ''
96
+ if (api.includes('localhost:0') || api.includes('127.0.0.1:0')) return null
97
+
98
+ const project_root = opts.project_root
99
+ const cache = read_cache() || {}
100
+ const scopes = [
101
+ { is_global: false, project_root },
102
+ { is_global: true, project_root },
103
+ ]
104
+
105
+ // Tag each outdated item with the scope it came from BEFORE deduping, so
106
+ // has_local / has_global stay accurate even for a skill outdated in both.
107
+ const raw = []
108
+ for (const s of scopes) {
109
+ const key = scope_key(s)
110
+ const entry = cache[key]
111
+ const is_stale = !entry || !entry.checked_at || (Date.now() - entry.checked_at > CACHE_TTL_MS)
112
+ if (is_stale) refresh_cache(key, s)
113
+ if (entry && Array.isArray(entry.outdated)) {
114
+ const label = s.is_global ? 'global' : 'local'
115
+ for (const item of entry.outdated) raw.push({ ...item, scope: label })
116
+ }
117
+ }
118
+
119
+ const has_local = raw.some(i => i.scope === 'local')
120
+ const has_global = raw.some(i => i.scope === 'global')
121
+
122
+ // Dedupe by skill name for the display list / count (a skill installed in both
123
+ // scopes is one concern); has_local/has_global above already captured both.
124
+ const seen = new Set()
125
+ const outdated = []
126
+ for (const item of raw) {
127
+ if (seen.has(item.skill)) continue
128
+ seen.add(item.skill)
129
+ outdated.push(item)
130
+ }
131
+
132
+ return outdated.length ? { outdated, has_local, has_global } : null
133
+ }
134
+
135
+ // Shared human-readable advisory — used by BOTH the stderr nudge (index.js, text mode)
136
+ // and the SKILLS_UPDATE_AVAILABLE envelope warning (ui/envelope.js, JSON mode), so the
137
+ // two faces never drift. The command hint is scope-aware: `-g` is added only when the
138
+ // outdated set actually includes globally-installed skills.
139
+ const format_skill_drift = (verdict) => {
140
+ const items = verdict.outdated
141
+ const names = items.slice(0, 3).map(s => s.skill).join(', ')
142
+ const more = items.length > 3 ? `, +${items.length - 3} more` : ''
143
+ let cmd
144
+ if (verdict.has_global && verdict.has_local) cmd = 'npx happyskills update --all (and add -g for the global ones)'
145
+ else if (verdict.has_global) cmd = 'npx happyskills update --all -g'
146
+ else cmd = 'npx happyskills update --all'
147
+ return `${items.length} installed skill(s) behind the registry (${names}${more}). Run: ${cmd}`
148
+ }
149
+
150
+ module.exports = { check_skills_for_update, format_skill_drift, get_cache_path, scope_key }
@@ -0,0 +1,118 @@
1
+ const { test } = require('node:test')
2
+ const assert = require('node:assert')
3
+ const fs = require('fs')
4
+ const os = require('os')
5
+ const path = require('path')
6
+
7
+ const { check_skills_for_update, format_skill_drift, get_cache_path, scope_key } = require('./skill_update_check')
8
+
9
+ const PROJECT_ROOT = '/proj'
10
+ const PKEY = scope_key({ is_global: false, project_root: PROJECT_ROOT })
11
+ const GKEY = scope_key({ is_global: true, project_root: PROJECT_ROOT })
12
+
13
+ // Isolate the cache into a temp XDG dir, run fn, restore env. Also neutralises the
14
+ // two short-circuit guards (env opt-out + localhost sentinel) so the cache path runs.
15
+ const with_tmp = (fn) => {
16
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hs-skilldrift-'))
17
+ const saved = {
18
+ xdg: process.env.XDG_CONFIG_HOME,
19
+ api: process.env.HAPPYSKILLS_API_URL,
20
+ off: process.env.HAPPYSKILLS_NO_SKILL_UPDATE_CHECK,
21
+ }
22
+ process.env.XDG_CONFIG_HOME = dir
23
+ process.env.HAPPYSKILLS_API_URL = 'https://api.example.test'
24
+ delete process.env.HAPPYSKILLS_NO_SKILL_UPDATE_CHECK
25
+ try { fn(dir) }
26
+ finally {
27
+ const restore = (k, v) => { if (v === undefined) delete process.env[k]; else process.env[k] = v }
28
+ restore('XDG_CONFIG_HOME', saved.xdg)
29
+ restore('HAPPYSKILLS_API_URL', saved.api)
30
+ restore('HAPPYSKILLS_NO_SKILL_UPDATE_CHECK', saved.off)
31
+ fs.rmSync(dir, { recursive: true, force: true })
32
+ }
33
+ }
34
+
35
+ // Seed BOTH scopes FRESH (checked_at = now) so the sync read returns them without
36
+ // firing any background network refresh. Pass the outdated arrays per scope.
37
+ const seed = ({ local = [], global = [] }) => {
38
+ const p = get_cache_path()
39
+ fs.mkdirSync(path.dirname(p), { recursive: true })
40
+ fs.writeFileSync(p, JSON.stringify({
41
+ [PKEY]: { outdated: local, checked_at: Date.now() },
42
+ [GKEY]: { outdated: global, checked_at: Date.now() },
43
+ }), 'utf8')
44
+ }
45
+
46
+ const item = (skill) => ({ skill, installed: '1.0.0', latest: '1.1.0' })
47
+
48
+ test('project-only drift → verdict flagged local, no -g in the message', () => {
49
+ with_tmp(() => {
50
+ seed({ local: [item('acme/x')], global: [] })
51
+ const v = check_skills_for_update({ project_root: PROJECT_ROOT })
52
+ assert.ok(v)
53
+ assert.strictEqual(v.outdated.length, 1)
54
+ assert.strictEqual(v.has_local, true)
55
+ assert.strictEqual(v.has_global, false)
56
+ assert.match(format_skill_drift(v), /update --all$/)
57
+ })
58
+ })
59
+
60
+ test('global-only drift → verdict flagged global, message uses -g', () => {
61
+ with_tmp(() => {
62
+ seed({ local: [], global: [item('happyskillsai/happyskills')] })
63
+ const v = check_skills_for_update({ project_root: PROJECT_ROOT })
64
+ assert.ok(v)
65
+ assert.strictEqual(v.has_local, false)
66
+ assert.strictEqual(v.has_global, true)
67
+ assert.match(format_skill_drift(v), /update --all -g$/)
68
+ })
69
+ })
70
+
71
+ test('drift in BOTH scopes → merged, both flags set, message mentions -g for global', () => {
72
+ with_tmp(() => {
73
+ seed({ local: [item('acme/x')], global: [item('happyskillsai/happyskills')] })
74
+ const v = check_skills_for_update({ project_root: PROJECT_ROOT })
75
+ assert.strictEqual(v.outdated.length, 2)
76
+ assert.strictEqual(v.has_local, true)
77
+ assert.strictEqual(v.has_global, true)
78
+ assert.match(format_skill_drift(v), /add -g for the global ones/)
79
+ })
80
+ })
81
+
82
+ test('a skill outdated in both scopes is name-deduped but both flags stay accurate', () => {
83
+ with_tmp(() => {
84
+ seed({ local: [item('acme/x')], global: [item('acme/x')] })
85
+ const v = check_skills_for_update({ project_root: PROJECT_ROOT })
86
+ assert.strictEqual(v.outdated.length, 1) // counted once
87
+ assert.strictEqual(v.has_local, true)
88
+ assert.strictEqual(v.has_global, true) // ...but both scopes still acknowledged
89
+ })
90
+ })
91
+
92
+ test('nothing outdated in either scope → null', () => {
93
+ with_tmp(() => {
94
+ seed({ local: [], global: [] })
95
+ assert.strictEqual(check_skills_for_update({ project_root: PROJECT_ROOT }), null)
96
+ })
97
+ })
98
+
99
+ test('format collapses more than three skills to a "+N more" summary', () => {
100
+ const outdated = ['a', 'b', 'c', 'd', 'e'].map(n => ({ ...item(`acme/${n}`), scope: 'local' }))
101
+ assert.match(format_skill_drift({ outdated, has_local: true, has_global: false }), /\+2 more/)
102
+ })
103
+
104
+ test('env opt-out short-circuits to null', () => {
105
+ with_tmp(() => {
106
+ seed({ local: [item('acme/x')], global: [] })
107
+ process.env.HAPPYSKILLS_NO_SKILL_UPDATE_CHECK = '1'
108
+ assert.strictEqual(check_skills_for_update({ project_root: PROJECT_ROOT }), null)
109
+ })
110
+ })
111
+
112
+ test('localhost:0 test API sentinel short-circuits to null', () => {
113
+ with_tmp(() => {
114
+ seed({ local: [item('acme/x')], global: [] })
115
+ process.env.HAPPYSKILLS_API_URL = 'http://localhost:0'
116
+ assert.strictEqual(check_skills_for_update({ project_root: PROJECT_ROOT }), null)
117
+ })
118
+ })