happyskills 1.13.0 → 1.14.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 +12 -0
- package/package.json +2 -1
- package/src/api/client.js +20 -2
- package/src/api/client.test.js +153 -0
- package/src/api/repos.js +23 -1
- package/src/auth/token_store.js +10 -1
- package/src/commands/match.js +185 -0
- package/src/commands/match.test.js +119 -0
- package/src/constants/error_codes.js +5 -0
- package/src/constants/next_step_actions.js +3 -1
- package/src/constants/next_step_by_error_code.js +13 -0
- package/src/constants.js +1 -0
- package/src/match/diff.js +41 -0
- package/src/match/funnel.js +179 -0
- package/src/match/funnel.test.js +157 -0
- package/src/match/scanner.js +129 -0
- package/src/match/scanner.test.js +93 -0
- package/src/utils/concurrency.js +28 -0
- package/src/utils/concurrency.test.js +53 -0
- package/src/utils/git_hash.js +29 -1
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.14.0] - 2026-06-30
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- 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).
|
|
15
|
+
|
|
16
|
+
## [1.13.1] - 2026-06-29
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- Stop a transient session-refresh hiccup from becoming a terminal "session expired" auth failure on a **write** (`publish`/`release` and other authenticated writes). When your `id_token` had expired and the automatic refresh blipped (a network/cold-start failure on the refresh call), the CLI would fire the write with no token, the API would answer `401`, and that was rendered as `AUTH_REQUIRED` ("your session has expired … run `happyskills login`") — aborting the command even though an immediate retry succeeds. Now, when valid credentials exist on disk but the token simply couldn't be refreshed at that moment, a write surfaces a **retryable** `NETWORK_ERROR` (a `retry` `next_step`) instead of sending a tokenless request. The guard is deliberately narrow: **reads are unchanged** (a `GET` still degrades to anonymous/public results), a **corrupt/unreadable credentials file** still falls through to re-login (it is not turned into an infinite "please retry" loop), and the genuinely-not-signed-in case (no credentials) is unchanged. (Spec 260629-02.)
|
|
21
|
+
|
|
10
22
|
## [1.13.0] - 2026-06-29
|
|
11
23
|
|
|
12
24
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "happyskills",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.14.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/client.js
CHANGED
|
@@ -2,7 +2,7 @@ const { createHash } = require('crypto')
|
|
|
2
2
|
const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
|
|
3
3
|
const { API_URL, CLI_VERSION } = require('../constants')
|
|
4
4
|
const { ApiError, NetworkError, AuthError } = require('../utils/errors')
|
|
5
|
-
const { load_token } = require('../auth/token_store')
|
|
5
|
+
const { load_token, credentials_present } = require('../auth/token_store')
|
|
6
6
|
const { get_envelope, set_envelope } = require('../utils/intent')
|
|
7
7
|
|
|
8
8
|
const get_base_url = () => process.env.HAPPYSKILLS_API_URL || API_URL
|
|
@@ -29,10 +29,28 @@ const request = (method, path, options = {}) => catch_errors(`API ${method} ${pa
|
|
|
29
29
|
|
|
30
30
|
let token_attached = false
|
|
31
31
|
if (auth) {
|
|
32
|
-
const [, token_data] = await load_token()
|
|
32
|
+
const [token_err, token_data] = await load_token()
|
|
33
33
|
if (token_data) {
|
|
34
34
|
headers['Authorization'] = `Bearer ${token_data.id_token}`
|
|
35
35
|
token_attached = true
|
|
36
|
+
} else if (!token_err && credentials_present() && method !== 'GET' && method !== 'HEAD') {
|
|
37
|
+
// A WRITE (non-GET/HEAD) where load_token returned CLEANLY (no error) with no
|
|
38
|
+
// token, yet the credentials file still exists. That is uniquely the
|
|
39
|
+
// TRANSIENT-refresh-failure branch: a real session whose id_token expired and
|
|
40
|
+
// whose proactive refresh blipped (network / cold start) — load_token preserves
|
|
41
|
+
// the still-valid file and returns [null, null]. Permanent failures (Cognito
|
|
42
|
+
// rejected the token) and the no-refresh-token case both DELETE the file, and a
|
|
43
|
+
// corrupt/unreadable file returns [errors, undefined] (token_err set) — so
|
|
44
|
+
// guarding on `!token_err && file-present` isolates the genuinely-retryable case
|
|
45
|
+
// and lets a corrupt file fall through to a tokenless request (→ server 401 →
|
|
46
|
+
// re-login, which overwrites the bad file) rather than a dead retry loop.
|
|
47
|
+
// Firing a tokenless WRITE in the transient case makes the API answer 401 and
|
|
48
|
+
// the CLI render it as a terminal AUTH_REQUIRED "your session has expired" — the
|
|
49
|
+
// spurious-AUTH_REQUIRED bug (spec 260629-02). Surface a retryable error instead.
|
|
50
|
+
// Scoped to writes ONLY: a read (GET/HEAD) keeps its pre-existing behavior —
|
|
51
|
+
// proceed tokenless and degrade to anonymous/public results (spec §6.3: "keep
|
|
52
|
+
// the read-path behavior unchanged"); a write must not silently lose authorship.
|
|
53
|
+
throw new NetworkError('Your session could not be refreshed right now (a temporary issue). Please retry.')
|
|
36
54
|
}
|
|
37
55
|
}
|
|
38
56
|
|
package/src/api/client.test.js
CHANGED
|
@@ -122,3 +122,156 @@ describe('api client — 401 message never tells a signed-in user to log in', ()
|
|
|
122
122
|
})
|
|
123
123
|
})
|
|
124
124
|
})
|
|
125
|
+
|
|
126
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
127
|
+
// Transient backend failure on a write/publish path (spec 260629-02). The API no
|
|
128
|
+
// longer maps a transient verify/lookup hiccup to a 401 "session expired" — it
|
|
129
|
+
// returns a retryable 503 DB_UNAVAILABLE. The CLI must surface that as a RETRY
|
|
130
|
+
// affordance, NOT collapse it into an AUTH_REQUIRED / "run login" dead-end. This
|
|
131
|
+
// is the operator-facing half of the fix: an authenticated, authorized operator
|
|
132
|
+
// whose publish hit a blip is told to retry (and an immediate retry succeeds),
|
|
133
|
+
// instead of being sent down a pointless re-login path.
|
|
134
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
const with_503 = async (code, fn) => {
|
|
137
|
+
const orig = global.fetch
|
|
138
|
+
global.fetch = async () => ({
|
|
139
|
+
ok: false,
|
|
140
|
+
status: 503,
|
|
141
|
+
headers: { get: () => null },
|
|
142
|
+
json: async () => ({ error: { code, message: 'Authentication is temporarily unavailable. Please retry.' } }),
|
|
143
|
+
})
|
|
144
|
+
try { return await fn() } finally { global.fetch = orig }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
describe('api client — transient 503 on publish surfaces retry, not a login dead-end (spec 260629-02)', () => {
|
|
148
|
+
const { next_step_for_error } = require('../constants/next_step_by_error_code')
|
|
149
|
+
|
|
150
|
+
it('a 503 DB_UNAVAILABLE on push preserves the retryable code (NOT AUTH_REQUIRED)', async () => {
|
|
151
|
+
await with_503('DB_UNAVAILABLE', async () => {
|
|
152
|
+
const [errors] = await client.post('/repos/acme/deploy/push', { commit: 'x' }, { auth: false })
|
|
153
|
+
assert.ok(errors, 'expected an error tuple')
|
|
154
|
+
const err = errors.find(e => e && e.name === 'ApiError')
|
|
155
|
+
assert.ok(err, 'a 503 must surface as an ApiError, not an AuthError')
|
|
156
|
+
assert.equal(err.code, 'DB_UNAVAILABLE')
|
|
157
|
+
assert.notEqual(err.code, 'AUTH_REQUIRED')
|
|
158
|
+
assert.equal(err.status_code, 503)
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('the operator-facing next_step for that code is RETRY, not login', () => {
|
|
163
|
+
const next_step = next_step_for_error('DB_UNAVAILABLE', 'whatever', {})
|
|
164
|
+
assert.equal(next_step.action, 'retry')
|
|
165
|
+
assert.notEqual(next_step.action, 'login')
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
170
|
+
// The ACTUAL 2026-06-29 incident (spec 260629-02 Findings): the operator's session
|
|
171
|
+
// had expired and the CLI's proactive token refresh hit a TRANSIENT failure. On a
|
|
172
|
+
// transient refresh failure, load_token preserves the (still-valid) refresh_token
|
|
173
|
+
// on disk but returns null — so the client fired an authed request WITHOUT a token,
|
|
174
|
+
// the API answered 401 (no-token branch), and the CLI rendered it as AUTH_REQUIRED
|
|
175
|
+
// "session expired". An immediate retry (after the refresh blip cleared) succeeded.
|
|
176
|
+
//
|
|
177
|
+
// The fix: a real session whose token can't be refreshed *right now* (file present
|
|
178
|
+
// but load_token null) must surface a RETRYABLE error, never a tokenless request
|
|
179
|
+
// that turns into a terminal "session expired".
|
|
180
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
describe('api client — transient token-refresh failure → retry, never a tokenless 401 (spec 260629-02)', () => {
|
|
183
|
+
const auth_path = require.resolve('./auth')
|
|
184
|
+
|
|
185
|
+
const write_expired_token = (dir) => {
|
|
186
|
+
const token = {
|
|
187
|
+
id_token: 'expired-id-token',
|
|
188
|
+
access_token: 'expired-access-token',
|
|
189
|
+
refresh_token: 'still-valid-refresh-token',
|
|
190
|
+
expires_in: 1,
|
|
191
|
+
stored_at: new Date(Date.now() - 5000).toISOString(),
|
|
192
|
+
}
|
|
193
|
+
fs.mkdirSync(path.join(dir, 'happyskills'), { recursive: true })
|
|
194
|
+
fs.writeFileSync(path.join(dir, 'happyskills', 'credentials.json'), JSON.stringify(token), { mode: 0o600 })
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Make the refresh endpoint fail transiently (NetworkError) for both attempts.
|
|
198
|
+
const with_transient_refresh = async (fn) => {
|
|
199
|
+
const { NetworkError } = require('../utils/errors')
|
|
200
|
+
const orig = require.cache[auth_path]
|
|
201
|
+
require.cache[auth_path] = {
|
|
202
|
+
id: auth_path, filename: auth_path, loaded: true,
|
|
203
|
+
exports: { refresh: async () => [[new NetworkError('refresh endpoint unreachable')], null] },
|
|
204
|
+
}
|
|
205
|
+
try { return await fn() } finally {
|
|
206
|
+
if (orig) require.cache[auth_path] = orig; else delete require.cache[auth_path]
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
it('expired session + transient refresh blip on a push → retryable NetworkError, no tokenless request, NOT AUTH_REQUIRED', async () => {
|
|
211
|
+
await with_tmp_config(async (dir) => {
|
|
212
|
+
write_expired_token(dir)
|
|
213
|
+
await with_transient_refresh(async () => {
|
|
214
|
+
let fetched = false
|
|
215
|
+
const orig_fetch = global.fetch
|
|
216
|
+
global.fetch = async () => { fetched = true; return { ok: true, status: 200, headers: { get: () => null }, json: async () => ({ data: {} }) } }
|
|
217
|
+
try {
|
|
218
|
+
const [errors] = await client.post('/acme/deploy/push', { commit: 'z' })
|
|
219
|
+
assert.ok(errors, 'expected an error tuple')
|
|
220
|
+
assert.equal(fetched, false, 'must NOT fire a tokenless authed request when the session merely failed to refresh')
|
|
221
|
+
assert.ok(errors.find(e => e && e.name === 'NetworkError'), 'a transient refresh failure must surface as a retryable NetworkError')
|
|
222
|
+
assert.ok(!errors.find(e => e && e.name === 'AuthError'), 'must NOT surface AUTH_REQUIRED / "session expired" for a transient refresh blip')
|
|
223
|
+
} finally { global.fetch = orig_fetch }
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('NO credentials at all → proceeds tokenless (anonymous), unchanged', async () => {
|
|
229
|
+
await with_tmp_config(async () => {
|
|
230
|
+
// No creds file written → genuinely not signed in.
|
|
231
|
+
let fetched = false
|
|
232
|
+
const orig_fetch = global.fetch
|
|
233
|
+
global.fetch = async () => { fetched = true; return { ok: true, status: 200, headers: { get: () => null }, json: async () => ({ data: {} }) } }
|
|
234
|
+
try {
|
|
235
|
+
await client.get('/repos:search')
|
|
236
|
+
assert.equal(fetched, true, 'with no session, the request still goes out (server decides auth)')
|
|
237
|
+
} finally { global.fetch = orig_fetch }
|
|
238
|
+
})
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('transient refresh blip on a GET (read) → proceeds tokenless (anonymous), read-path unchanged (spec §6.3)', async () => {
|
|
242
|
+
await with_tmp_config(async (dir) => {
|
|
243
|
+
write_expired_token(dir)
|
|
244
|
+
await with_transient_refresh(async () => {
|
|
245
|
+
let fetched = false
|
|
246
|
+
const orig_fetch = global.fetch
|
|
247
|
+
global.fetch = async () => { fetched = true; return { ok: true, status: 200, headers: { get: () => null }, json: async () => ({ data: {} }) } }
|
|
248
|
+
try {
|
|
249
|
+
const [errors] = await client.get('/repos:search')
|
|
250
|
+
assert.equal(fetched, true, 'a read must still go out tokenless during a refresh blip (degrade to anonymous), not throw')
|
|
251
|
+
assert.ok(!errors || !errors.find(e => e && e.name === 'NetworkError'), 'reads keep their pre-existing anonymous-degrade behavior')
|
|
252
|
+
} finally { global.fetch = orig_fetch }
|
|
253
|
+
})
|
|
254
|
+
})
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
// A corrupt/unparseable credentials file ALSO leaves the file on disk while
|
|
258
|
+
// load_token yields no token — but it returns an ERROR (JSON.parse throws),
|
|
259
|
+
// NOT a clean null like the transient-refresh branch. It must NOT be treated as
|
|
260
|
+
// a retryable refresh blip (retrying never fixes a corrupt file → a dead loop).
|
|
261
|
+
// The actionable path is the pre-existing one: proceed tokenless so the server
|
|
262
|
+
// 401s and the user is steered to re-login (which overwrites the bad file).
|
|
263
|
+
it('corrupt credentials file → does NOT become a retry loop; proceeds tokenless so re-login is reachable', async () => {
|
|
264
|
+
await with_tmp_config(async (dir) => {
|
|
265
|
+
fs.mkdirSync(path.join(dir, 'happyskills'), { recursive: true })
|
|
266
|
+
fs.writeFileSync(path.join(dir, 'happyskills', 'credentials.json'), 'not-valid-json{{{', { mode: 0o600 })
|
|
267
|
+
let fetched = false
|
|
268
|
+
const orig_fetch = global.fetch
|
|
269
|
+
global.fetch = async () => { fetched = true; return { ok: true, status: 200, headers: { get: () => null }, json: async () => ({ data: {} }) } }
|
|
270
|
+
try {
|
|
271
|
+
const [errors] = await client.post('/acme/deploy/push', { commit: 'z' })
|
|
272
|
+
assert.equal(fetched, true, 'a corrupt creds file must proceed tokenless (server → 401 → login), not throw a retryable error')
|
|
273
|
+
assert.ok(!errors || !errors.find(e => e && e.name === 'NetworkError'), 'a corrupt creds file must NOT be reported as a transient/retryable refresh failure')
|
|
274
|
+
} finally { global.fetch = orig_fetch }
|
|
275
|
+
})
|
|
276
|
+
})
|
|
277
|
+
})
|
package/src/api/repos.js
CHANGED
|
@@ -213,4 +213,26 @@ const semantic_search = (query, options = {}) => catch_errors('Semantic search f
|
|
|
213
213
|
return data
|
|
214
214
|
})
|
|
215
215
|
|
|
216
|
-
|
|
216
|
+
// POST /repos:match — catalog matching for `happyskills match` (spec 260629-03).
|
|
217
|
+
// ONE batch call for the whole folder's hashes + names. Returns
|
|
218
|
+
// { results: [{ skill_md_sha, candidates }], name_results: [{ name, candidates }] }
|
|
219
|
+
// (two keys, so the client's single-key unwrap leaves it intact).
|
|
220
|
+
const match = (hashes, names) => catch_errors('Catalog match lookup failed', async () => {
|
|
221
|
+
const [errors, data] = await client.post('/repos:match', { hashes, names: names || [] })
|
|
222
|
+
if (errors) throw errors[errors.length - 1]
|
|
223
|
+
return data
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
// POST /repos:manifests — per-file manifests for a narrowed candidate set.
|
|
227
|
+
// unwrap:false so we can surface the envelope's warnings (a repo we couldn't
|
|
228
|
+
// read becomes a warning, not a thrown error) alongside the manifests.
|
|
229
|
+
const manifests = (repo_versions) => catch_errors('Manifests lookup failed', async () => {
|
|
230
|
+
const [errors, raw] = await client.post('/repos:manifests', { repo_versions }, { unwrap: false })
|
|
231
|
+
if (errors) throw errors[errors.length - 1]
|
|
232
|
+
return {
|
|
233
|
+
manifests: (raw && raw.data && raw.data.manifests) || [],
|
|
234
|
+
warnings: (raw && raw.warnings) || [],
|
|
235
|
+
}
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
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 }
|
package/src/auth/token_store.js
CHANGED
|
@@ -97,6 +97,15 @@ const load_token = () => catch_errors('Failed to load token', async () => {
|
|
|
97
97
|
return data
|
|
98
98
|
})
|
|
99
99
|
|
|
100
|
+
// Synchronous presence check for the credentials file. Used by the API client to
|
|
101
|
+
// tell "no session at all" (file absent) apart from "a real session whose token
|
|
102
|
+
// could not be refreshed right now" (file present but load_token returned null —
|
|
103
|
+
// the transient-refresh-failure branch preserves the file, whereas permanent /
|
|
104
|
+
// no-refresh-token failures delete it). See spec 260629-02.
|
|
105
|
+
const credentials_present = () => {
|
|
106
|
+
try { return fs.existsSync(credentials_path()) } catch { return false }
|
|
107
|
+
}
|
|
108
|
+
|
|
100
109
|
const clear_token = () => catch_errors('Failed to clear token', async () => {
|
|
101
110
|
const creds_path = credentials_path()
|
|
102
111
|
try {
|
|
@@ -115,4 +124,4 @@ const require_token = async () => {
|
|
|
115
124
|
return data
|
|
116
125
|
}
|
|
117
126
|
|
|
118
|
-
module.exports = { save_token, load_token, clear_token, require_token }
|
|
127
|
+
module.exports = { save_token, load_token, clear_token, require_token, credentials_present }
|
|
@@ -0,0 +1,185 @@
|
|
|
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
|
+
if (data.near.length) {
|
|
135
|
+
print_info('\nSkills with small differences:')
|
|
136
|
+
for (const en of data.near) {
|
|
137
|
+
const where = en.match ? `${en.match.workspace}/${en.match.name}` : '(catalog)'
|
|
138
|
+
const diff = (en.differing_files && en.differing_files.length)
|
|
139
|
+
? `differs in: ${en.differing_files.join(', ')}`
|
|
140
|
+
: 'differs'
|
|
141
|
+
print_info(` ${en.local.name} → ${where} — ${diff}`)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (data.errored.length) {
|
|
145
|
+
print_warn(`\n${data.errored.length} skill(s) could not be read:`)
|
|
146
|
+
for (const en of data.errored) print_warn(` ${en.skill}: ${en.error}`)
|
|
147
|
+
}
|
|
148
|
+
print_info('\nNext: choose which matched skills to add to your favorites, or group them into a kit.')
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const schema = {
|
|
152
|
+
name: 'match',
|
|
153
|
+
audience: 'consumer',
|
|
154
|
+
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.',
|
|
155
|
+
mutation: false,
|
|
156
|
+
interactive_in_text_mode: false,
|
|
157
|
+
owner_skill: 'happyskills-search',
|
|
158
|
+
input: {
|
|
159
|
+
positional: [
|
|
160
|
+
{ name: 'folder', required: true, type: 'string', pattern: '<path>' },
|
|
161
|
+
],
|
|
162
|
+
flags: [
|
|
163
|
+
{ name: 'json', type: 'boolean', default: false },
|
|
164
|
+
{ name: 'concurrency', type: 'number', default: DEFAULT_CONCURRENCY },
|
|
165
|
+
{ name: 'threshold', type: 'number', default: 0.5 },
|
|
166
|
+
],
|
|
167
|
+
},
|
|
168
|
+
output: {
|
|
169
|
+
data_shape: {
|
|
170
|
+
certified: 'array', near: 'array', likely: 'array',
|
|
171
|
+
possible: 'array', unmatched: 'array', errored: 'array',
|
|
172
|
+
summary: 'object',
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
errors: [
|
|
176
|
+
{ code: 'MATCH_FOLDER_NOT_FOUND', next_step: { kind: 'recovery', action: 'retry' } },
|
|
177
|
+
{ code: 'MATCH_NO_SKILLS_FOUND', next_step: { kind: 'recovery', action: 'retry' } },
|
|
178
|
+
],
|
|
179
|
+
examples: [
|
|
180
|
+
'happyskills match ./skills',
|
|
181
|
+
'happyskills match ~/.claude/skills --json',
|
|
182
|
+
],
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
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) => {
|