happyskills 1.12.1 → 1.13.1
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 +14 -0
- package/package.json +1 -1
- package/src/api/client.js +20 -2
- package/src/api/client.test.js +153 -0
- package/src/auth/token_store.js +10 -1
- package/src/commands/list.js +177 -94
- package/src/commands/update.js +155 -94
- package/src/index.js +23 -0
- package/src/integration/list_scopes.test.js +171 -0
- package/src/integration/update_scopes.test.js +100 -0
- package/src/ui/envelope.js +21 -1
- package/src/ui/envelope.test.js +63 -0
- package/src/utils/skill_update_check.js +150 -0
- package/src/utils/skill_update_check.test.js +118 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.13.1] - 2026-06-29
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- 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.)
|
|
15
|
+
|
|
16
|
+
## [1.13.0] - 2026-06-29
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- Add `list --all-scopes`, which merges project-local and global skills into one listing instead of showing a single scope at a time (`list` = project, `list -g` = global). Every entry is tagged with `scope` (`local`/`global`) and `native` (`true` for managed/draft HappySkills skills, `false` for manually-added external/agent-orphan skills), and the human table gains **Scope** and **Native** columns — so you can see at a glance where each skill is installed and whether it's a native HappySkills skill or one added by hand. In this mode `data.skills` becomes an array (a skill installed in both scopes surfaces once per scope, rather than colliding on a shared `owner/name` key); the single-scope output shape is unchanged for backward compatibility.
|
|
21
|
+
- Add `update --all-scopes`, which updates outdated skills in **both** the project-local and global scopes in one run (local first, then global) and returns a per-scope report: `{ all_scopes: true, scopes: [{ scope: "local", … }, { scope: "global", … }], updated_count, outdated_count }`. Each `scopes[]` entry carries the same payload a single-scope update emits, so partial failure (e.g. local succeeds, global has an error) is visible per scope. Unlike `list --all-scopes`, this is opt-in, not the default — a write should not touch shared global state implicitly, so `update` stays project-local unless `-g` (global) or `--all-scopes` (both) is passed. The single-scope output shape is unchanged.
|
|
22
|
+
- Add passive skill-drift awareness, so you're told when your installed skills have fallen behind the registry without having to remember to check. Before each command, the CLI does a once-per-24h, fire-and-forget, zero-latency check (a synchronous read of a `~/.config/happyskills/skill-update-check.json` cache; a stale cache triggers a background refresh for next time, never blocking the current command) of installed root skills against the registry, spanning **both** the project-local and global locks so a globally-installed constellation is surfaced even during project-scoped work. Outdated skills appear two ways: a one-line nudge on stderr (text mode) and a `SKILLS_UPDATE_AVAILABLE` entry in the existing `warnings[]` array of every command's envelope (`--json` mode) — `data` is untouched and the envelope schema is unchanged. The suggested command is scope-aware (`update --all`, adding `-g` when global skills are behind). Only registry-advance (`outdated`) is reported — never a locally-bumped (`ahead`) or drifted skill. Disable with `HAPPYSKILLS_NO_SKILL_UPDATE_CHECK=1`.
|
|
23
|
+
|
|
10
24
|
## [1.12.1] - 2026-06-23
|
|
11
25
|
|
|
12
26
|
### Changed
|
package/package.json
CHANGED
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/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 }
|
package/src/commands/list.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
const path = require('path')
|
|
1
2
|
const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
|
|
2
3
|
const { read_lock, get_all_locked_skills } = require('../lock/reader')
|
|
3
4
|
const { verify_lock_disk_consistency, detect_ahead_state } = require('../lock/verify')
|
|
@@ -17,7 +18,10 @@ const HELP_TEXT = `Usage: happyskills list [options]
|
|
|
17
18
|
List installed skills.
|
|
18
19
|
|
|
19
20
|
Options:
|
|
20
|
-
-g, --global List globally installed skills
|
|
21
|
+
-g, --global List globally installed skills (~/.agents/skills/)
|
|
22
|
+
--all-scopes List BOTH project-local and global skills in one view,
|
|
23
|
+
each row tagged with its scope (local/global) and whether
|
|
24
|
+
it is a native HappySkills skill or a manually-added one
|
|
21
25
|
--json Output as JSON
|
|
22
26
|
|
|
23
27
|
Aliases: ls
|
|
@@ -25,16 +29,23 @@ Aliases: ls
|
|
|
25
29
|
Examples:
|
|
26
30
|
happyskills list
|
|
27
31
|
happyskills ls --json
|
|
28
|
-
happyskills ls -g
|
|
32
|
+
happyskills ls -g
|
|
33
|
+
happyskills ls --all-scopes --json`
|
|
29
34
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
+
// scope_tag — the public, machine-readable scope label for an entry.
|
|
36
|
+
const scope_tag = (is_global) => (is_global ? 'global' : 'local')
|
|
37
|
+
// scope_label — the human, title-cased scope label for the table.
|
|
38
|
+
const scope_label = (is_global) => (is_global ? 'Global' : 'Local')
|
|
35
39
|
|
|
36
|
-
|
|
37
|
-
|
|
40
|
+
// gather_scope — collect the full, render-ready inventory for ONE scope
|
|
41
|
+
// (project or global). Returns normalized "views" so the JSON and human
|
|
42
|
+
// renderers share one computation of status / type / enabled, rather than
|
|
43
|
+
// each re-deriving it. The four buckets mirror `list`'s data contract:
|
|
44
|
+
// managed_views → native, recorded in skills-lock.json (data.skills)
|
|
45
|
+
// draft_skills → native, scaffolded by `init`, not yet published (data.drafts)
|
|
46
|
+
// external_skills→ manual/foreign on disk (data.external)
|
|
47
|
+
// orphan_skills → manual/foreign dropped into an agent folder (data.agent_orphans)
|
|
48
|
+
const gather_scope = (is_global, project_root, agents_flag) => catch_errors('Gather scope failed', async () => {
|
|
38
49
|
const base_dir = skills_dir(is_global, project_root)
|
|
39
50
|
|
|
40
51
|
const [, lock_data] = await read_lock(lock_root(is_global, project_root))
|
|
@@ -42,7 +53,7 @@ const run = (args) => catch_errors('List failed', async () => {
|
|
|
42
53
|
const managed_entries = Object.entries(skills)
|
|
43
54
|
|
|
44
55
|
// Resolve agents and build enabled/disabled map for managed skills
|
|
45
|
-
const [, agents_result] = await resolve_agents(
|
|
56
|
+
const [, agents_result] = await resolve_agents(agents_flag, { global: is_global, project_root })
|
|
46
57
|
const agents = agents_result?.agents || []
|
|
47
58
|
const managed_short_names = managed_entries.map(([k]) => k.split('/')[1])
|
|
48
59
|
const [, enabled_map] = agents.length > 0
|
|
@@ -65,31 +76,13 @@ const run = (args) => catch_errors('List failed', async () => {
|
|
|
65
76
|
const [, agent_orphans] = await scan_agent_orphan_skills(AGENTS, is_global, project_root, all_known_names)
|
|
66
77
|
const orphan_skills = agent_orphans || []
|
|
67
78
|
|
|
68
|
-
if (managed_entries.length === 0 && draft_skills.length === 0 && external_skills.length === 0 && orphan_skills.length === 0) {
|
|
69
|
-
if (args.flags.json) {
|
|
70
|
-
print_json({ data: { skills: {}, drafts: [], external: [], agent_orphans: [] } })
|
|
71
|
-
return
|
|
72
|
-
}
|
|
73
|
-
print_info('No skills installed.')
|
|
74
|
-
return
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Resolve type for each managed entry from lock data or skill.json on disk
|
|
78
|
-
const resolve_type = async (name, data) => {
|
|
79
|
-
if (data.type) return data.type
|
|
80
|
-
const dir = skill_install_dir(base_dir, name.split('/')[1])
|
|
81
|
-
const json_path = require('path').join(dir, SKILL_JSON)
|
|
82
|
-
const [, manifest] = await read_json(json_path)
|
|
83
|
-
return manifest?.type || SKILL_TYPES.SKILL
|
|
84
|
-
}
|
|
85
|
-
|
|
86
79
|
// Compute drift AND ahead state up-front for every managed entry. Cheap
|
|
87
80
|
// (one skill.json read per skill, plus an optional CHANGELOG read) and lets
|
|
88
81
|
// both the JSON and human paths report identically.
|
|
89
82
|
//
|
|
90
83
|
// §10.5: drift is narrowed to genuine inconsistency (regression, missing
|
|
91
|
-
// files); the disk-greater-than-lock case is reported under
|
|
92
|
-
//
|
|
84
|
+
// files); the disk-greater-than-lock case is reported under status:ahead,
|
|
85
|
+
// not under drift.
|
|
93
86
|
const drift_by_skill = {}
|
|
94
87
|
const ahead_by_skill = {}
|
|
95
88
|
await Promise.all(managed_entries.map(async ([name, data]) => {
|
|
@@ -104,86 +97,167 @@ const run = (args) => catch_errors('List failed', async () => {
|
|
|
104
97
|
if (ahead && ahead.ahead) ahead_by_skill[name] = ahead
|
|
105
98
|
}))
|
|
106
99
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const [, exists] = await file_exists(dir)
|
|
113
|
-
const drift = drift_by_skill[name]
|
|
114
|
-
const ahead = ahead_by_skill[name]
|
|
115
|
-
let status
|
|
116
|
-
if (drift) status = 'drift'
|
|
117
|
-
else if (ahead) status = 'ahead'
|
|
118
|
-
else if (exists) status = 'installed'
|
|
119
|
-
else status = 'missing'
|
|
120
|
-
const source = data.requested_by?.includes('__root__') ? 'direct' : 'dep'
|
|
121
|
-
const type = await resolve_type(name, data)
|
|
122
|
-
const enabled = enabled_map?.get(short) ?? true
|
|
123
|
-
const entry = { version: data.version, type, source, status, enabled }
|
|
124
|
-
if (drift) entry.drift = { reason: drift.reason, lock_version: drift.expected, disk_version: drift.actual }
|
|
125
|
-
if (ahead) entry.ahead = {
|
|
126
|
-
lock_version: ahead.lock_version,
|
|
127
|
-
disk_version: ahead.disk_version,
|
|
128
|
-
has_changelog_entry: ahead.has_changelog_entry || false,
|
|
129
|
-
changelog_version: ahead.changelog_version || null
|
|
130
|
-
}
|
|
131
|
-
skills_map[name] = entry
|
|
132
|
-
}
|
|
133
|
-
const drafts = draft_skills.map(s => ({
|
|
134
|
-
name: s.name,
|
|
135
|
-
description: s.description || '',
|
|
136
|
-
version: s.version || null,
|
|
137
|
-
type: s.type || SKILL_TYPES.SKILL
|
|
138
|
-
}))
|
|
139
|
-
const external = external_skills.map(s => ({ name: s.name, description: s.description || '' }))
|
|
140
|
-
const agent_orphan_list = orphan_skills.map(s => ({
|
|
141
|
-
name: s.name,
|
|
142
|
-
description: s.description || '',
|
|
143
|
-
agents: s.agents
|
|
144
|
-
}))
|
|
145
|
-
print_json({ data: { skills: skills_map, drafts, external, agent_orphans: agent_orphan_list } })
|
|
146
|
-
return
|
|
100
|
+
const resolve_type = async (name, data) => {
|
|
101
|
+
if (data.type) return data.type
|
|
102
|
+
const dir = skill_install_dir(base_dir, name.split('/')[1])
|
|
103
|
+
const [, manifest] = await read_json(path.join(dir, SKILL_JSON))
|
|
104
|
+
return manifest?.type || SKILL_TYPES.SKILL
|
|
147
105
|
}
|
|
148
106
|
|
|
149
|
-
|
|
150
|
-
|
|
107
|
+
// Normalize each managed entry into a single render-ready view. Both the
|
|
108
|
+
// JSON and human renderers consume these, so status/type/enabled are
|
|
109
|
+
// computed exactly once.
|
|
110
|
+
const managed_views = await Promise.all(managed_entries.map(async ([name, data]) => {
|
|
151
111
|
const short = name.split('/')[1]
|
|
152
112
|
const dir = skill_install_dir(base_dir, short)
|
|
153
113
|
const [, exists] = await file_exists(dir)
|
|
154
114
|
const drift = drift_by_skill[name]
|
|
155
115
|
const ahead = ahead_by_skill[name]
|
|
156
|
-
let
|
|
157
|
-
if (drift)
|
|
158
|
-
else if (ahead)
|
|
159
|
-
else if (exists)
|
|
160
|
-
else
|
|
116
|
+
let status
|
|
117
|
+
if (drift) status = 'drift'
|
|
118
|
+
else if (ahead) status = 'ahead'
|
|
119
|
+
else if (exists) status = 'installed'
|
|
120
|
+
else status = 'missing'
|
|
161
121
|
const source = data.requested_by?.includes('__root__') ? 'direct' : 'dep'
|
|
162
122
|
const type = await resolve_type(name, data)
|
|
163
|
-
const display_name = type === SKILL_TYPES.KIT ? `${name} [kit]` : name
|
|
164
123
|
const enabled = enabled_map?.get(short) ?? true
|
|
165
|
-
|
|
166
|
-
|
|
124
|
+
return { name, short, version: data.version, type, source, status, enabled, drift, ahead }
|
|
125
|
+
}))
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
is_global,
|
|
129
|
+
managed_views,
|
|
130
|
+
draft_skills,
|
|
131
|
+
external_skills,
|
|
132
|
+
orphan_skills,
|
|
167
133
|
}
|
|
134
|
+
}).then(([errors, result]) => {
|
|
135
|
+
if (errors) throw e('Failed to gather scope', errors)
|
|
136
|
+
return result
|
|
137
|
+
})
|
|
168
138
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
139
|
+
// json_managed_entry — the canonical per-skill JSON shape, shared by the
|
|
140
|
+
// object form (single scope) and the array form (--all-scopes).
|
|
141
|
+
const json_managed_entry = (v) => {
|
|
142
|
+
const entry = { version: v.version, type: v.type, source: v.source, status: v.status, enabled: v.enabled }
|
|
143
|
+
if (v.drift) entry.drift = { reason: v.drift.reason, lock_version: v.drift.expected, disk_version: v.drift.actual }
|
|
144
|
+
if (v.ahead) entry.ahead = {
|
|
145
|
+
lock_version: v.ahead.lock_version,
|
|
146
|
+
disk_version: v.ahead.disk_version,
|
|
147
|
+
has_changelog_entry: v.ahead.has_changelog_entry || false,
|
|
148
|
+
changelog_version: v.ahead.changelog_version || null,
|
|
172
149
|
}
|
|
150
|
+
return entry
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const run = (args) => catch_errors('List failed', async () => {
|
|
154
|
+
if (args.flags._show_help) {
|
|
155
|
+
print_help(HELP_TEXT)
|
|
156
|
+
return process.exit(EXIT_CODES.SUCCESS)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const all_scopes = args.flags['all-scopes'] || false
|
|
160
|
+
const is_global = args.flags.global || false
|
|
161
|
+
const project_root = find_project_root()
|
|
162
|
+
|
|
163
|
+
// --all-scopes overrides -g: it always covers BOTH project and global.
|
|
164
|
+
const scope_flags = all_scopes ? [false, true] : [is_global]
|
|
165
|
+
const scopes = await Promise.all(scope_flags.map(g => gather_scope(g, project_root, args.flags.agents)))
|
|
166
|
+
|
|
167
|
+
const total_entries = scopes.reduce((n, s) =>
|
|
168
|
+
n + s.managed_views.length + s.draft_skills.length + s.external_skills.length + s.orphan_skills.length, 0)
|
|
173
169
|
|
|
174
|
-
|
|
175
|
-
|
|
170
|
+
if (total_entries === 0) {
|
|
171
|
+
if (args.flags.json) {
|
|
172
|
+
print_json({ data: all_scopes
|
|
173
|
+
? { skills: [], drafts: [], external: [], agent_orphans: [] }
|
|
174
|
+
: { skills: {}, drafts: [], external: [], agent_orphans: [] } })
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
print_info('No skills installed.')
|
|
178
|
+
return
|
|
176
179
|
}
|
|
177
180
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
+
if (args.flags.json) {
|
|
182
|
+
if (all_scopes) {
|
|
183
|
+
// Merged, collision-safe ARRAY form: a skill installed in BOTH scopes
|
|
184
|
+
// is genuinely two installs (separate lock files, possibly different
|
|
185
|
+
// versions / enabled state) and surfaces as two entries.
|
|
186
|
+
const skills = []
|
|
187
|
+
const drafts = []
|
|
188
|
+
const external = []
|
|
189
|
+
const agent_orphans = []
|
|
190
|
+
for (const s of scopes) {
|
|
191
|
+
const tag = scope_tag(s.is_global)
|
|
192
|
+
for (const v of s.managed_views) skills.push({ name: v.name, scope: tag, native: true, ...json_managed_entry(v) })
|
|
193
|
+
for (const d of s.draft_skills) drafts.push({ name: d.name, scope: tag, native: true, description: d.description || '', version: d.version || null, type: d.type || SKILL_TYPES.SKILL })
|
|
194
|
+
for (const x of s.external_skills) external.push({ name: x.name, scope: tag, native: false, description: x.description || '' })
|
|
195
|
+
for (const o of s.orphan_skills) agent_orphans.push({ name: o.name, scope: tag, native: false, description: o.description || '', agents: o.agents })
|
|
196
|
+
}
|
|
197
|
+
print_json({ data: { skills, drafts, external, agent_orphans } })
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Single-scope (default / -g): object-keyed `skills`, unchanged contract.
|
|
202
|
+
const s = scopes[0]
|
|
203
|
+
const skills_map = {}
|
|
204
|
+
for (const v of s.managed_views) skills_map[v.name] = json_managed_entry(v)
|
|
205
|
+
const drafts = s.draft_skills.map(d => ({
|
|
206
|
+
name: d.name,
|
|
207
|
+
description: d.description || '',
|
|
208
|
+
version: d.version || null,
|
|
209
|
+
type: d.type || SKILL_TYPES.SKILL,
|
|
210
|
+
}))
|
|
211
|
+
const external = s.external_skills.map(x => ({ name: x.name, description: x.description || '' }))
|
|
212
|
+
const agent_orphan_list = s.orphan_skills.map(o => ({
|
|
213
|
+
name: o.name,
|
|
214
|
+
description: o.description || '',
|
|
215
|
+
agents: o.agents,
|
|
216
|
+
}))
|
|
217
|
+
print_json({ data: { skills: skills_map, drafts, external, agent_orphans: agent_orphan_list } })
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ── Human output ──────────────────────────────────────────────────────
|
|
222
|
+
// In --all-scopes mode each row is prefixed with Scope + Native cells
|
|
223
|
+
// (after the Skill name); push_row handles that uniformly so the per-bucket
|
|
224
|
+
// loops only describe the trailing columns.
|
|
225
|
+
const rows = []
|
|
226
|
+
const push_row = (s_label, native_cell, name_cell, tail) => {
|
|
227
|
+
rows.push(all_scopes ? [name_cell, s_label, native_cell, ...tail] : [name_cell, ...tail])
|
|
228
|
+
}
|
|
229
|
+
for (const s of scopes) {
|
|
230
|
+
const s_label = scope_label(s.is_global)
|
|
231
|
+
for (const v of s.managed_views) {
|
|
232
|
+
let status_label
|
|
233
|
+
if (v.drift) status_label = red(`drift (${v.drift.reason})`)
|
|
234
|
+
else if (v.ahead) status_label = `ahead (disk ${v.ahead.disk_version})`
|
|
235
|
+
else if (v.status === 'missing') status_label = yellow('missing')
|
|
236
|
+
else status_label = 'installed'
|
|
237
|
+
const display_name = v.type === SKILL_TYPES.KIT ? `${v.name} [kit]` : v.name
|
|
238
|
+
const enabled_label = v.enabled ? green('enabled') : yellow('disabled')
|
|
239
|
+
push_row(s_label, green('yes'), display_name, [v.version, v.source, status_label, enabled_label])
|
|
240
|
+
}
|
|
241
|
+
for (const d of s.draft_skills) {
|
|
242
|
+
const type_label = d.type === SKILL_TYPES.KIT ? `${d.name} [kit]` : d.name
|
|
243
|
+
push_row(s_label, green('yes'), type_label, [d.version || '-', 'draft', 'unpublished', '-'])
|
|
244
|
+
}
|
|
245
|
+
for (const x of s.external_skills) {
|
|
246
|
+
push_row(s_label, yellow('no'), x.name, ['-', 'external', 'installed', '-'])
|
|
247
|
+
}
|
|
248
|
+
for (const o of s.orphan_skills) {
|
|
249
|
+
const agent_label = o.agents.map(a => a.name).join(', ')
|
|
250
|
+
push_row(s_label, yellow('no'), o.name, ['-', `agent-orphan (${agent_label})`, 'installed', '-'])
|
|
251
|
+
}
|
|
181
252
|
}
|
|
182
253
|
|
|
183
|
-
print_table(['Skill', 'Version', 'Source', 'Status', 'Enabled'], rows)
|
|
254
|
+
if (all_scopes) print_table(['Skill', 'Scope', 'Native', 'Version', 'Source', 'Status', 'Enabled'], rows)
|
|
255
|
+
else print_table(['Skill', 'Version', 'Source', 'Status', 'Enabled'], rows)
|
|
184
256
|
|
|
185
|
-
|
|
186
|
-
const
|
|
257
|
+
// Aggregate the advisory tails across every gathered scope.
|
|
258
|
+
const drift_count = scopes.reduce((n, s) => n + s.managed_views.filter(v => v.drift).length, 0)
|
|
259
|
+
const ahead_count = scopes.reduce((n, s) => n + s.managed_views.filter(v => v.ahead && !v.drift).length, 0)
|
|
260
|
+
const draft_count = scopes.reduce((n, s) => n + s.draft_skills.length, 0)
|
|
187
261
|
if (drift_count > 0) {
|
|
188
262
|
console.log()
|
|
189
263
|
print_warn(`${drift_count} skill(s) drifted: lock and on-disk skill.json disagree.`)
|
|
@@ -193,37 +267,46 @@ const run = (args) => catch_errors('List failed', async () => {
|
|
|
193
267
|
console.log()
|
|
194
268
|
print_info(`${ahead_count} skill(s) ahead of lock — bumped locally, not yet published. Run publish when ready.`)
|
|
195
269
|
}
|
|
196
|
-
if (
|
|
270
|
+
if (draft_count > 0) {
|
|
197
271
|
console.log()
|
|
198
|
-
print_info(`${
|
|
272
|
+
print_info(`${draft_count} draft(s) ready to publish — run ${code('happyskills release <name>')} to ship.`)
|
|
199
273
|
}
|
|
200
274
|
}).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
|
|
201
275
|
|
|
202
276
|
const schema = {
|
|
203
277
|
name: 'list',
|
|
204
278
|
audience: 'consumer',
|
|
205
|
-
purpose: 'List installed skills, showing version, source, drift/ahead status, and agent-enabled state.',
|
|
279
|
+
purpose: 'List installed skills, showing version, source, drift/ahead status, and agent-enabled state. With --all-scopes, merges project-local and global skills into one view, each tagged with its scope and whether it is a native HappySkills skill.',
|
|
206
280
|
mutation: false,
|
|
207
281
|
interactive_in_text_mode: false,
|
|
208
282
|
input: {
|
|
209
283
|
positional: [],
|
|
210
284
|
flags: [
|
|
211
285
|
{ name: 'global', alias: 'g', type: 'boolean', default: false, description: 'List globally installed skills' },
|
|
286
|
+
{ name: 'all-scopes', type: 'boolean', default: false, description: 'List both project-local and global skills, tagged with scope (local/global) and native (true/false)' },
|
|
212
287
|
{ name: 'json', type: 'boolean', default: false, description: 'Output as JSON' },
|
|
213
288
|
],
|
|
214
289
|
},
|
|
215
290
|
output: {
|
|
216
291
|
data_shape: {
|
|
292
|
+
// Default / -g (single scope): `skills` is an object keyed by owner/name.
|
|
217
293
|
skills: 'object<string, { version: string, type: string, source: string, status: string, enabled: boolean, drift?: object, ahead?: object }>',
|
|
218
294
|
drafts: 'array<{ name: string, description: string, version: string|null, type: string }>',
|
|
219
295
|
external: 'array<{ name: string, description: string }>',
|
|
220
296
|
agent_orphans: 'array<{ name: string, description: string, agents: string[] }>',
|
|
297
|
+
// With --all-scopes: `skills` becomes an ARRAY and every bucket entry
|
|
298
|
+
// carries `scope: "local"|"global"` and `native: boolean`.
|
|
299
|
+
// skills: 'array<{ name, scope, native: true, version, type, source, status, enabled, drift?, ahead? }>'
|
|
300
|
+
// drafts: 'array<{ name, scope, native: true, description, version, type }>'
|
|
301
|
+
// external: 'array<{ name, scope, native: false, description }>'
|
|
302
|
+
// agent_orphans:'array<{ name, scope, native: false, description, agents }>'
|
|
221
303
|
},
|
|
222
304
|
},
|
|
223
305
|
errors: [],
|
|
224
306
|
examples: [
|
|
225
307
|
'happyskills list',
|
|
226
308
|
'happyskills ls --json',
|
|
309
|
+
'happyskills ls --all-scopes --json',
|
|
227
310
|
],
|
|
228
311
|
}
|
|
229
312
|
|