happyskills 1.13.1 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.15.0] - 2026-06-30
11
+
12
+ ### Changed
13
+
14
+ - Make `happyskills match` **version-precise**: it now matches each local skill against **all** published versions of a skill, not just the latest. A local copy that is byte-identical to an *older* release is reported as `certified` at that exact version (the result carries `match.version`, `match.latest_version`, and `match.is_latest`) — instead of being diffed against head and misreported as `likely`/drift just because you're a few versions behind. The text output gains an "identical to an older published version (you are behind)" line listing each such skill as `name → owner/skill @ <version> (latest <latest>)`. Mechanically, the match funnel now also sends the whole-skill `tree_sha`s in the same batched `POST /repos:match` call and certifies on a cross-version tree match (with a fallback to the previous head-only behavior for older API servers). Requires API ≥ 5.18.0 (the `tree_shas` axis on `POST /repos:match`). (Spec 260629-03 §4.8.)
15
+
16
+ ## [1.14.0] - 2026-06-30
17
+
18
+ ### Added
19
+
20
+ - Add the `happyskills match <folder>` command — scan a local folder of skills and find which ones already exist in HappySkills, and how your local copy differs. It fingerprints each skill the same way publish does (the `SKILL.md` git-blob hash plus a git tree hash over the normalized fileset, with symlinked skill roots resolved to real content), then matches against the catalog through a staged funnel — one batched `POST /repos:match` for the whole folder, a tree-SHA compare, a bounded `POST /repos:manifests` per-file diff, and a semantic fallback — never downloading file content to classify. Each skill lands in exactly one tier: `certified` (byte-identical), `near` (same skill, some files differ — with the exact differing-file list), `likely` (name match only), `possible` (loose semantic match), or `unmatched`. Read-only: it reports matches and makes no changes, emitting a `next_step` decision (`organize_matched_skills`) pointing at favoriting or kit organization. Flags: `--json`, `--concurrency <N>` (default 6), `--threshold <N>` (default 0.5). Requires API ≥ 5.17.0 (the `POST /repos:match` + `POST /repos:manifests` endpoints).
21
+
10
22
  ## [1.13.1] - 2026-06-29
11
23
 
12
24
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "1.13.1",
3
+ "version": "1.15.0",
4
4
  "description": "Package manager for AI agent skills",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Nicolas Dao <nic@cloudlesslabs.com> (https://cloudlesslabs.com)",
@@ -47,6 +47,7 @@
47
47
  "@jimp/js-jpeg": "1.6.1",
48
48
  "@jimp/js-png": "1.6.1",
49
49
  "@jimp/plugin-resize": "1.6.1",
50
+ "core-async": "^3.1.0",
50
51
  "node-diff3": "3.2.0",
51
52
  "puffy-core": "1.3.1",
52
53
  "semver": "7.7.4",
package/src/api/repos.js CHANGED
@@ -213,4 +213,29 @@ const semantic_search = (query, options = {}) => catch_errors('Semantic search f
213
213
  return data
214
214
  })
215
215
 
216
- module.exports = { search, dispatch_search, semantic_search, resolve_dependencies, clone, push, get_refs, get_repo, check_updates, del_repo, patch_repo, star, unstar, compare, get_blob, get_tree }
216
+ // POST /repos:match catalog matching for `happyskills match` (spec 260629-03).
217
+ // ONE batch call for the whole folder's hashes + names + tree shas. Returns
218
+ // { results: [{ skill_md_sha, candidates }], name_results: [{ name, candidates }],
219
+ // tree_results: [{ tree_sha, candidates }] } — multi-key, so the client's
220
+ // single-key unwrap leaves it intact. `tree_results` (spec §4.8) matches the
221
+ // whole-skill content address across ALL versions, so a local copy identical to
222
+ // an older release is pinned to that exact version instead of drifting vs head.
223
+ const match = (hashes, names, tree_shas) => catch_errors('Catalog match lookup failed', async () => {
224
+ const [errors, data] = await client.post('/repos:match', { hashes, names: names || [], tree_shas: tree_shas || [] })
225
+ if (errors) throw errors[errors.length - 1]
226
+ return data
227
+ })
228
+
229
+ // POST /repos:manifests — per-file manifests for a narrowed candidate set.
230
+ // unwrap:false so we can surface the envelope's warnings (a repo we couldn't
231
+ // read becomes a warning, not a thrown error) alongside the manifests.
232
+ const manifests = (repo_versions) => catch_errors('Manifests lookup failed', async () => {
233
+ const [errors, raw] = await client.post('/repos:manifests', { repo_versions }, { unwrap: false })
234
+ if (errors) throw errors[errors.length - 1]
235
+ return {
236
+ manifests: (raw && raw.data && raw.data.manifests) || [],
237
+ warnings: (raw && raw.warnings) || [],
238
+ }
239
+ })
240
+
241
+ module.exports = { search, dispatch_search, semantic_search, resolve_dependencies, clone, push, get_refs, get_repo, check_updates, del_repo, patch_repo, star, unstar, compare, get_blob, get_tree, match, manifests }
@@ -0,0 +1,196 @@
1
+ const { error: { catch_errors } } = require('puffy-core')
2
+ const fs = require('fs')
3
+ const path = require('path')
4
+ const repos_api = require('../api/repos')
5
+ const { scan_folder } = require('../match/scanner')
6
+ const { run_funnel } = require('../match/funnel')
7
+ const { DEFAULT_CONCURRENCY } = require('../utils/concurrency')
8
+ const { print_help, print_json, print_success, print_info, print_warn } = require('../ui/output')
9
+ const { exit_with_error, CliError } = require('../utils/errors')
10
+ const { EXIT_CODES } = require('../constants')
11
+
12
+ const HELP_TEXT = `Usage: happyskills match <folder> [options]
13
+
14
+ Scan a folder of skills and find which ones already exist in HappySkills, and
15
+ how your local copy differs. Reports five buckets — no changes are made:
16
+
17
+ certified byte-identical to a catalog skill
18
+ near same skill, some files differ (with the differing-file list)
19
+ likely a name match (content differs)
20
+ possible a loose, semantic-only match
21
+ unmatched nothing comparable in the catalog
22
+
23
+ Arguments:
24
+ folder Folder to scan (a single skill, or a folder of skills)
25
+
26
+ Options:
27
+ --json Output as JSON (the six-key envelope)
28
+ --concurrency N Max parallel catalog lookups (default ${DEFAULT_CONCURRENCY})
29
+ --threshold N Identical-file fraction (0..1) above which a name match
30
+ counts as "near" (default 0.5)
31
+
32
+ Examples:
33
+ happyskills match ./skills
34
+ happyskills match ~/.claude/skills --json`
35
+
36
+ const to_number = (v, fallback) => {
37
+ const n = Number(v)
38
+ return Number.isFinite(n) ? n : fallback
39
+ }
40
+
41
+ const run = (args) => catch_errors('Match failed', async () => {
42
+ if (args.flags._show_help) {
43
+ print_help(HELP_TEXT)
44
+ return process.exit(EXIT_CODES.SUCCESS)
45
+ }
46
+
47
+ const folder = args._[0]
48
+ if (!folder) {
49
+ throw new CliError('Please specify a folder to match (e.g., happyskills match ./skills).', { code: 'MATCH_FOLDER_NOT_FOUND' })
50
+ }
51
+ const abs = path.resolve(folder)
52
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) {
53
+ throw new CliError(`Folder not found: ${folder}`, { code: 'MATCH_FOLDER_NOT_FOUND', context: { folder } })
54
+ }
55
+
56
+ const concurrency = Math.max(1, Math.round(to_number(args.flags.concurrency, DEFAULT_CONCURRENCY)))
57
+ const threshold = Math.min(1, Math.max(0, to_number(args.flags.threshold, 0.5)))
58
+
59
+ // ── Scan ─────────────────────────────────────────────────────────────────
60
+ const [scan_err, scanned] = await scan_folder(abs)
61
+ if (scan_err) throw scan_err[scan_err.length - 1]
62
+ if (!scanned.skills.length && !scanned.errored.length) {
63
+ throw new CliError(`No skills found in ${folder}. A skill is a folder with a SKILL.md or skill.json.`, { code: 'MATCH_NO_SKILLS_FOUND', context: { folder } })
64
+ }
65
+
66
+ // ── Funnel ─────────────────────────────────────────────────────────────────
67
+ const deps = {
68
+ match: repos_api.match,
69
+ manifests: repos_api.manifests,
70
+ semantic_search: (q) => repos_api.semantic_search(q, { limit: 3 }),
71
+ }
72
+ const [funnel_err, funnel] = await run_funnel(scanned.skills, deps, { threshold, concurrency })
73
+ if (funnel_err) throw funnel_err[funnel_err.length - 1]
74
+
75
+ const { buckets, warnings } = funnel
76
+ const rel = (d) => path.relative(abs, d) || path.basename(d)
77
+ const errored = scanned.errored.map(en => ({
78
+ skill: rel(en.dir),
79
+ error: (en.error && en.error[en.error.length - 1] && en.error[en.error.length - 1].message) || 'Could not read skill',
80
+ }))
81
+
82
+ const summary = {
83
+ total: scanned.skills.length,
84
+ certified: buckets.certified.length,
85
+ near: buckets.near.length,
86
+ likely: buckets.likely.length,
87
+ possible: buckets.possible.length,
88
+ unmatched: buckets.unmatched.length,
89
+ errored: errored.length,
90
+ }
91
+
92
+ const data = {
93
+ certified: buckets.certified,
94
+ near: buckets.near,
95
+ likely: buckets.likely,
96
+ possible: buckets.possible,
97
+ unmatched: buckets.unmatched,
98
+ errored,
99
+ summary,
100
+ }
101
+
102
+ // A decision for the principal: what to do with the matched skills. This
103
+ // command only reports — favoriting and kit-building are separate actions.
104
+ const matched_total = summary.certified + summary.near + summary.likely + summary.possible
105
+ const next_step = matched_total > 0 ? {
106
+ action: 'organize_matched_skills',
107
+ instructions: 'Report the matched skills to the principal and let them decide what to do with each: add it to favorites, or organize matched skills into a kit. This command only reports matches — it does not change anything.',
108
+ route_to_skill: 'happyskills-collab',
109
+ context: {
110
+ counts: summary,
111
+ commands: ['npx happyskills star <owner>/<name> --json'],
112
+ matched: [...buckets.certified, ...buckets.near, ...buckets.likely, ...buckets.possible]
113
+ .map(en => (en.match && en.match.workspace && en.match.name) ? `${en.match.workspace}/${en.match.name}` : null)
114
+ .filter(Boolean),
115
+ },
116
+ } : null
117
+
118
+ const all_warnings = (warnings || []).concat(errored.length ? [{ code: 'SKILLS_UNREADABLE', message: `${errored.length} local skill(s) could not be read; see data.errored.` }] : [])
119
+
120
+ if (args.flags.json) {
121
+ print_json({ data, next_step, warnings: all_warnings })
122
+ return
123
+ }
124
+
125
+ render_text(data, all_warnings)
126
+ }).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
127
+
128
+ // Warm, human summary (no leaked internals — no "tree", "blob", "manifest").
129
+ const render_text = (data, warnings) => {
130
+ const s = data.summary
131
+ print_info(`Scanned ${s.total} skill${s.total === 1 ? '' : 's'}.`)
132
+ print_success(`✓ ${s.certified} already in HappySkills, unchanged · ≈ ${s.near} with small differences · ~ ${s.likely + s.possible} loosely matched · ✗ ${s.unmatched} not found`)
133
+
134
+ // A certified match to an OLDER version is still an exact match — surface the
135
+ // version gap so the principal knows it is the real skill, just behind.
136
+ const behind = data.certified.filter(en => en.match && en.match.latest_version && en.match.is_latest === false)
137
+ if (behind.length) {
138
+ print_info('\nIdentical to an older published version (you are behind):')
139
+ for (const en of behind) {
140
+ const where = en.match ? `${en.match.workspace}/${en.match.name}` : '(catalog)'
141
+ print_info(` ${en.local.name} → ${where} @ ${en.match.version} (latest ${en.match.latest_version})`)
142
+ }
143
+ }
144
+
145
+ if (data.near.length) {
146
+ print_info('\nSkills with small differences:')
147
+ for (const en of data.near) {
148
+ const where = en.match ? `${en.match.workspace}/${en.match.name}` : '(catalog)'
149
+ const diff = (en.differing_files && en.differing_files.length)
150
+ ? `differs in: ${en.differing_files.join(', ')}`
151
+ : 'differs'
152
+ print_info(` ${en.local.name} → ${where} — ${diff}`)
153
+ }
154
+ }
155
+ if (data.errored.length) {
156
+ print_warn(`\n${data.errored.length} skill(s) could not be read:`)
157
+ for (const en of data.errored) print_warn(` ${en.skill}: ${en.error}`)
158
+ }
159
+ print_info('\nNext: choose which matched skills to add to your favorites, or group them into a kit.')
160
+ }
161
+
162
+ const schema = {
163
+ name: 'match',
164
+ audience: 'consumer',
165
+ purpose: 'Scan a local folder of skills and match each against the catalog (certified / near / likely / possible / unmatched). Read-only — reports matches, makes no changes.',
166
+ mutation: false,
167
+ interactive_in_text_mode: false,
168
+ owner_skill: 'happyskills-search',
169
+ input: {
170
+ positional: [
171
+ { name: 'folder', required: true, type: 'string', pattern: '<path>' },
172
+ ],
173
+ flags: [
174
+ { name: 'json', type: 'boolean', default: false },
175
+ { name: 'concurrency', type: 'number', default: DEFAULT_CONCURRENCY },
176
+ { name: 'threshold', type: 'number', default: 0.5 },
177
+ ],
178
+ },
179
+ output: {
180
+ data_shape: {
181
+ certified: 'array', near: 'array', likely: 'array',
182
+ possible: 'array', unmatched: 'array', errored: 'array',
183
+ summary: 'object',
184
+ },
185
+ },
186
+ errors: [
187
+ { code: 'MATCH_FOLDER_NOT_FOUND', next_step: { kind: 'recovery', action: 'retry' } },
188
+ { code: 'MATCH_NO_SKILLS_FOUND', next_step: { kind: 'recovery', action: 'retry' } },
189
+ ],
190
+ examples: [
191
+ 'happyskills match ./skills',
192
+ 'happyskills match ~/.claude/skills --json',
193
+ ],
194
+ }
195
+
196
+ module.exports = { run, schema }
@@ -0,0 +1,119 @@
1
+ 'use strict'
2
+ // Spec 260629-03 §4.8 — the `match` command. Asserts the six-key envelope,
3
+ // object-typed data, correct bucket placement, the organize_matched_skills
4
+ // next_step, and that an unreadable local skill lands in errored[] while the
5
+ // others still report. Network + scan are mocked (scan injected so we can force
6
+ // an errored skill deterministically); the REAL funnel + diff run.
7
+
8
+ const { test, before, after } = require('node:test')
9
+ const assert = require('node:assert/strict')
10
+ const fs = require('fs')
11
+ const os = require('os')
12
+ const path = require('path')
13
+
14
+ // ── Mocks (must be set before requiring ./match) ─────────────────────────────
15
+ const scanner_path = require.resolve('../match/scanner')
16
+ const repos_path = require.resolve('../api/repos')
17
+
18
+ const scan_state = { skills: [], errored: [] }
19
+ require.cache[scanner_path] = {
20
+ id: scanner_path, filename: scanner_path, loaded: true,
21
+ exports: { scan_folder: async () => [null, { skills: scan_state.skills, errored: scan_state.errored }] },
22
+ }
23
+
24
+ const api_state = { results: [], name_results: [], manifests: [], semantic: [] }
25
+ require.cache[repos_path] = {
26
+ id: repos_path, filename: repos_path, loaded: true,
27
+ exports: {
28
+ match: async () => [null, { results: api_state.results, name_results: api_state.name_results }],
29
+ manifests: async () => [null, { manifests: api_state.manifests, warnings: [] }],
30
+ semantic_search: async () => [null, api_state.semantic],
31
+ },
32
+ }
33
+
34
+ const { run } = require('./match')
35
+ const { schema } = require('./match')
36
+
37
+ let dir
38
+ before(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'match-cmd-')) })
39
+ after(() => { try { fs.rmSync(dir, { recursive: true, force: true }) } catch {} })
40
+
41
+ // Capture the envelope emit_envelope writes via console.log.
42
+ const capture = async (fn) => {
43
+ const orig = console.log
44
+ let out = ''
45
+ console.log = (s) => { out += s }
46
+ try { await fn() } finally { console.log = orig }
47
+ return JSON.parse(out)
48
+ }
49
+
50
+ test('emits the six-key envelope; buckets are placed correctly; errored skill is surfaced', async () => {
51
+ scan_state.skills = [
52
+ { name: 'alpha', dir: path.join(dir, 'alpha'), file_count: 1, skill_md_sha: 'h1', tree_sha: 't1', files: [{ path: 'SKILL.md', blob_sha: 'b1' }] },
53
+ { name: 'beta', dir: path.join(dir, 'beta'), file_count: 1, skill_md_sha: 'h2', tree_sha: 't2', files: [{ path: 'SKILL.md', blob_sha: 'b2' }] },
54
+ ]
55
+ scan_state.errored = [{ dir: path.join(dir, 'broken'), error: [new Error('permission denied')] }]
56
+ // alpha is byte-identical (tree matches) → certified; beta matches nothing → unmatched.
57
+ api_state.results = [{ skill_md_sha: 'h1', candidates: [{ workspace: 'anthropics', name: 'alpha', version: '1.0.0', tree_sha: 't1', is_source_of_truth: true }] }]
58
+ api_state.name_results = []
59
+ api_state.semantic = []
60
+
61
+ const env = await capture(() => run({ _: [dir], flags: { json: true } }))
62
+
63
+ assert.deepEqual(Object.keys(env).sort(), ['data', 'error', 'meta', 'next_step', 'ok', 'warnings'])
64
+ assert.equal(env.ok, true)
65
+ assert.ok(env.data && typeof env.data === 'object' && !Array.isArray(env.data), 'data is an object, never a bare array')
66
+
67
+ assert.equal(env.data.certified.length, 1)
68
+ assert.equal(env.data.certified[0].local.name, 'alpha')
69
+ assert.equal(env.data.certified[0].match.workspace, 'anthropics')
70
+ assert.equal(env.data.unmatched.length, 1)
71
+ assert.equal(env.data.unmatched[0].local.name, 'beta')
72
+
73
+ // The unreadable skill is reported, not dropped (no-silent-failures).
74
+ assert.equal(env.data.errored.length, 1)
75
+ assert.match(env.data.errored[0].error, /permission denied/)
76
+ assert.ok(env.warnings.some(w => w.code === 'SKILLS_UNREADABLE'))
77
+
78
+ assert.deepEqual(env.data.summary, { total: 2, certified: 1, near: 0, likely: 0, possible: 0, unmatched: 1, errored: 1 })
79
+ })
80
+
81
+ test('a near match carries its differing-file list and emits the organize_matched_skills decision', async () => {
82
+ scan_state.skills = [
83
+ { name: 'gamma', dir: path.join(dir, 'gamma'), file_count: 2, skill_md_sha: 'hg', tree_sha: 't_local',
84
+ files: [{ path: 'SKILL.md', blob_sha: 'sm' }, { path: 'LICENSE', blob_sha: 'lic_local' }] },
85
+ ]
86
+ scan_state.errored = []
87
+ api_state.results = [{ skill_md_sha: 'hg', candidates: [{ workspace: 'anthropics', name: 'gamma', version: '2.0.0', tree_sha: 't_remote', is_source_of_truth: true }] }]
88
+ api_state.name_results = []
89
+ api_state.manifests = [{ workspace: 'anthropics', name: 'gamma', version: '2.0.0', tree_sha: 't_remote',
90
+ files: [{ path: 'SKILL.md', blob_sha: 'sm' }, { path: 'LICENSE', blob_sha: 'lic_remote' }] }]
91
+
92
+ const env = await capture(() => run({ _: [dir], flags: { json: true } }))
93
+
94
+ assert.equal(env.data.near.length, 1)
95
+ assert.deepEqual(env.data.near[0].differing_files, ['LICENSE'])
96
+ assert.equal(env.next_step.action, 'organize_matched_skills')
97
+ assert.equal(env.next_step.kind, 'decision', 'kind is auto-derived from the closed enum')
98
+ assert.equal(env.next_step.route_to_skill, 'happyskills-collab')
99
+ assert.ok(env.next_step.context.matched.includes('anthropics/gamma'))
100
+ })
101
+
102
+ test('no matched skills → no decision next_step', async () => {
103
+ scan_state.skills = [{ name: 'lonely', dir: path.join(dir, 'lonely'), file_count: 1, skill_md_sha: 'hz', tree_sha: 'tz', files: [{ path: 'SKILL.md', blob_sha: 'z' }] }]
104
+ scan_state.errored = []
105
+ api_state.results = []; api_state.name_results = []; api_state.semantic = []
106
+
107
+ const env = await capture(() => run({ _: [dir], flags: { json: true } }))
108
+ assert.equal(env.data.unmatched.length, 1)
109
+ assert.deepEqual(env.next_step, {}, 'no matches ⇒ no decision to make')
110
+ })
111
+
112
+ test('schema: read-only, owned by happyskills-search, with the documented data shape', () => {
113
+ assert.equal(schema.name, 'match')
114
+ assert.equal(schema.mutation, false)
115
+ assert.equal(schema.owner_skill, 'happyskills-search')
116
+ for (const k of ['certified', 'near', 'likely', 'possible', 'unmatched', 'errored', 'summary']) {
117
+ assert.ok(k in schema.output.data_shape, `schema documents data.${k}`)
118
+ }
119
+ })
@@ -117,6 +117,10 @@ const CLONE_REMOTE_FAILED = 'CLONE_REMOTE_FAILED'
117
117
  const READ_LOCAL_FAILED = 'READ_LOCAL_FAILED'
118
118
  const SWAP_FAILED = 'SWAP_FAILED'
119
119
 
120
+ // match command (client-side, spec 260629-03) ──────────────────────────────
121
+ const MATCH_FOLDER_NOT_FOUND = 'MATCH_FOLDER_NOT_FOUND'
122
+ const MATCH_NO_SKILLS_FOUND = 'MATCH_NO_SKILLS_FOUND'
123
+
120
124
  // Internal ─────────────────────────────────────────────────────────────────
121
125
  const INTERNAL_ERROR = 'INTERNAL_ERROR'
122
126
  const PERSIST_FAILED = 'PERSIST_FAILED'
@@ -183,6 +187,7 @@ const ERROR_CODES = Object.freeze({
183
187
  PUBLISH_FAILED, RANKING_SCHEMA_MISMATCH,
184
188
  COMPARE_FAILED, CLONE_BASE_FAILED, CLONE_REMOTE_FAILED, READ_LOCAL_FAILED,
185
189
  SWAP_FAILED,
190
+ MATCH_FOLDER_NOT_FOUND, MATCH_NO_SKILLS_FOUND,
186
191
  INTERNAL_ERROR, PERSIST_FAILED, VERIFICATION_FAILED,
187
192
  NO_COGNITO_USER, COGNITO_SYNC_FAILED, COGNITO_UNLINK_FAILED,
188
193
  ALREADY_HAS_PASSWORD, EMAIL_ALIAS_CONFLICT, ALREADY_APPROVED,
@@ -48,6 +48,7 @@ const SPECIFY_BUMP_TYPE = 'specify_bump_type'
48
48
  const RESOLVE_BUMP_DISAGREEMENT = 'resolve_bump_disagreement'
49
49
  const PICK_VERSION = 'pick_version'
50
50
  const RESOLVE_UNKNOWN_DRIFT = 'resolve_unknown_drift'
51
+ const ORGANIZE_MATCHED_SKILLS = 'organize_matched_skills'
51
52
 
52
53
  // kind: confirmation
53
54
  const CONFIRM_DISCARD_OR_SNAPSHOT_FIRST = 'confirm_discard_or_snapshot_first'
@@ -90,6 +91,7 @@ const ACTION_KIND = Object.freeze({
90
91
  [RESOLVE_BUMP_DISAGREEMENT]: DECISION,
91
92
  [PICK_VERSION]: DECISION,
92
93
  [RESOLVE_UNKNOWN_DRIFT]: DECISION,
94
+ [ORGANIZE_MATCHED_SKILLS]: DECISION,
93
95
 
94
96
  [CONFIRM_DISCARD_OR_SNAPSHOT_FIRST]: CONFIRMATION,
95
97
  [CONFIRM_CASCADE]: CONFIRMATION,
@@ -112,7 +114,7 @@ const NEXT_STEP_ACTIONS = Object.freeze({
112
114
  RESOLVE_REGRESSION, RESOLVE_MISSING_SKILL_JSON, RESOLVE_MISSING_DIR,
113
115
  RESOLVE_CONFLICTS, RESOLVE_PATCH_REJECTIONS, SPECIFY_WORKSPACE,
114
116
  SPECIFY_BUMP_TYPE, RESOLVE_BUMP_DISAGREEMENT, PICK_VERSION,
115
- RESOLVE_UNKNOWN_DRIFT,
117
+ RESOLVE_UNKNOWN_DRIFT, ORGANIZE_MATCHED_SKILLS,
116
118
  CONFIRM_DISCARD_OR_SNAPSHOT_FIRST, CONFIRM_CASCADE, CONFIRM_DESTRUCTIVE,
117
119
  PASS_YES_FLAG,
118
120
  RANK_DIGESTS_INLINE, PRESENT_TO_USER, ATTACH_SCREENSHOT,
@@ -287,6 +287,19 @@ const NEXT_STEP_BY_ERROR_CODE = Object.freeze({
287
287
  ...(ctx.commands ? { commands: ctx.commands } : {}),
288
288
  }
289
289
  ),
290
+
291
+ // Spec 260629-03 §4.8 — `happyskills match <folder>` input errors. Recovery:
292
+ // re-run pointed at a real folder that actually contains skills.
293
+ MATCH_FOLDER_NOT_FOUND: () => recovery(
294
+ RETRY,
295
+ 'The folder to match was not found. Re-run with a path to an existing directory of skills.',
296
+ { commands: ['npx happyskills match <folder> --json'] }
297
+ ),
298
+ MATCH_NO_SKILLS_FOUND: () => recovery(
299
+ RETRY,
300
+ 'No skills were found in that folder. Point match at a directory that contains skills (each skill is a folder with a SKILL.md or skill.json).',
301
+ { commands: ['npx happyskills match <folder> --json'] }
302
+ ),
290
303
  })
291
304
 
292
305
  const next_step_for_error = (code, message, context) => {
package/src/constants.js CHANGED
@@ -51,6 +51,7 @@ const COMMANDS = [
51
51
  'visibility',
52
52
  'list',
53
53
  'search',
54
+ 'match',
54
55
  'resolve',
55
56
  'star',
56
57
  'unstar',
@@ -90,7 +90,13 @@ const run_cli = (args, env_override = {}) => new Promise((resolve) => {
90
90
  const has_mode_flag = args.includes('--json') || args.includes('--text')
91
91
  const final_args = has_mode_flag ? args : [...args, '--text']
92
92
  const child = spawn(NODE, [CLI, ...final_args], {
93
- env: { ...process.env, NO_COLOR: '1', CI: '', ...env_override },
93
+ // Isolate the CLI config dir so the spawned binary never reads the
94
+ // developer's real ~/.config/happyskills credentials. A stored (expired)
95
+ // token would otherwise make an authed command (e.g. a search POST)
96
+ // attempt a token refresh against the stub's missing /auth/refresh route
97
+ // and fail with NETWORK_ERROR — a machine-state-dependent flake. A test
98
+ // that needs a specific config dir overrides this via env_override (last).
99
+ env: { ...process.env, NO_COLOR: '1', CI: '', XDG_CONFIG_HOME: make_tmp(), ...env_override },
94
100
  })
95
101
  let stdout = ''
96
102
  let stderr = ''
@@ -0,0 +1,41 @@
1
+ 'use strict'
2
+ // Manifest diff (spec 260629-03 §4.7). Joins a local manifest against a catalog
3
+ // manifest on `path` with ZERO blob downloads — both sides are just
4
+ // [{ path, blob_sha }] lists. The differing-file list is the load-bearing
5
+ // artifact (not the single similarity %): it's what tells the principal exactly
6
+ // what changed (e.g. "only LICENSE differs").
7
+
8
+ // local_files / catalog_files: [{ path, blob_sha }]
9
+ const diff_manifests = (local_files, catalog_files) => {
10
+ const local = new Map((local_files || []).map(f => [f.path, f.blob_sha]))
11
+ const catalog = new Map((catalog_files || []).map(f => [f.path, f.blob_sha]))
12
+ const all = new Set([...local.keys(), ...catalog.keys()])
13
+
14
+ const identical = []
15
+ const modified = []
16
+ const added = [] // present locally, absent in the catalog skill
17
+ const removed = [] // present in the catalog skill, absent locally
18
+
19
+ for (const p of all) {
20
+ const l = local.get(p)
21
+ const c = catalog.get(p)
22
+ if (l !== undefined && c !== undefined) (l === c ? identical : modified).push(p)
23
+ else if (l !== undefined) added.push(p)
24
+ else removed.push(p)
25
+ }
26
+
27
+ const total = all.size
28
+ const similarity = total === 0 ? 1 : identical.length / total
29
+ const differing = [...modified, ...added, ...removed].sort()
30
+
31
+ return {
32
+ similarity,
33
+ identical: identical.sort(),
34
+ modified: modified.sort(),
35
+ added: added.sort(),
36
+ removed: removed.sort(),
37
+ differing,
38
+ }
39
+ }
40
+
41
+ module.exports = { diff_manifests }
@@ -0,0 +1,191 @@
1
+ 'use strict'
2
+ // The match funnel + tier classification (spec 260629-03 §4.7).
3
+ //
4
+ // Stages, cheapest signal first, NO blob downloads to classify:
5
+ // 1. hash + name — ONE batched POST /repos:match for ALL local skills.
6
+ // 2. tree SHA — local.tree_sha === a candidate's tree_sha ⇒ certified
7
+ // (byte-identical, zero further work).
8
+ // 3. manifest — for non-certified hash/name candidates, fetch the narrowed
9
+ // source-of-truth manifest (bounded fan-out, no blob content)
10
+ // and diff per-file. A hash match means SKILL.md is identical
11
+ // by construction → favor `near` and just list the differing
12
+ // files. A name-only match is `near` above the similarity
13
+ // threshold, else `likely`.
14
+ // 4. semantic — skills with no hash/name match fall back to the deployed
15
+ // semantic search (bounded), labeled `possible` (clearly low
16
+ // confidence), else `unmatched`.
17
+ //
18
+ // Closed tier set: certified · near · likely · possible · unmatched.
19
+
20
+ const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
21
+ const { bounded, DEFAULT_CONCURRENCY } = require('../utils/concurrency')
22
+ const { diff_manifests } = require('./diff')
23
+
24
+ // Identical-file fraction above which a NAME-only match is promoted to `near`.
25
+ const DEFAULT_THRESHOLD = 0.5
26
+ // Mirror of the server cap (MATCH_MAX_MANIFESTS) so we chunk manifest requests.
27
+ const MANIFEST_CHUNK = 50
28
+
29
+ const key_of = (c) => `${String(c.workspace).toLowerCase()}/${c.name}@${c.version}`
30
+
31
+ const local_view = (skill) => ({
32
+ name: skill.name,
33
+ dir: skill.dir,
34
+ file_count: skill.file_count,
35
+ skill_md_sha: skill.skill_md_sha,
36
+ tree_sha: skill.tree_sha,
37
+ })
38
+
39
+ const match_view = (c) => ({
40
+ workspace: c.workspace,
41
+ name: c.name,
42
+ version: c.version,
43
+ tree_sha: c.tree_sha,
44
+ is_source_of_truth: !!c.is_source_of_truth,
45
+ // Cross-version probe carries the repo head so a certified-but-older match
46
+ // can be surfaced as "certified @ 0.3.0 (latest 0.3.2)". Null when the
47
+ // candidate came from the legacy head-only path (older server).
48
+ latest_version: c.latest_version || null,
49
+ is_latest: c.latest_version ? c.version === c.latest_version : null,
50
+ })
51
+
52
+ // deps: { match(hashes,names), manifests(repo_versions), semantic_search?(query) }
53
+ // opts: { threshold, concurrency }
54
+ const run_funnel = (skills, deps, opts = {}) =>
55
+ catch_errors('Match funnel failed', async () => {
56
+ const threshold = typeof opts.threshold === 'number' ? opts.threshold : DEFAULT_THRESHOLD
57
+ const concurrency = opts.concurrency || DEFAULT_CONCURRENCY
58
+ const buckets = { certified: [], near: [], likely: [], possible: [], unmatched: [] }
59
+ const warnings = []
60
+
61
+ if (!skills.length) return { buckets, warnings }
62
+
63
+ // ── Stage 1: one batched match for the whole folder ──────────────────
64
+ // Hashes, names AND whole-skill tree shas ride the same call. The tree
65
+ // probe (spec §4.8) is what lets an OLDER local copy match its exact
66
+ // published version instead of drifting against head.
67
+ const hashes = [...new Set(skills.map(s => s.skill_md_sha).filter(Boolean))]
68
+ const names = [...new Set(skills.map(s => s.name).filter(Boolean))]
69
+ const tree_shas = [...new Set(skills.map(s => s.tree_sha).filter(Boolean))]
70
+ const [m_err, match_data] = await deps.match(hashes, names, tree_shas)
71
+ if (m_err) throw e('Stage 1 /repos:match failed', m_err)
72
+
73
+ const by_hash = new Map((match_data.results || []).map(r => [r.skill_md_sha, r.candidates || []]))
74
+ const by_name = new Map((match_data.name_results || []).map(r => [String(r.name).toLowerCase(), r.candidates || []]))
75
+ const by_tree = new Map((match_data.tree_results || []).map(r => [r.tree_sha, r.candidates || []]))
76
+
77
+ // ── Stage 2: classify; collect the narrowed manifest set ─────────────
78
+ const pending = [] // needs a manifest diff
79
+ const none = [] // no hash/name match → semantic stage
80
+ const manifest_needs = new Map()
81
+
82
+ for (const skill of skills) {
83
+ const tree_cands = (skill.tree_sha && by_tree.get(skill.tree_sha)) || []
84
+ const hash_cands = (skill.skill_md_sha && by_hash.get(skill.skill_md_sha)) || []
85
+ const name_cands = by_name.get(String(skill.name || '').toLowerCase()) || []
86
+
87
+ // Certified = byte-identical to SOME published version. The cross-version
88
+ // tree probe is authoritative and pins the EXACT version (even an older
89
+ // one — candidates are source-of-truth + newest-matching first). Fall
90
+ // back to head tree-SHA equality from a hash/name candidate so an older
91
+ // server with no tree_results still certifies a head-identical skill.
92
+ const certified = tree_cands[0] || [...hash_cands, ...name_cands].find(c => c.tree_sha === skill.tree_sha)
93
+ if (certified) {
94
+ buckets.certified.push({ local: local_view(skill), match: match_view(certified) })
95
+ continue
96
+ }
97
+ if (hash_cands.length) {
98
+ const sot = hash_cands[0] // API returns source-of-truth first
99
+ manifest_needs.set(key_of(sot), { workspace: sot.workspace, name: sot.name, version: sot.version })
100
+ pending.push({ skill, cand: sot, via: 'hash' })
101
+ continue
102
+ }
103
+ if (name_cands.length) {
104
+ const sot = name_cands[0]
105
+ manifest_needs.set(key_of(sot), { workspace: sot.workspace, name: sot.name, version: sot.version })
106
+ pending.push({ skill, cand: sot, via: 'name' })
107
+ continue
108
+ }
109
+ none.push(skill)
110
+ }
111
+
112
+ // ── Stage 3: bounded manifest fetch (chunked to the server cap) ──────
113
+ const manifest_by_key = new Map()
114
+ const needs = [...manifest_needs.values()]
115
+ if (needs.length) {
116
+ const chunks = []
117
+ for (let i = 0; i < needs.length; i += MANIFEST_CHUNK) chunks.push(needs.slice(i, i + MANIFEST_CHUNK))
118
+ const results = await bounded(chunks.map(chunk => () => deps.manifests(chunk)), concurrency)
119
+ for (const r of results) {
120
+ if (r && r.error) { warnings.push({ code: 'MANIFEST_FETCH_FAILED', message: String((r.error && r.error.message) || r.error) }); continue }
121
+ const [err, data] = r || [null, null]
122
+ if (err || !data) { warnings.push({ code: 'MANIFEST_FETCH_FAILED', message: 'A manifest batch could not be fetched.' }); continue }
123
+ for (const m of (data.manifests || [])) manifest_by_key.set(key_of(m), m)
124
+ for (const w of (data.warnings || [])) warnings.push(w)
125
+ }
126
+ }
127
+
128
+ // ── finalize the hash/name candidates with their diffs ───────────────
129
+ for (const { skill, cand, via } of pending) {
130
+ const m = manifest_by_key.get(key_of(cand))
131
+ const diff = m ? diff_manifests(skill.files, m.files) : null
132
+ const entry = {
133
+ local: local_view(skill),
134
+ match: match_view(cand),
135
+ differing_files: diff ? diff.differing : null,
136
+ similarity: diff ? diff.similarity : null,
137
+ }
138
+ if (via === 'hash') {
139
+ // SKILL.md identical by construction → the difference is ancillary.
140
+ buckets.near.push(entry)
141
+ } else if (diff && diff.similarity >= threshold) {
142
+ buckets.near.push(entry)
143
+ } else {
144
+ buckets.likely.push(entry)
145
+ }
146
+ }
147
+
148
+ // ── Stage 4: semantic fallback for the rest (bounded) ────────────────
149
+ // deps.semantic_search returns the api-client tuple [err, hits] (hits is
150
+ // an array, or { results: [...] }); a failure degrades the skill to
151
+ // `unmatched` with a surfaced warning — never a silent drop.
152
+ if (none.length && typeof deps.semantic_search === 'function') {
153
+ // The thunk ALWAYS resolves (carrying its skill), so a search that
154
+ // rejects can't strand the skill — it still lands in `unmatched` with a
155
+ // surfaced warning, never silently dropped from `data`.
156
+ const results = await bounded(
157
+ none.map(skill => () =>
158
+ deps.semantic_search(skill.name).then(res => ({ skill, res })).catch(error => ({ skill, error }))),
159
+ concurrency
160
+ )
161
+ for (const r of results) {
162
+ const { skill, res, error } = r || {}
163
+ if (!skill) continue // defensive: bounded never yields a bare {error} here
164
+ const tuple_err = Array.isArray(res) ? res[0] : null
165
+ if (error || tuple_err) {
166
+ warnings.push({ code: 'SEMANTIC_SEARCH_FAILED', message: String((error && error.message) || 'A semantic lookup failed.') })
167
+ buckets.unmatched.push({ local: local_view(skill) })
168
+ continue
169
+ }
170
+ const data = Array.isArray(res) ? res[1] : res
171
+ const hits = Array.isArray(data) ? data : (data && Array.isArray(data.results) ? data.results : [])
172
+ if (hits[0]) buckets.possible.push({ local: local_view(skill), match: semantic_match_view(hits[0]) })
173
+ else buckets.unmatched.push({ local: local_view(skill) })
174
+ }
175
+ } else {
176
+ for (const skill of none) buckets.unmatched.push({ local: local_view(skill) })
177
+ }
178
+
179
+ return { buckets, warnings }
180
+ })
181
+
182
+ // A semantic hit shape varies by search response; normalize the fields the
183
+ // match report cares about, leaving missing ones null.
184
+ const semantic_match_view = (hit) => ({
185
+ workspace: hit.workspace_slug || hit.workspace || null,
186
+ name: hit.name || null,
187
+ version: hit.version || null,
188
+ score: typeof hit.score === 'number' ? hit.score : null,
189
+ })
190
+
191
+ module.exports = { run_funnel, DEFAULT_THRESHOLD, MANIFEST_CHUNK }
@@ -0,0 +1,203 @@
1
+ 'use strict'
2
+ // Spec 260629-03 §4.7 — the match funnel + tier classification. Network is
3
+ // mocked (deps injected). The four "done when" guarantees:
4
+ // - identical → certified with ZERO blob fetches (the funnel never fetches
5
+ // blob content at all; classification is hash/tree/manifest only)
6
+ // - LICENSE-only diff → near, listing LICENSE as the sole differing file
7
+ // - SKILL.md differs (name-only, low similarity) → demoted to likely
8
+ // - semantic-only → possible
9
+
10
+ const { test } = require('node:test')
11
+ const assert = require('node:assert/strict')
12
+ const { run_funnel } = require('./funnel')
13
+ const { diff_manifests } = require('./diff')
14
+
15
+ const skill = (over) => ({
16
+ name: 'webapp-testing', dir: '/local/webapp-testing', file_count: 2,
17
+ skill_md_sha: 'h_skillmd', tree_sha: 't_local',
18
+ files: [{ path: 'SKILL.md', blob_sha: 'b_skill' }, { path: 'LICENSE', blob_sha: 'b_lic_local' }],
19
+ ...over,
20
+ })
21
+ const cand = (over) => ({ workspace: 'anthropics', name: 'webapp-testing', version: '1.0.0', tree_sha: 't_remote', skill_md_sha: 'h_skillmd', is_source_of_truth: true, ...over })
22
+
23
+ // A deps double that records calls.
24
+ const make_deps = ({ results = [], name_results = [], tree_results = [], manifests = [], semantic = null } = {}) => {
25
+ const calls = { match: 0, manifests: 0, semantic: 0, blob: 0 }
26
+ return {
27
+ calls,
28
+ match: async () => { calls.match++; return [null, { results, name_results, tree_results }] },
29
+ manifests: async () => { calls.manifests++; return [null, { manifests, warnings: [] }] },
30
+ ...(semantic ? { semantic_search: async (q) => { calls.semantic++; return semantic(q) } } : {}),
31
+ }
32
+ }
33
+
34
+ test('identical content → certified, with zero manifest/blob fetches', async () => {
35
+ const deps = make_deps({ results: [{ skill_md_sha: 'h_skillmd', candidates: [cand({ tree_sha: 't_local' })] }] })
36
+ const [err, out] = await run_funnel([skill()], deps)
37
+ assert.equal(err, null)
38
+ assert.equal(out.buckets.certified.length, 1)
39
+ assert.equal(out.buckets.certified[0].match.workspace, 'anthropics')
40
+ assert.equal(deps.calls.manifests, 0, 'certified needs no manifest fetch')
41
+ assert.equal(deps.calls.blob, 0, 'the funnel never fetches blob content')
42
+ })
43
+
44
+ test('hash match, only LICENSE differs → near, listing LICENSE as the sole differing file', async () => {
45
+ const deps = make_deps({
46
+ results: [{ skill_md_sha: 'h_skillmd', candidates: [cand({ tree_sha: 't_remote' })] }],
47
+ manifests: [{ workspace: 'anthropics', name: 'webapp-testing', version: '1.0.0', tree_sha: 't_remote',
48
+ files: [{ path: 'SKILL.md', blob_sha: 'b_skill' }, { path: 'LICENSE', blob_sha: 'b_lic_remote' }] }],
49
+ })
50
+ const [err, out] = await run_funnel([skill()], deps)
51
+ assert.equal(err, null)
52
+ assert.equal(out.buckets.certified.length, 0)
53
+ assert.equal(out.buckets.near.length, 1)
54
+ assert.deepEqual(out.buckets.near[0].differing_files, ['LICENSE'])
55
+ assert.equal(deps.calls.manifests, 1)
56
+ })
57
+
58
+ test('name-only match with low similarity (SKILL.md differs) → likely', async () => {
59
+ // No hash candidate; a name candidate whose manifest shares almost nothing.
60
+ const local = skill({ skill_md_sha: 'h_local_only', tree_sha: 't_local', files: [{ path: 'SKILL.md', blob_sha: 'x1' }, { path: 'a.md', blob_sha: 'x2' }] })
61
+ const deps = make_deps({
62
+ name_results: [{ name: 'webapp-testing', candidates: [cand({ skill_md_sha: 'h_other', tree_sha: 't_remote' })] }],
63
+ manifests: [{ workspace: 'anthropics', name: 'webapp-testing', version: '1.0.0', tree_sha: 't_remote',
64
+ files: [{ path: 'SKILL.md', blob_sha: 'y1' }, { path: 'b.md', blob_sha: 'y2' }, { path: 'c.md', blob_sha: 'y3' }] }],
65
+ })
66
+ const [err, out] = await run_funnel([local], deps, { threshold: 0.5 })
67
+ assert.equal(err, null)
68
+ assert.equal(out.buckets.near.length, 0)
69
+ assert.equal(out.buckets.likely.length, 1)
70
+ assert.equal(out.buckets.likely[0].match.name, 'webapp-testing')
71
+ })
72
+
73
+ test('name-only match above the threshold → near', async () => {
74
+ const local = skill({ skill_md_sha: 'h_local_only', files: [{ path: 'SKILL.md', blob_sha: 'x1' }, { path: 'a.md', blob_sha: 'shared' }] })
75
+ const deps = make_deps({
76
+ name_results: [{ name: 'webapp-testing', candidates: [cand({ skill_md_sha: 'h_other', tree_sha: 't_remote' })] }],
77
+ manifests: [{ workspace: 'anthropics', name: 'webapp-testing', version: '1.0.0', tree_sha: 't_remote',
78
+ files: [{ path: 'SKILL.md', blob_sha: 'x1' }, { path: 'a.md', blob_sha: 'shared' }] }],
79
+ })
80
+ const [err, out] = await run_funnel([local], deps, { threshold: 0.5 })
81
+ assert.equal(err, null)
82
+ assert.equal(out.buckets.near.length, 1)
83
+ })
84
+
85
+ test('no hash/name match, semantic hit → possible', async () => {
86
+ const local = skill({ name: 'novel-skill', skill_md_sha: 'h_novel' })
87
+ const deps = make_deps({ semantic: async () => [null, [{ workspace_slug: 'someone', name: 'similar-thing', version: '2.1.0', score: 0.7 }]] })
88
+ const [err, out] = await run_funnel([local], deps)
89
+ assert.equal(err, null)
90
+ assert.equal(out.buckets.possible.length, 1)
91
+ assert.equal(out.buckets.possible[0].match.workspace, 'someone')
92
+ assert.equal(deps.calls.semantic, 1)
93
+ })
94
+
95
+ test('no match at all (no semantic dep) → unmatched', async () => {
96
+ const local = skill({ name: 'novel-skill', skill_md_sha: 'h_novel' })
97
+ const deps = make_deps({}) // no semantic_search
98
+ const [err, out] = await run_funnel([local], deps)
99
+ assert.equal(err, null)
100
+ assert.equal(out.buckets.unmatched.length, 1)
101
+ assert.equal(out.buckets.possible.length, 0)
102
+ })
103
+
104
+ test('Stage 1 is ONE batched call regardless of skill count (no N+1)', async () => {
105
+ const deps = make_deps({ results: [{ skill_md_sha: 'h_skillmd', candidates: [cand({ tree_sha: 't_local' })] }] })
106
+ await run_funnel([skill(), skill({ dir: '/b' }), skill({ dir: '/c' })], deps)
107
+ assert.equal(deps.calls.match, 1, 'the whole folder resolves in one /repos:match call')
108
+ })
109
+
110
+ // ── Tier 1: cross-version tree probe (spec 260629-03 §4.8) ───────────────────
111
+
112
+ test('local tree byte-identical to an OLDER published version → certified at that exact version', async () => {
113
+ // HEAD is 0.3.2 (a DIFFERENT tree — this is the drift the bug surfaced); the
114
+ // local copy is byte-identical to 0.3.0. The cross-version tree probe must
115
+ // pin it to 0.3.0 and certify it — NOT diff it against head and call it likely.
116
+ const local = skill({ name: 'tiny-gemini-image', skill_md_sha: 'h_old', tree_sha: 't_v030' })
117
+ const deps = make_deps({
118
+ // hash/name discovery still points at HEAD (head's tree differs from local)
119
+ name_results: [{ name: 'tiny-gemini-image', candidates: [cand({ name: 'tiny-gemini-image', version: '0.3.2', tree_sha: 't_v032', skill_md_sha: 'h_head' })] }],
120
+ tree_results: [{ tree_sha: 't_v030', candidates: [
121
+ { workspace: 'nicolasdao', name: 'tiny-gemini-image', version: '0.3.0', tree_sha: 't_v030', skill_md_sha: 'h_old', is_source_of_truth: true, latest_version: '0.3.2' },
122
+ ] }],
123
+ })
124
+ const [err, out] = await run_funnel([local], deps)
125
+ assert.equal(err, null)
126
+ assert.equal(out.buckets.certified.length, 1, 'an older byte-identical version is certified, not likely')
127
+ assert.equal(out.buckets.likely.length, 0)
128
+ assert.equal(out.buckets.near.length, 0)
129
+ assert.equal(out.buckets.certified[0].match.version, '0.3.0', 'pinned to the EXACT matching version, not head')
130
+ assert.equal(out.buckets.certified[0].match.latest_version, '0.3.2', 'carries the latest version so the caller can show "behind by"')
131
+ assert.equal(out.buckets.certified[0].match.is_latest, false)
132
+ assert.equal(deps.calls.manifests, 0, 'an exact tree match needs no manifest diff')
133
+ })
134
+
135
+ test('the local tree probe is sent in the SAME batched match call (no extra round-trip)', async () => {
136
+ const deps = make_deps({ tree_results: [{ tree_sha: 't_local', candidates: [
137
+ { workspace: 'anthropics', name: 'webapp-testing', version: '1.0.0', tree_sha: 't_local', is_source_of_truth: true, latest_version: '1.0.0' },
138
+ ] }] })
139
+ const [err, out] = await run_funnel([skill(), skill({ dir: '/b' })], deps)
140
+ assert.equal(err, null)
141
+ assert.equal(deps.calls.match, 1, 'hashes + names + tree_shas all ride one /repos:match call')
142
+ assert.equal(out.buckets.certified.length, 2)
143
+ assert.equal(out.buckets.certified[0].match.is_latest, true, 'matching head is is_latest:true')
144
+ })
145
+
146
+ test('backward-compat: a server with no tree_results still certifies a head-identical skill', async () => {
147
+ // Older server returns no tree_results; the funnel falls back to head tree
148
+ // equality from the hash/name candidate so existing behavior is preserved.
149
+ const deps = make_deps({ results: [{ skill_md_sha: 'h_skillmd', candidates: [cand({ tree_sha: 't_local' })] }] })
150
+ const [err, out] = await run_funnel([skill()], deps)
151
+ assert.equal(err, null)
152
+ assert.equal(out.buckets.certified.length, 1)
153
+ assert.equal(out.buckets.certified[0].match.version, '1.0.0')
154
+ })
155
+
156
+ test('a failed manifest batch becomes a warning, not a thrown error', async () => {
157
+ const deps = {
158
+ calls: {},
159
+ match: async () => [null, { results: [{ skill_md_sha: 'h_skillmd', candidates: [cand({ tree_sha: 't_remote' })] }], name_results: [] }],
160
+ manifests: async () => { throw new Error('network boom') },
161
+ }
162
+ const [err, out] = await run_funnel([skill()], deps)
163
+ assert.equal(err, null, 'one bad batch does not abort the run')
164
+ assert.ok(out.warnings.some(w => w.code === 'MANIFEST_FETCH_FAILED'))
165
+ // hash match still lands in near, just without a differing-file list.
166
+ assert.equal(out.buckets.near.length, 1)
167
+ assert.equal(out.buckets.near[0].differing_files, null)
168
+ })
169
+
170
+ test('certified is tree-SHA equality even via a NAME match (skill has no SKILL.md → null hash)', async () => {
171
+ // A skill.json-only local skill (no SKILL.md) → skill_md_sha null, so it can
172
+ // only be found by name — but if its tree is byte-identical it is still certified.
173
+ const local = skill({ name: 'no-skillmd', skill_md_sha: null, tree_sha: 't_local', files: [{ path: 'skill.json', blob_sha: 'sj' }] })
174
+ const deps = make_deps({ name_results: [{ name: 'no-skillmd', candidates: [cand({ name: 'no-skillmd', tree_sha: 't_local' })] }] })
175
+ const [err, out] = await run_funnel([local], deps)
176
+ assert.equal(err, null)
177
+ assert.equal(out.buckets.certified.length, 1, 'byte-identical (tree equal) is certified even when matched by name')
178
+ assert.equal(out.buckets.near.length, 0)
179
+ assert.equal(out.buckets.likely.length, 0)
180
+ })
181
+
182
+ test('a semantic search that REJECTS still lands the skill in unmatched (never silently dropped)', async () => {
183
+ const local = skill({ name: 'novel-skill', skill_md_sha: 'h_novel' })
184
+ const deps = make_deps({ semantic: async () => { throw new Error('search exploded') } })
185
+ const [err, out] = await run_funnel([local], deps)
186
+ assert.equal(err, null, 'a rejecting semantic lookup does not abort the run')
187
+ assert.equal(out.buckets.unmatched.length, 1, 'the skill is reported as unmatched, not dropped from data')
188
+ assert.equal(out.buckets.unmatched[0].local.name, 'novel-skill')
189
+ assert.ok(out.warnings.some(w => w.code === 'SEMANTIC_SEARCH_FAILED'))
190
+ })
191
+
192
+ test('diff_manifests classifies identical / modified / added / removed', () => {
193
+ const d = diff_manifests(
194
+ [{ path: 'SKILL.md', blob_sha: 'a' }, { path: 'keep', blob_sha: 'k' }, { path: 'local-only', blob_sha: 'z' }],
195
+ [{ path: 'SKILL.md', blob_sha: 'b' }, { path: 'keep', blob_sha: 'k' }, { path: 'remote-only', blob_sha: 'r' }],
196
+ )
197
+ assert.deepEqual(d.modified, ['SKILL.md'])
198
+ assert.deepEqual(d.identical, ['keep'])
199
+ assert.deepEqual(d.added, ['local-only'])
200
+ assert.deepEqual(d.removed, ['remote-only'])
201
+ assert.deepEqual(d.differing, ['SKILL.md', 'local-only', 'remote-only'].sort())
202
+ assert.equal(d.similarity, 1 / 4)
203
+ })
@@ -0,0 +1,129 @@
1
+ 'use strict'
2
+ // Local skill scanner + fileset normalization (spec 260629-03 §4.6).
3
+ //
4
+ // Walks a folder, discovers each skill (a dir containing SKILL.md and/or
5
+ // skill.json), and fingerprints it the SAME way the publish path does — so the
6
+ // locally-computed tree_sha lines up byte-for-byte with the catalog's stored
7
+ // tree_sha and a byte-identical skill matches as `certified`.
8
+ //
9
+ // Normalization is the #1 correctness risk (spec §6 #2): a globally-installed
10
+ // skill carries install cruft (.DS_Store, .git, agent lock files) and is often
11
+ // a symlink (multi-agent installs share one copy). If the local fileset differs
12
+ // from what the push path includes, the tree_sha never matches and identical
13
+ // skills look "different". So we reuse `collect_files` / the EXCLUDE rules the
14
+ // push path uses, and we resolve symlinked skill roots to their real content.
15
+
16
+ const fs = require('fs')
17
+ const path = require('path')
18
+ const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
19
+ const { collect_files } = require('../utils/file_collector')
20
+ const { hash_tree } = require('../utils/git_hash')
21
+
22
+ const has_manifest = (dir) =>
23
+ fs.existsSync(path.join(dir, 'SKILL.md')) || fs.existsSync(path.join(dir, 'skill.json'))
24
+
25
+ // Directories we never descend into while hunting for skill roots — the same
26
+ // install/VCS cruft the push fileset excludes, plus any dotdir.
27
+ const SKIP_DIRS = new Set(['node_modules', '.git'])
28
+
29
+ // Discover skill roots under `root`. A skill root is the FIRST directory on a
30
+ // path that has a SKILL.md/skill.json — we do NOT descend past it, so a skill's
31
+ // own references/ subdir is never mistaken for a separate skill. `fs.statSync`
32
+ // follows symlinks, so a symlinked skill dir (the multi-agent install case)
33
+ // resolves to its real target; we dedupe by realpath so a symlink + its target
34
+ // don't both count.
35
+ const find_skill_roots = (root) => {
36
+ const roots = []
37
+ const seen_real = new Set()
38
+
39
+ const add = (dir) => {
40
+ let real
41
+ try { real = fs.realpathSync(dir) } catch { real = dir }
42
+ if (seen_real.has(real)) return
43
+ seen_real.add(real)
44
+ roots.push(dir)
45
+ }
46
+
47
+ const walk = (dir) => {
48
+ if (has_manifest(dir)) { add(dir); return }
49
+ let entries
50
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
51
+ for (const ent of entries) {
52
+ if (SKIP_DIRS.has(ent.name) || ent.name.startsWith('.')) continue
53
+ const full = path.join(dir, ent.name)
54
+ let st
55
+ try { st = fs.statSync(full) } catch { continue } // follows symlinks; skip broken
56
+ if (st.isDirectory()) walk(full)
57
+ }
58
+ }
59
+
60
+ walk(root)
61
+ return roots
62
+ }
63
+
64
+ // Read the skill's declared name from skill.json (the field the catalog matches
65
+ // on); fall back to the directory basename when absent or unreadable.
66
+ const read_skill_name = (skill_dir) => {
67
+ const fallback = path.basename(skill_dir)
68
+ try {
69
+ const raw = fs.readFileSync(path.join(skill_dir, 'skill.json'), 'utf8')
70
+ const json = JSON.parse(raw)
71
+ return (json && typeof json.name === 'string' && json.name.trim()) ? json.name.trim() : fallback
72
+ } catch {
73
+ return fallback
74
+ }
75
+ }
76
+
77
+ // Fingerprint one skill root into the shape the funnel consumes.
78
+ const fingerprint_skill = (skill_dir) =>
79
+ catch_errors(`Failed to fingerprint skill at ${skill_dir}`, async () => {
80
+ const [err, files] = await collect_files(skill_dir)
81
+ if (err) throw e(`Failed to read skill files at ${skill_dir}`, err)
82
+
83
+ // Normalize separators to '/' so the fingerprint is identical to the
84
+ // stored (POSIX-path) tree regardless of the host OS.
85
+ const norm = files.map(f => ({ path: f.path.split(path.sep).join('/'), blob_sha: f.sha }))
86
+
87
+ // Manifest: [{ path, blob_sha }], sorted for stable comparison/diff.
88
+ const manifest = norm
89
+ .map(f => ({ path: f.path, blob_sha: f.blob_sha }))
90
+ .sort((a, b) => a.path.localeCompare(b.path))
91
+
92
+ // skill_md_sha = the blob hash of the ROOT SKILL.md (case-insensitive
93
+ // match, exactly like the server's compute_skill_md_sha). Null when the
94
+ // skill has no SKILL.md (it can then only name/semantic-match).
95
+ const skill_md_entry = manifest.find(f => f.path.toLowerCase() === 'skill.md')
96
+ const skill_md_sha = skill_md_entry ? skill_md_entry.blob_sha : null
97
+
98
+ // tree_sha = the whole-skill fingerprint, computed with the EXACT server
99
+ // algorithm (git_hash.hash_tree mirrors api/app/utils/git_objects).
100
+ const tree_sha = hash_tree(norm.map(f => ({ path: f.path, sha: f.blob_sha })))
101
+
102
+ return {
103
+ dir: skill_dir,
104
+ name: read_skill_name(skill_dir),
105
+ skill_md_sha,
106
+ tree_sha,
107
+ files: manifest,
108
+ file_count: manifest.length,
109
+ }
110
+ })
111
+
112
+ // Scan a folder → fingerprints for every skill found. Returns
113
+ // { skills: [...], errored: [{ dir, error }] } — a single unreadable skill never
114
+ // aborts the scan (CLAUDE.md no-silent-failures: failures are surfaced, not
115
+ // dropped).
116
+ const scan_folder = (folder) =>
117
+ catch_errors(`Failed to scan folder ${folder}`, async () => {
118
+ const roots = find_skill_roots(folder)
119
+ const skills = []
120
+ const errored = []
121
+ for (const dir of roots) {
122
+ const [err, skill] = await fingerprint_skill(dir)
123
+ if (err) errored.push({ dir, error: err })
124
+ else skills.push(skill)
125
+ }
126
+ return { skills, errored }
127
+ })
128
+
129
+ module.exports = { scan_folder, fingerprint_skill, find_skill_roots, has_manifest, read_skill_name }
@@ -0,0 +1,93 @@
1
+ 'use strict'
2
+ // Spec 260629-03 §4.6 — local skill scanner + fileset normalization.
3
+ //
4
+ // The load-bearing assertion (§6 #2): the scanner's locally-computed tree_sha
5
+ // must equal what the PUSH path produces for the same content — otherwise a
6
+ // byte-identical skill shows as "different" and the `certified` tier is broken.
7
+ // We prove that by computing the expected tree_sha/skill_md_sha with the
8
+ // SERVER's canonical git_objects (imported across the monorepo), independent of
9
+ // the CLI's mirror. We also prove cruft (.DS_Store) is excluded and a symlinked
10
+ // skill resolves to its real content (deduped).
11
+
12
+ const { test, before, after } = require('node:test')
13
+ const assert = require('node:assert/strict')
14
+ const fs = require('fs')
15
+ const os = require('os')
16
+ const path = require('path')
17
+
18
+ const { scan_folder, find_skill_roots, fingerprint_skill } = require('./scanner')
19
+ // The canonical server algorithm — the reference the CLI mirror must match.
20
+ const { hash_tree: canon_tree, hash_blob: canon_blob } = require('../../../api/app/utils/git_objects')
21
+
22
+ let root
23
+ const w = (p, content) => { fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, content) }
24
+
25
+ before(() => {
26
+ root = fs.mkdtempSync(path.join(os.tmpdir(), 'match-scan-'))
27
+
28
+ // alpha — a clean skill: SKILL.md + a nested reference file.
29
+ w(path.join(root, 'skills/alpha/SKILL.md'), '# Alpha\nDoes alpha things.\n')
30
+ w(path.join(root, 'skills/alpha/references/guide.md'), 'detailed guide\n')
31
+ w(path.join(root, 'skills/alpha/skill.json'), JSON.stringify({ name: 'alpha-skill', version: '1.0.0', type: 'skill' }))
32
+
33
+ // beta — carries install cruft that MUST be excluded from the fingerprint.
34
+ w(path.join(root, 'skills/beta/SKILL.md'), '# Beta\n')
35
+ w(path.join(root, 'skills/beta/.DS_Store'), 'macos cruft\n')
36
+
37
+ // gamma-real + a symlink to it — the multi-agent install shape.
38
+ w(path.join(root, 'links/gamma-real/SKILL.md'), '# Gamma\n')
39
+ w(path.join(root, 'links/gamma-real/extra.txt'), 'x\n')
40
+ fs.symlinkSync(path.join(root, 'links/gamma-real'), path.join(root, 'links/gamma'), 'dir')
41
+ })
42
+
43
+ after(() => { try { fs.rmSync(root, { recursive: true, force: true }) } catch {} })
44
+
45
+ // Expected fingerprint computed from a known fileset using the SERVER algorithm.
46
+ // canon_tree returns { sha, buffer } — we compare the hex sha.
47
+ const expected_tree = (entries) => canon_tree(entries.map(en => ({ path: en.path, sha: canon_blob(Buffer.from(en.content)) }))).sha
48
+
49
+ test('discovers each skill root and does not descend past it (references/ is not a separate skill)', () => {
50
+ const roots = find_skill_roots(path.join(root, 'skills'))
51
+ const names = roots.map(r => path.basename(r)).sort()
52
+ assert.deepEqual(names, ['alpha', 'beta'])
53
+ })
54
+
55
+ test('alpha: tree_sha + skill_md_sha equal the values the PUSH path would produce (server algorithm)', async () => {
56
+ const [err, skill] = await fingerprint_skill(path.join(root, 'skills/alpha'))
57
+ assert.equal(err, null)
58
+ assert.equal(skill.name, 'alpha-skill', 'name comes from skill.json')
59
+
60
+ const files = [
61
+ { path: 'SKILL.md', content: '# Alpha\nDoes alpha things.\n' },
62
+ { path: 'references/guide.md', content: 'detailed guide\n' },
63
+ { path: 'skill.json', content: JSON.stringify({ name: 'alpha-skill', version: '1.0.0', type: 'skill' }) },
64
+ ]
65
+ assert.equal(skill.tree_sha, expected_tree(files), 'CLI tree_sha must match the server hash_tree byte-for-byte')
66
+ assert.equal(skill.skill_md_sha, canon_blob(Buffer.from('# Alpha\nDoes alpha things.\n')), 'skill_md_sha = git-blob hash of SKILL.md')
67
+ assert.deepEqual([...skill.files.map(f => f.path)].sort(), ['SKILL.md', 'references/guide.md', 'skill.json'].sort())
68
+ })
69
+
70
+ test('beta: .DS_Store cruft is excluded from the manifest and the tree_sha', async () => {
71
+ const [err, skill] = await fingerprint_skill(path.join(root, 'skills/beta'))
72
+ assert.equal(err, null)
73
+ assert.ok(!skill.files.some(f => f.path.includes('.DS_Store')), '.DS_Store must not be in the manifest')
74
+ // tree_sha matches a tree of ONLY SKILL.md — proving the cruft never entered the hash.
75
+ assert.equal(skill.tree_sha, expected_tree([{ path: 'SKILL.md', content: '# Beta\n' }]))
76
+ })
77
+
78
+ test('a symlinked skill resolves to real content and is counted once (dedupe by realpath)', async () => {
79
+ const [err, result] = await scan_folder(path.join(root, 'links'))
80
+ assert.equal(err, null)
81
+ assert.equal(result.skills.length, 1, 'symlink + its target must not both count')
82
+ assert.equal(result.skills[0].tree_sha, expected_tree([
83
+ { path: 'SKILL.md', content: '# Gamma\n' },
84
+ { path: 'extra.txt', content: 'x\n' },
85
+ ]), 'fingerprint reflects the real (symlink-followed) content')
86
+ })
87
+
88
+ test('scan_folder surfaces all skills with no false errors', async () => {
89
+ const [err, result] = await scan_folder(path.join(root, 'skills'))
90
+ assert.equal(err, null)
91
+ assert.equal(result.skills.length, 2)
92
+ assert.equal(result.errored.length, 0)
93
+ })
@@ -0,0 +1,28 @@
1
+ 'use strict'
2
+ // Bounded-concurrency wrapper over core-async's `throttle` — the ONE place the
3
+ // match pipeline (and any future fan-out) imports concurrency from (spec
4
+ // 260629-03 §4.5). `throttle` keeps at most `limit` task thunks in flight and
5
+ // pulls the next the instant a slot frees (no fixed-batch head-of-line
6
+ // blocking). Crucially, a FAILED task resolves to `{ error }` instead of
7
+ // throwing — so one bad skill never aborts the whole scan. Callers MUST
8
+ // partition the `{ error }` results and surface them (never drop them silently
9
+ // — CLAUDE.md no-silent-failures).
10
+ //
11
+ // Bounded concurrency is also rate-limit safety: an unbounded fan-out of ~70
12
+ // manifest/blob fetches can trip the API RateLimited alarm (see memory
13
+ // project_rate_limit_observability_and_nudge). Keep the default modest.
14
+
15
+ const { throttle } = require('core-async')
16
+
17
+ const DEFAULT_CONCURRENCY = 6
18
+
19
+ // Deliberately NOT catch_errors-wrapped: it returns throttle's results array
20
+ // verbatim (order-preserving; a failed task is `{ error }`, not a throw) so
21
+ // callers keep the per-task error partition. Wrapping it would collapse that to
22
+ // a single [errors, result] and lose which task failed.
23
+ const bounded = (tasks, limit = DEFAULT_CONCURRENCY) => {
24
+ const n = Number.isInteger(limit) && limit > 0 ? limit : DEFAULT_CONCURRENCY
25
+ return throttle(Array.isArray(tasks) ? tasks : [], n)
26
+ }
27
+
28
+ module.exports = { bounded, DEFAULT_CONCURRENCY }
@@ -0,0 +1,53 @@
1
+ 'use strict'
2
+ // Spec 260629-03 §4.5 — bounded() over core-async throttle. The three load-
3
+ // bearing guarantees: never exceed the limit, complete every task in order, and
4
+ // a throwing task yields `{ error }` WITHOUT rejecting the whole batch (so one
5
+ // bad skill never aborts a match scan). Run with: node --test.
6
+
7
+ const { test } = require('node:test')
8
+ const assert = require('node:assert/strict')
9
+ const { bounded, DEFAULT_CONCURRENCY } = require('./concurrency')
10
+
11
+ test('never exceeds the limit, completes every task, preserves order', async () => {
12
+ let inflight = 0, peak = 0
13
+ const mk = (i) => async () => {
14
+ inflight++; peak = Math.max(peak, inflight)
15
+ await new Promise(r => setTimeout(r, 5))
16
+ inflight--
17
+ return i
18
+ }
19
+ const tasks = Array.from({ length: 20 }, (_, i) => mk(i))
20
+ const res = await bounded(tasks, 4)
21
+ assert.ok(peak <= 4, `peak concurrency ${peak} must not exceed the limit of 4`)
22
+ assert.deepEqual(res, Array.from({ length: 20 }, (_, i) => i), 'all tasks complete, order preserved')
23
+ })
24
+
25
+ test('a throwing task yields { error } without rejecting the batch', async () => {
26
+ const tasks = [
27
+ async () => 'ok-0',
28
+ async () => { throw new Error('boom-1') },
29
+ async () => 'ok-2',
30
+ ]
31
+ const res = await bounded(tasks, 2)
32
+ assert.equal(res[0], 'ok-0')
33
+ assert.ok(res[1] && res[1].error instanceof Error, 'failed task is partitioned as { error }')
34
+ assert.match(res[1].error.message, /boom-1/)
35
+ assert.equal(res[2], 'ok-2', 'sibling tasks still complete')
36
+ })
37
+
38
+ test('defaults to DEFAULT_CONCURRENCY and tolerates a bad limit', async () => {
39
+ assert.equal(DEFAULT_CONCURRENCY, 6)
40
+ let inflight = 0, peak = 0
41
+ const mk = () => async () => {
42
+ inflight++; peak = Math.max(peak, inflight)
43
+ await new Promise(r => setTimeout(r, 5))
44
+ inflight--
45
+ }
46
+ await bounded(Array.from({ length: 12 }, mk), 0) // 0 is invalid → falls back to the default
47
+ assert.ok(peak <= DEFAULT_CONCURRENCY, `bad limit must fall back to ${DEFAULT_CONCURRENCY}, peak was ${peak}`)
48
+ })
49
+
50
+ test('empty / non-array input resolves to an empty array', async () => {
51
+ assert.deepEqual(await bounded([], 4), [])
52
+ assert.deepEqual(await bounded(undefined, 4), [])
53
+ })
@@ -6,4 +6,32 @@ const hash_blob = (buf) => {
6
6
  return crypto.createHash('sha256').update(header).update(buf).digest('hex')
7
7
  }
8
8
 
9
- module.exports = { hash_blob }
9
+ // Git-tree hash over a flat entry list — an EXACT byte-for-byte mirror of the
10
+ // server's canonical `hash_tree` in api/app/utils/git_objects.js. The server
11
+ // builds the tree from the flat file list the CLI pushes, so the CLI never had
12
+ // a tree hash of its own; `happyskills match` needs one to reproduce a skill's
13
+ // catalog `tree_sha` locally and compare it (spec 260629-03 §4.6). Identity:
14
+ // equal tree_sha ⇒ byte-identical skill. Cross-package import is impossible
15
+ // (separate npm packages), so this is a deliberate mirror — if the server
16
+ // algorithm ever changes, change it here too (the e2e journey
17
+ // match-local-skills.spec.js proves the two stay byte-aligned end-to-end).
18
+ //
19
+ // Entries: [{ path, sha }] (sha = the file's blob hash). Sorted by
20
+ // path.localeCompare, serialized as `<mode> <path>\0<32-byte-binary-sha>`,
21
+ // wrapped in `tree <len>\0`, then sha256'd. mode defaults to '100644' (the
22
+ // server never sends a mode either).
23
+ const hash_tree = (entries) => {
24
+ const sorted = [...entries].sort((a, b) => a.path.localeCompare(b.path))
25
+ const parts = sorted.map(entry => {
26
+ const mode = entry.mode || '100644'
27
+ const prefix = Buffer.from(`${mode} ${entry.path}\0`)
28
+ const sha_bin = Buffer.from(entry.sha, 'hex')
29
+ return Buffer.concat([prefix, sha_bin])
30
+ })
31
+ const content = Buffer.concat(parts)
32
+ const header = Buffer.from(`tree ${content.length}\0`)
33
+ const full = Buffer.concat([header, content])
34
+ return crypto.createHash('sha256').update(full).digest('hex')
35
+ }
36
+
37
+ module.exports = { hash_blob, hash_tree }