happyskills 0.54.0 → 1.0.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +32 -2
  2. package/package.json +1 -1
  3. package/src/api/auth.js +18 -2
  4. package/src/api/client.js +29 -3
  5. package/src/api/feedback.js +14 -5
  6. package/src/api/repos.js +28 -10
  7. package/src/api/translate.js +90 -0
  8. package/src/commands/delete.js +15 -1
  9. package/src/commands/feedback.js +4 -4
  10. package/src/commands/init.js +5 -1
  11. package/src/commands/install.js +58 -32
  12. package/src/commands/postlex.js +74 -38
  13. package/src/commands/postlex.test.js +96 -18
  14. package/src/commands/pull.js +5 -1
  15. package/src/commands/reconcile.js +52 -4
  16. package/src/commands/release.js +45 -15
  17. package/src/commands/schema.js +179 -0
  18. package/src/commands/search.js +27 -23
  19. package/src/commands/search.test.js +59 -33
  20. package/src/commands/uninstall.js +20 -11
  21. package/src/commands/validate.js +33 -11
  22. package/src/constants/error_codes.js +197 -0
  23. package/src/constants/exit_codes.js +54 -0
  24. package/src/constants/next_step_actions.js +133 -0
  25. package/src/constants/next_step_by_error_code.js +249 -0
  26. package/src/constants.js +2 -1
  27. package/src/index.js +51 -7
  28. package/src/integration/api_envelope.test.js +499 -0
  29. package/src/integration/bump.test.js +13 -4
  30. package/src/integration/cli.test.js +169 -147
  31. package/src/integration/drift.test.js +16 -4
  32. package/src/integration/install_fresh.test.js +37 -29
  33. package/src/integration/reconcile.test.js +77 -56
  34. package/src/integration/release.test.js +48 -31
  35. package/src/integration/schema.test.js +167 -0
  36. package/src/schema/envelope.schema.json +73 -0
  37. package/src/schema/envelope_test_helpers.js +94 -0
  38. package/src/schema/envelope_validator.js +239 -0
  39. package/src/schema/envelope_validator.test.js +333 -0
  40. package/src/ui/envelope.js +171 -0
  41. package/src/ui/output.js +66 -2
  42. package/src/utils/errors.js +116 -47
package/CHANGELOG.md CHANGED
@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.0.1] - 2026-06-01
11
+
12
+ ### Fixed
13
+
14
+ - Fix the post-rerank clarification step in `postlex`: the `clarify_triggered` telemetry beacon and the text-mode clarification prompt checked for a `clarify` action and read `suggested_questions` at the wrong depth, but the command emits `clarify_query` with the questions nested under `next_step.context` — so neither fired. The `--json` envelope was already correct; this restores the CLI's own telemetry and human-readable clarification output.
15
+
16
+ ## [1.0.0] - 2026-05-29
17
+
18
+ ### Added
19
+
20
+ - **Canonical six-key response envelope on every `--json` command** (spec 260525-cli-default-json). All structured output now emits a canonical six-key shape — `{ ok, data, error, next_step, warnings, meta }` — with `ok` derived from error presence, `data` always an object, and the process exit code mirrored into `meta.exit_code`. Agents can determine success/failure and the recovery path without parsing prose.
21
+ - **`happyskills schema --json` command** — emits a machine-readable description of the entire CLI surface: every command, the closed `error_codes`, `next_step_actions`, and `next_step_kinds` enums. A generic agent can discover the full CLI contract in one call.
22
+ - **Closed enums for the envelope** — `error.code` (state/auth/network/validation/etc.), `next_step.kind` (six kinds: recovery, clarification, decision, confirmation, continuation, routing), and `next_step.action` (~27 actions). Shipped as `cli/src/constants/{error_codes,next_step_actions}.js`, kept byte-identical with the API.
23
+ - **Hand-rolled envelope validator** (`cli/src/schema/envelope_validator.js`) — enforces the closed schema, the `dependentRequired` clusters (code+message, kind+action+instructions), and the derived invariants. No new runtime dependency.
24
+ - **Mode resolution** — `--json` / `--text` explicit flags, `CI=true` and non-TTY auto-flip to JSON, interactive TTY defaults to text. `--json` + `--text` together is a usage error.
25
+
26
+ ### Changed
27
+
28
+ - **BREAKING: `--json` output format.** Every command's JSON output moved from the legacy `{ data }` / `{ error: { code, message, exit_code } }` shapes to the canonical six-key response envelope. `exit_code` now lives on `meta.exit_code`, not inside `error`. Consumers that parsed the old shapes must update — this is why the release is `1.0.0`.
29
+ - **CLI consumes the new API envelope** while remaining backward-compatible with the current (pre-envelope) API. The `search`, `feedback`, and device-login clients detect the envelope and translate; against the old API they fall through to the legacy path, so the new CLI works against both.
30
+ - **`error.code` is a closed enum end-to-end** — unknown codes from a newer API coerce to `UNKNOWN_CODE` with the original preserved under `error.details.original_code`.
31
+ - **Per-command `next_step` recoveries** now use the closed action enum across `install`, `reconcile`, `release`, `search`, `postlex`, `validate`, `delete`, and `feedback` (e.g. `VERSION_NOT_FOUND` → `pick_version`, `LOCAL_EDITS_PRESENT` → `confirm_discard_or_snapshot_first`, `DRIFT_DETECTED` → `reconcile_first`).
32
+ - **`install --fresh` pre-flight reordered** so the local-edit safety check runs before the registry call — local edits are protected even when the registry is unreachable.
33
+
34
+ ### Fixed
35
+
36
+ - **`error.details` is now propagated** from API errors into the envelope (previously dropped) — e.g. `RATE_LIMITED`'s `retry_after_seconds` now reaches the caller.
37
+ - **Batch per-row failures fold into `data.results`** for `install` and `uninstall` instead of a silently-dropped top-level `errors` key — each row carries its own `status` and `error`.
38
+ - **Exit-code mirror corrected** for `pull --rebase` (success-with-followup no longer forces a non-zero exit) and `postlex` (a retry envelope's process exit now matches its `meta.exit_code`).
39
+
10
40
  ## [0.54.0] - 2026-05-26
11
41
 
12
42
  ### Added
@@ -157,14 +187,14 @@ This release combines two streams of work: **spec 260523-02 (Skill Update Determ
157
187
  ## [0.47.0] - 2026-05-21
158
188
 
159
189
  ### Added
160
- - Add `--with-rerank` flag to `happyskills search`. When set, the CLI passes `with_rerank_digests=true` to `POST /repos:search` (via `dispatch_search()`) and wraps the `--json` response in the universal envelope shape `{ data, error, next_step }`. The `data` payload gains four new fields alongside the existing `results`: `rerank_digests`, `rerank_system_prompt`, `rerank_prompt_version`, `rerank_response_schema`, plus `formulated_query`. `next_step.action` is one of `rank_digests_inline` (digests came back — agent ranks them and pipes to `postlex`), `clarify` (semantic match_notice fired and clarification budget remains — agent uses its native question mechanism with a calibrated narrowing question), or `present_to_user` (budget exhausted / non-semantic mode — render results). Silently ignored on non-semantic dispatches (slug-shape, scoped, exact). Combining `--with-rerank --exact` exits 2 (`USAGE_ERROR`). Intended for use inside an agentic session — see the `happyskills-help@0.3.0` skill's `references/discovery-protocol.md` for the envelope contract.
190
+ - Add `--with-rerank` flag to `happyskills search`. When set, the CLI passes `with_rerank_digests=true` to `POST /repos:search` (via `dispatch_search()`) and wraps the `--json` response in the canonical six-key response envelope shape `{ data, error, next_step }`. The `data` payload gains four new fields alongside the existing `results`: `rerank_digests`, `rerank_system_prompt`, `rerank_prompt_version`, `rerank_response_schema`, plus `formulated_query`. `next_step.action` is one of `rank_digests_inline` (digests came back — agent ranks them and pipes to `postlex`), `clarify` (semantic match_notice fired and clarification budget remains — agent uses its native question mechanism with a calibrated narrowing question), or `present_to_user` (budget exhausted / non-semantic mode — render results). Silently ignored on non-semantic dispatches (slug-shape, scoped, exact). Combining `--with-rerank --exact` exits 2 (`USAGE_ERROR`). Intended for use inside an agentic session — see the `happyskills-help@0.3.0` skill's `references/discovery-protocol.md` for the envelope contract.
161
191
  - Add `--clarification-turns-used <N>` companion flag on `search`. Carries the clarification budget (0–2, default 0) across re-searches in the discovery protocol's clarification flow. The CLI clamps to the hard cap of 2 and emits `present_to_user` (instead of `clarify`) once the budget is spent.
162
192
  - Add new `happyskills postlex` subcommand — deterministic post-lex finalization for the discovery protocol. Takes the LLM's `ranking[]` JSON plus the original `data[]` array (from stdin via `--ranking -`, or from separate `--ranking <file>` + `--data <file>` paths), applies the canonical 30-line slug-overlap promotion algorithm, and emits a `next_step` envelope (`present_to_user` on success, `clarify` if the post-rerank top results are still weak and budget remains, `retry_rank` with an `input_template` when the ranking fails schema validation). Stateless — the agent carries all cross-call state via `--clarification-turns-used` and `next_step.context`. Human-readable output renders only the LLM-ranked subset (the unranked tail is not shown — the LLM chose not to rank those rows). Refuses to crash on out-of-range `candidate_id` values: invalid entries are dropped with a stderr warning; if every entry drops out, exit 1.
163
193
  - Add `cli/src/utils/slug_tokens.js` — STOP_TOKENS + `slug_tokens()` + `slug_token_set()` + `compute_lex_tier()`. Byte-identical mirror of `api/app/utils/slug_tokens.js`. The companion test (`slug_tokens.test.js`) cross-imports the API canonical version and asserts STOP_TOKENS set-equality in both directions — fails CI loud if either side drifts. This is the load-bearing invariant from spec 260521-01 v2 § 6: if the CLI's view of which candidate is "exact" disagrees with the API's slug-boost behavior, the post-lex stage promotes the wrong candidate (or none).
164
194
  - Add `cli/src/api/telemetry.js` — fire-and-forget client for `POST /telemetry/discovery`. 2-second `AbortController` timeout; all errors swallowed; never affects exit codes. Called from `search` (events `rerank_started` when emitting `rank_digests_inline`, `clarify_triggered` when emitting `clarify`, `clarify_completed` when re-running after a clarify cycle) and from `postlex` (event `rerank_completed` after the algorithm runs, plus `clarify_triggered` when emitting a post-rerank clarify). Server-side aggregation in CloudWatch Insights drives the two key signals: postlex fire rate (0.5%–5% target) and protocol completion rate (≥85% target — `count(rerank_completed)/count(rerank_started)`).
165
195
 
166
196
  ### Changed
167
- - `search --json` output is wrapped in the universal envelope `{ data, error, next_step }` when (and only when) `--with-rerank` is set. Plain `search --json` (without the flag) keeps its existing `{ data: { … } }` shape for backward compatibility — no consumer of the existing JSON contract sees a change.
197
+ - `search --json` output is wrapped in the canonical six-key response envelope `{ data, error, next_step }` when (and only when) `--with-rerank` is set. Plain `search --json` (without the flag) keeps its existing `{ data: { … } }` shape for backward compatibility — no consumer of the existing JSON contract sees a change.
168
198
  - Argument parser (`parse_args` in `cli/src/index.js`) now treats a literal `-` as a flag value instead of as the prefix of another flag. This is the standard Unix stdin sentinel and lets `happyskills postlex --ranking -` parse correctly. Conservative change: `next.startsWith('-') && next !== '-'` is the new gate, so any flag whose value would previously have been swallowed and replaced with `true` because the next arg happened to be `-` now correctly receives `'-'`. No existing command relied on the previous behavior.
169
199
 
170
200
  ## [0.46.1] - 2026-05-20
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.54.0",
3
+ "version": "1.0.1",
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)",
package/src/api/auth.js CHANGED
@@ -32,9 +32,25 @@ const device_start = () => catch_errors('Device auth start failed', async () =>
32
32
  })
33
33
 
34
34
  const device_token = (device_code) => catch_errors('Device auth token failed', async () => {
35
- const [errors, data] = await client.post('/auth/device/token', { device_code }, { auth: false })
35
+ // Opt out of auto-unwrap so the caller can detect AUTHORIZATION_PENDING
36
+ // via the envelope's `error.code` slot on HTTP 202 (spec § 15.3.2). The
37
+ // new envelope keeps `error.code` at the same path the legacy ad-hoc
38
+ // body used, but moves the success token payload under `data` — so we
39
+ // flatten on success here and surface the error envelope on pending.
40
+ const [errors, raw] = await client.post('/auth/device/token', { device_code }, { auth: false, unwrap: false })
36
41
  if (errors) throw errors[errors.length - 1]
37
- return data
42
+ if (raw && typeof raw === 'object' && 'meta' in raw) {
43
+ if (raw.error && raw.error.code) {
44
+ // Pending / expired — surface the error slot so the poller can
45
+ // branch on error.code (works for both old + new shapes).
46
+ return { error: raw.error }
47
+ }
48
+ // Success: hoist token fields out of data so save_token sees the
49
+ // same shape it always has.
50
+ return raw.data || {}
51
+ }
52
+ // Legacy fallback (older API deploys without the envelope).
53
+ return raw
38
54
  })
39
55
 
40
56
  const refresh = (refresh_token) => catch_errors('Token refresh failed', async () => {
package/src/api/client.js CHANGED
@@ -69,9 +69,16 @@ const request = (method, path, options = {}) => catch_errors(`API ${method} ${pa
69
69
  if (!res.ok) {
70
70
  const err_code = data?.error?.code || 'UNKNOWN'
71
71
  const err_msg = data?.error?.message || `Request failed with status ${res.status}`
72
+ // Spec § 15.3.3 — propagate the API's error.details into the CLI's
73
+ // thrown ApiError. Previously dropped on the floor; RATE_LIMITED
74
+ // endpoints carry retry_after_seconds / max_per_window / window_hours
75
+ // inside details that never reached the user.
76
+ const err_details = data?.error?.details
72
77
 
73
78
  if (res.status === 401) {
74
- throw new AuthError(err_msg)
79
+ const err = new AuthError(err_msg)
80
+ if (err_details) err.details = err_details
81
+ throw err
75
82
  }
76
83
 
77
84
  if (res.status === 429) {
@@ -81,18 +88,37 @@ const request = (method, path, options = {}) => catch_errors(`API ${method} ${pa
81
88
  : 'API rate limit reached. Please wait and try again.'
82
89
  const err = new ApiError(retry_msg, 429, 'RATE_LIMITED')
83
90
  err.retry_after = retry_after ? parseInt(retry_after) : null
91
+ if (err_details) err.details = { ...err.details, ...err_details }
84
92
  throw err
85
93
  }
86
94
 
87
- throw new ApiError(err_msg, res.status, err_code)
95
+ const err = new ApiError(err_msg, res.status, err_code)
96
+ if (err_details) err.details = { ...err.details, ...err_details }
97
+ throw err
88
98
  }
89
99
 
90
100
  // Auto-unwrap `{ data: ... }` envelope by default. Callers that need the
91
101
  // full response (e.g. endpoints that emit top-level metadata fields
92
102
  // alongside `data` — see `dispatch_search` for `/repos:search`'s
93
103
  // `mode` / `rerank_digests` / `match_notice` fields) pass `unwrap: false`.
104
+ //
105
+ // Second-layer unwrap: the new envelope's `normalise_data` helper wraps
106
+ // array payloads as `{ results: [...] }` so the top-level `data` slot
107
+ // stays an object (spec 260525-cli-default-json § 4.3). When the wrapper
108
+ // is the SOLE content (`Object.keys(d) === ['results']` and `results`
109
+ // is an array), strip it so the caller sees the bare array — preserves
110
+ // the legacy contract for `list_members`, `list_groups`, `search_users`,
111
+ // `get_refs`, etc. Multi-key shapes (search's `{results, mode, …}`,
112
+ // check_updates' `{results: <object-map>}`) are returned untouched.
94
113
  if (!unwrap) return data
95
- return data?.data !== undefined ? data.data : data
114
+ const inner = data?.data !== undefined ? data.data : data
115
+ if (inner && typeof inner === 'object' && !Array.isArray(inner)) {
116
+ const keys = Object.keys(inner)
117
+ if (keys.length === 1 && keys[0] === 'results' && Array.isArray(inner.results)) {
118
+ return inner.results
119
+ }
120
+ }
121
+ return inner
96
122
  })
97
123
 
98
124
  const get = (path, options) => request('GET', path, options)
@@ -7,13 +7,22 @@
7
7
  const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
8
8
  const client = require('./client')
9
9
 
10
- // POST /feedback — does NOT unwrap, so the caller can read `next_step` which
11
- // the API places alongside `feedback` inside `data`. (Our envelope is
12
- // `{ data: { feedback, next_step } }`.)
10
+ // POST /feedback — opts out of the client's auto-unwrap so we can read
11
+ // both the data slot (with the feedback row) AND the top-level next_step
12
+ // (spec § 15.3.4: API now hoists next_step out of data). Returns the
13
+ // CANONICAL envelope-shaped next_step verbatim so the CLI command can
14
+ // pass it straight through to emit_envelope.
13
15
  const create_feedback = (payload) => catch_errors('Create feedback failed', async () => {
14
- const [errors, data] = await client.post('/feedback', payload)
16
+ const [errors, raw] = await client.post('/feedback', payload, { unwrap: false })
15
17
  if (errors) throw errors[errors.length - 1]
16
- return data // { feedback, next_step }
18
+ if (raw && typeof raw === 'object' && 'meta' in raw && raw.data && !Array.isArray(raw.data)) {
19
+ return {
20
+ feedback: raw.data.feedback,
21
+ next_step: raw.next_step && raw.next_step.action ? raw.next_step : null,
22
+ }
23
+ }
24
+ // Legacy fallback for older API deploys.
25
+ return raw && raw.data ? raw.data : raw
17
26
  })
18
27
 
19
28
  const initiate_attachment_upload = (feedback_id, payload) => catch_errors('Initiate attachment upload failed', async () => {
package/src/api/repos.js CHANGED
@@ -145,18 +145,36 @@ const dispatch_search = (query, options = {}) => catch_errors('Search failed', a
145
145
  workspace_slugs: options.workspace_slug ? options.workspace_slug.split(',').map(s => s.trim()).filter(Boolean) : null,
146
146
  scope: options.workspace_slug ? null : (options.scope || null),
147
147
  }
148
- // Spec 260521-01 v2 § 5.1 — opt-in rerank digests for the discovery
149
- // protocol. Server returns digests + system prompt + json_schema only
150
- // when this is true AND the dispatcher routes to mode='semantic'.
151
148
  if (options.with_rerank_digests) body.with_rerank_digests = true
152
- // `/repos:search` returns top-level metadata alongside `data`
153
- // `mode`, `match_notice`, `workspace_match`, and (when opted-in)
154
- // `rerank_digests`, `rerank_system_prompt`, `rerank_prompt_version`,
155
- // `rerank_response_schema`. The client's default unwrap would drop these
156
- // fields entirely. Opt out so the caller sees the full envelope.
157
- const [errors, data] = await client.post('/repos:search', body, { auth: true, unwrap: false })
149
+ const [errors, raw] = await client.post('/repos:search', body, { auth: true, unwrap: false })
158
150
  if (errors) throw errors[errors.length - 1]
159
- return data
151
+ // Spec 260525 § 15.3.1 — the API now emits the canonical envelope:
152
+ // { ok, data: { query, mode, results, match_notice, workspace_match,
153
+ // workspace_candidates, intent_id },
154
+ // next_step: { kind, action: 'rank_digests_inline',
155
+ // context: { rerank_digests, rerank_system_prompt,
156
+ // rerank_prompt_version, rerank_response_schema } },
157
+ // ... }
158
+ // The CLI's downstream (build_search_next_step + the search command) was
159
+ // written against the LEGACY flat shape — so we flatten here. This keeps
160
+ // the wire contract clean while letting the command code stay terse.
161
+ if (raw && typeof raw === 'object' && 'meta' in raw && 'data' in raw && raw.data && !Array.isArray(raw.data)) {
162
+ const data = raw.data
163
+ const rerank_ctx = raw.next_step?.context || {}
164
+ return {
165
+ data: Array.isArray(data.results) ? data.results : [],
166
+ mode: data.mode || null,
167
+ match_notice: data.match_notice || null,
168
+ workspace_match: data.workspace_match,
169
+ workspace_candidates: data.workspace_candidates,
170
+ intent_id: data.intent_id,
171
+ rerank_digests: rerank_ctx.rerank_digests || null,
172
+ rerank_system_prompt: rerank_ctx.rerank_system_prompt || null,
173
+ rerank_prompt_version: rerank_ctx.rerank_prompt_version || null,
174
+ rerank_response_schema: rerank_ctx.rerank_response_schema || null,
175
+ }
176
+ }
177
+ return raw
160
178
  })
161
179
 
162
180
  // Deprecated alias — kept for backwards compatibility with older code paths.
@@ -0,0 +1,90 @@
1
+ 'use strict'
2
+ // Post-Session-3 translator: near-identity. The public API now emits the
3
+ // canonical six-key response envelope natively (spec 260525-cli-default-json § 11),
4
+ // so this module only stamps the CLI's local `meta.cli_version` on top of
5
+ // the response — meta.api_version is preserved verbatim. Forward-compat
6
+ // for unknown error codes still routes through UNKNOWN_CODE per § 5.2.
7
+ //
8
+ // This file will be deleted no later than two CLI releases after Session
9
+ // 3. Keeping it as a stamp lets the dispatch layer stay symmetric with the
10
+ // old shape until every caller has migrated.
11
+
12
+ const { build_envelope } = require('../ui/envelope')
13
+ const { ERROR_CODE_SET, ERROR_CODES } = require('../constants/error_codes')
14
+
15
+ const is_plain_object = (x) => x !== null && typeof x === 'object' && !Array.isArray(x)
16
+
17
+ // Detect whether the response already speaks the new envelope. If yes, we
18
+ // stamp cli_version and pass-through. Otherwise we fall back to the
19
+ // Session-2 reshape path (kept inline below for any straggler endpoint
20
+ // that hasn't migrated yet — every public-API route is migrated as of
21
+ // Session 3, but admin_api / older deploys aren't).
22
+ const looks_like_envelope = (r) =>
23
+ is_plain_object(r) && 'ok' in r && 'data' in r && 'error' in r
24
+ && 'next_step' in r && 'warnings' in r && 'meta' in r
25
+
26
+ const stamp_cli_version = (env, ctx = {}) => {
27
+ const cli_meta = { ...env.meta }
28
+ if (ctx.command) cli_meta.command = ctx.command
29
+ if (ctx.exit_code_override != null) cli_meta.exit_code = ctx.exit_code_override
30
+ return { ...env, meta: cli_meta }
31
+ }
32
+
33
+ const rewrite_unknown_error_code = (env) => {
34
+ if (!env.error || !env.error.code) return env
35
+ if (ERROR_CODE_SET.has(env.error.code)) return env
36
+ const original_code = env.error.code
37
+ return {
38
+ ...env,
39
+ error: {
40
+ ...env.error,
41
+ code: ERROR_CODES.UNKNOWN_CODE,
42
+ details: { ...(env.error.details || {}), original_code },
43
+ },
44
+ }
45
+ }
46
+
47
+ const translate_api_envelope = (api_response, ctx = {}) => {
48
+ const command = ctx.command || 'api'
49
+
50
+ if (looks_like_envelope(api_response)) {
51
+ return stamp_cli_version(rewrite_unknown_error_code(api_response), { command, exit_code_override: ctx.exit_code_override })
52
+ }
53
+
54
+ // Legacy fallback. Same logic as Session 2's translator, kept for any
55
+ // endpoint that hasn't migrated yet. Removable after Session 3 ships.
56
+ if (is_plain_object(api_response?.error) && api_response.error.code) {
57
+ const code = ERROR_CODE_SET.has(api_response.error.code)
58
+ ? api_response.error.code
59
+ : ERROR_CODES.UNKNOWN_CODE
60
+ const error_payload = {
61
+ code,
62
+ message: api_response.error.message || `API error: ${code}`,
63
+ }
64
+ if (api_response.error.details) error_payload.details = api_response.error.details
65
+ if (code === ERROR_CODES.UNKNOWN_CODE && api_response.error.code) {
66
+ error_payload.details = { ...(error_payload.details || {}), original_code: api_response.error.code }
67
+ }
68
+ return build_envelope({
69
+ data: api_response.data || {},
70
+ error: error_payload,
71
+ next_step: api_response.next_step || {},
72
+ meta_overrides: { command, ...(ctx.exit_code_override != null ? { exit_code: ctx.exit_code_override } : {}) },
73
+ })
74
+ }
75
+
76
+ let data = api_response?.data
77
+ if (!is_plain_object(data)) {
78
+ if (Array.isArray(data)) data = { results: data }
79
+ else if (data == null) data = {}
80
+ else data = { value: data }
81
+ }
82
+
83
+ return build_envelope({
84
+ data,
85
+ next_step: api_response?.next_step || null,
86
+ meta_overrides: { command, ...(ctx.exit_code_override != null ? { exit_code: ctx.exit_code_override } : {}) },
87
+ })
88
+ }
89
+
90
+ module.exports = { translate_api_envelope }
@@ -54,7 +54,21 @@ const run = (args) => catch_errors('Delete failed', async () => {
54
54
 
55
55
  if (args.flags.json) {
56
56
  if (!args.flags.yes) {
57
- throw new CliError('Confirmation required. Use -y or --yes to confirm deletion in JSON mode.', EXIT_CODES.ERROR)
57
+ const { ERROR_CODES } = require('../constants/error_codes')
58
+ throw new CliError(`Confirmation required to delete ${skill}. Re-run with -y to proceed.`, {
59
+ code: ERROR_CODES.CONFIRMATION_REQUIRED,
60
+ exit_code: 1,
61
+ next_step: {
62
+ kind: 'confirmation',
63
+ action: 'confirm_destructive',
64
+ instructions: 'Deleting a skill is irreversible. Re-run with -y to confirm.',
65
+ context: {
66
+ would_remove: [skill],
67
+ commands: [`npx happyskills delete ${skill} --json -y`],
68
+ },
69
+ principal_authorization_required: true,
70
+ },
71
+ })
58
72
  }
59
73
  } else {
60
74
  const confirmed = await _ask_confirmation(skill)
@@ -3,7 +3,7 @@
3
3
  // Surfaces:
4
4
  // happyskills feedback <category> [body] [--subject "..."] [--attach a,b,c] [--json]
5
5
  //
6
- // --json mode emits the API's response wrapped in the universal CLI envelope:
6
+ // --json mode emits the API's response wrapped in the canonical six-key response envelope:
7
7
  // { data: { feedback, next_step, attachments? }, error: null }
8
8
  // The `next_step` envelope is what the `happyskills-help` skill reads to
9
9
  // route the post-creation conversation (offer attachment, etc.) — see spec
@@ -240,7 +240,7 @@ const run = (args) => catch_errors('Feedback command failed', async () => {
240
240
  }
241
241
 
242
242
  if (json_mode) {
243
- // Universal CLI envelope shape. The skill's envelope-reader (per spec
243
+ // Canonical six-key response envelope shape. The skill's envelope-reader (per spec
244
244
  // § 11) consumes `next_step` at the response root.
245
245
  const data = { feedback, attachments }
246
246
  print_json({ data, error: null, next_step })
@@ -252,8 +252,8 @@ const run = (args) => catch_errors('Feedback command failed', async () => {
252
252
  print_success(`Feedback recorded (#${short_id}).`)
253
253
  if (attachments.length > 0) {
254
254
  print_info(`Uploaded ${attachments.length} attachment${attachments.length === 1 ? '' : 's'}.`)
255
- } else if (next_step && next_step.attachments_supported && attach_paths.length === 0) {
256
- print_hint(`Pass --attach <path> to add a screenshot (max ${next_step.max_attachments}).`)
255
+ } else if (next_step && next_step.context && next_step.context.attachments_supported && attach_paths.length === 0) {
256
+ print_hint(`Pass --attach <path> to add a screenshot (max ${next_step.context.max_attachments}).`)
257
257
  }
258
258
  }).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
259
259
 
@@ -87,7 +87,11 @@ const run = (args) => catch_errors('Init failed', async () => {
87
87
 
88
88
  const [, json_exists] = await file_exists(path.join(dir, SKILL_JSON))
89
89
  if (json_exists) {
90
- throw new CliError(`${SKILL_JSON} already exists at ${dir}`, EXIT_CODES.ERROR)
90
+ const { ERROR_CODES } = require('../constants/error_codes')
91
+ throw new CliError(`${SKILL_JSON} already exists at ${dir}`, {
92
+ code: ERROR_CODES.ALREADY_EXISTS,
93
+ exit_code: 1,
94
+ })
91
95
  }
92
96
 
93
97
  const [dir_err] = await ensure_dir(dir)
@@ -115,37 +115,22 @@ const run = (args) => catch_errors('Install failed', async () => {
115
115
  // mode surfaces BEFORE any disk mutation.
116
116
  const fresh_snapshots = []
117
117
  if (base_options.fresh) {
118
+ const { CliError } = require('../utils/errors')
119
+ const { ERROR_CODES } = require('../constants/error_codes')
118
120
  for (const { skill, version: inline_version } of parsed) {
119
121
  const requested = flag_version || inline_version
120
122
  if (!requested || requested === 'latest') continue
121
123
 
122
- // (1) Verify the version exists on the registry. Hard-fail if not.
123
124
  const [owner, name] = skill.split('/')
124
- const [refs_err, refs] = await get_refs(owner, name)
125
- if (refs_err) {
126
- // Network failure or skill-not-found. Treat both as VERSION_NOT_FOUND
127
- // because we cannot prove the version exists.
128
- throw new UsageError(
129
- `VERSION_NOT_FOUND: Cannot verify that ${skill}@${requested} exists on the registry (registry unreachable or skill unknown). ` +
130
- `--fresh would wipe local content; refusing without confirmation.`
131
- )
132
- }
133
- const available = (refs || []).map(r => extract_version(r.name)).filter(Boolean)
134
- if (!available.includes(requested)) {
135
- throw new UsageError(
136
- `VERSION_NOT_FOUND: ${skill}@${requested} is not on the registry. ` +
137
- `Available versions: ${available.join(', ') || '(none)'}. ` +
138
- `This was previously a silent-fallback footgun (issue 260523-02 § 2.3) — --fresh now hard-fails.`
139
- )
140
- }
141
-
142
- // (2) Snapshot before wiping. Always — so a failed install is reversible.
143
125
  const base_dir = skills_dir(base_options.global, base_options.project_root)
144
126
  const skill_dir = skill_install_dir(base_dir, name)
145
127
  const [, dir_present] = await file_exists(skill_dir)
128
+
129
+ // (1) Local-edit check FIRST. The local-state gate is structurally
130
+ // earlier than the network call: if the disk would be clobbered, we
131
+ // refuse before contacting the registry. Spec § 8.5 + envelope
132
+ // integration test (install_fresh.test.js).
146
133
  if (dir_present) {
147
- // (3) Local-edit check. Compare on-disk hash to lock baseline; if
148
- // they disagree, require explicit --force-discard-local.
149
134
  const [, lock_data_pre] = await read_lock(base_options.project_root)
150
135
  let has_local_edits = false
151
136
  if (lock_data_pre) {
@@ -157,11 +142,44 @@ const run = (args) => catch_errors('Install failed', async () => {
157
142
  }
158
143
  }
159
144
  if (has_local_edits && !args.flags['force-discard-local']) {
160
- throw new UsageError(
161
- `LOCAL_EDITS_PRESENT: ${skill} has local edits that would be destroyed by --fresh. ` +
162
- `Snapshot first (\`happyskills snapshot create ${skill}\`) and pass --force-discard-local to proceed.`
145
+ throw new CliError(
146
+ `${skill} has local edits that would be destroyed by --fresh.`,
147
+ {
148
+ code: ERROR_CODES.LOCAL_EDITS_PRESENT,
149
+ exit_code: 1,
150
+ context: { skill, version: requested },
151
+ }
163
152
  )
164
153
  }
154
+ }
155
+
156
+ // (2) Verify the version exists on the registry. Hard-fail if not.
157
+ const [refs_err, refs] = await get_refs(owner, name)
158
+ if (refs_err) {
159
+ throw new CliError(
160
+ `Cannot verify that ${skill}@${requested} exists on the registry (registry unreachable or skill unknown). --fresh would wipe local content; refusing without confirmation.`,
161
+ {
162
+ code: ERROR_CODES.VERSION_NOT_FOUND,
163
+ exit_code: 2,
164
+ context: { skill, requested },
165
+ }
166
+ )
167
+ }
168
+ const available = (refs || []).map(r => extract_version(r.name)).filter(Boolean)
169
+ if (!available.includes(requested)) {
170
+ throw new CliError(
171
+ `${skill}@${requested} is not on the registry. Available: ${available.join(', ') || '(none)'}.`,
172
+ {
173
+ code: ERROR_CODES.VERSION_NOT_FOUND,
174
+ exit_code: 2,
175
+ context: { skill, requested, available },
176
+ }
177
+ )
178
+ }
179
+
180
+ // (3) Snapshot before wiping. Always — so a failed install is reversible.
181
+ if (dir_present) {
182
+ const [, lock_data_pre] = await read_lock(base_options.project_root)
165
183
  const [snap_err, snap] = await snapshot_storage.create({
166
184
  skill_dir,
167
185
  workspace: owner,
@@ -208,9 +226,17 @@ const run = (args) => catch_errors('Install failed', async () => {
208
226
  const snapshot_by_skill = Object.fromEntries(fresh_snapshots.map(s => [s.skill, s.snapshot_id]))
209
227
 
210
228
  if (args.flags.json) {
211
- const items = results.map(({ skill, result }) => {
229
+ // Spec 260525-cli-default-json § 4.3 rules 2 + 4:
230
+ // - No shape-flipping between single and batch — always emit
231
+ // `data.results: [...]` so skills iterate uniformly.
232
+ // - Per-row failures live inside `data.results[i].error`, not at
233
+ // the envelope root (top-level `error` is reserved for whole-
234
+ // operation failures). Pre-fix the `errors:` plural was a top-
235
+ // level key that the print_json retrofit silently dropped.
236
+ const success_rows = results.map(({ skill, result }) => {
212
237
  const item = {
213
238
  skill,
239
+ status: 'installed',
214
240
  version: result.version,
215
241
  installed: result.installed || [],
216
242
  skipped: result.skipped || [],
@@ -221,12 +247,12 @@ const run = (args) => catch_errors('Install failed', async () => {
221
247
  if (snapshot_by_skill[skill]) item.snapshot_id = snapshot_by_skill[skill]
222
248
  return item
223
249
  })
224
- const data = results.length === 1 && failures.length === 0 ? items[0] : items
225
- if (failures.length > 0) {
226
- print_json({ data, errors: failures })
227
- } else {
228
- print_json({ data })
229
- }
250
+ const failure_rows = failures.map(({ skill, error }) => ({
251
+ skill,
252
+ status: 'failed',
253
+ error: { code: 'INTERNAL_ERROR', message: error }
254
+ }))
255
+ print_json({ data: { results: [...success_rows, ...failure_rows] } })
230
256
  return
231
257
  }
232
258