happyskills 0.54.0 → 1.0.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +24 -0
  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 +2 -2
  10. package/src/commands/init.js +5 -1
  11. package/src/commands/install.js +58 -32
  12. package/src/commands/postlex.js +46 -34
  13. package/src/commands/postlex.test.js +48 -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 +26 -22
  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,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.0.0] - 2026-05-29
11
+
12
+ ### Added
13
+
14
+ - **Universal 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.
15
+ - **`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.
16
+ - **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.
17
+ - **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.
18
+ - **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.
19
+
20
+ ### Changed
21
+
22
+ - **BREAKING: `--json` output format.** Every command's JSON output moved from the legacy `{ data }` / `{ error: { code, message, exit_code } }` shapes to the six-key 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`.
23
+ - **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.
24
+ - **`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`.
25
+ - **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`).
26
+ - **`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.
27
+
28
+ ### Fixed
29
+
30
+ - **`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.
31
+ - **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`.
32
+ - **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`).
33
+
10
34
  ## [0.54.0] - 2026-05-26
11
35
 
12
36
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.54.0",
3
+ "version": "1.0.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)",
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 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)
@@ -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
 
@@ -178,61 +178,70 @@ const determine_next_step = (final_ordering, query, clarification_turns_used) =>
178
178
 
179
179
  if (all_strong || final_ordering.length === 0) {
180
180
  return {
181
+ kind: 'continuation',
181
182
  action: 'present_to_user',
182
183
  instructions: 'Render `data.final_ordering` as a table to the user, with skill name, slug, description, and rationale. Include `data.formulated_query` in your preamble.',
183
- context: null,
184
+ context: { original_query: query, clarification_turns_used: turns_used },
184
185
  }
185
186
  }
186
187
 
187
188
  if (turns_remaining <= 0) {
188
189
  return {
190
+ kind: 'continuation',
189
191
  action: 'present_to_user',
190
192
  instructions: 'Even after reranking, no top result is a strong match — but the clarification budget (2 turns) is spent. Render `data.final_ordering` honestly: note that no result was a strong match, and present what you have so the user can decide. Do NOT ask another clarifying question.',
191
- context: null,
193
+ context: { original_query: query, clarification_turns_used: turns_used },
192
194
  }
193
195
  }
194
196
 
195
197
  return {
196
- action: 'clarify',
197
- instructions: `After reranking, no top result is a strong match. Ask the user one of \`suggested_questions\` using your agent's question mechanism (e.g. AskUserQuestion in Claude Code), then re-run search with the refined query and add --clarification-turns-used ${turns_used + 1}. The last option is always "Just search anyway" — honor it by re-running with the original query unchanged.`,
198
- suggested_questions: [
199
- {
200
- question: 'After reranking I still don\'t have a strong match. Could you narrow the scope a bit?',
201
- options: [
202
- { label: 'Focus on a specific stack (Node / Python / Go / etc.)', refined_query_hint: 'stack-specific' },
203
- { label: 'Focus on a specific platform (AWS / GCP / Vercel / etc.)', refined_query_hint: 'platform-specific' },
204
- { label: 'Broaden the search — show me more options', refined_query_hint: 'broader' },
205
- { label: 'Just search anyway', refined_query_hint: null },
206
- ],
207
- },
208
- ],
209
- max_turns_remaining: turns_remaining,
198
+ kind: 'clarification',
199
+ action: 'clarify_query',
200
+ instructions: `After reranking, no top result is a strong match. Ask the user one of \`context.suggested_questions\` using your agent's question mechanism (e.g. AskUserQuestion in Claude Code), then re-run search with the refined query and add --clarification-turns-used ${turns_used + 1}. The last option is always "Just search anyway" — honor it by re-running with the original query unchanged.`,
210
201
  context: {
211
202
  original_query: query,
212
203
  clarification_turns_used: turns_used,
204
+ max_turns_remaining: turns_remaining,
205
+ suggested_questions: [
206
+ {
207
+ question: 'After reranking I still don\'t have a strong match. Could you narrow the scope a bit?',
208
+ options: [
209
+ { label: 'Focus on a specific stack (Node / Python / Go / etc.)', refined_query_hint: 'stack-specific' },
210
+ { label: 'Focus on a specific platform (AWS / GCP / Vercel / etc.)', refined_query_hint: 'platform-specific' },
211
+ { label: 'Broaden the search — show me more options', refined_query_hint: 'broader' },
212
+ { label: 'Just search anyway', refined_query_hint: null },
213
+ ],
214
+ },
215
+ ],
213
216
  },
217
+ principal_authorization_required: true,
214
218
  }
215
219
  }
216
220
 
217
221
  // Envelope used when the LLM's ranking is malformed and we need to ask it
218
- // to re-emit. The CLI exits 0 the protocol continues via the envelope.
219
- const build_retry_envelope = (query, reason, clarification_turns_used, retry_count) => ({
220
- data: null,
221
- error: {
222
- code: 'ranking_schema_mismatch',
223
- message: reason,
224
- exit_code: 0,
225
- },
226
- next_step: {
227
- action: 'retry_rank',
228
- instructions: 'Your ranking failed validation. Re-emit a JSON object matching the `rerank_response_schema` from the original search response, then re-pipe to `happyskills postlex --query "<q>" --ranking -`. Max one retry — if it fails again, render the search results without rerank (the API\'s relevance_score order is the fallback).',
229
- context: {
230
- original_query: query,
231
- clarification_turns_used: clarification_turns_used | 0,
232
- retry_count: (retry_count | 0) + 1,
222
+ // to re-emit. Returns the canonical six-key envelope (spec § 4) so postlex
223
+ // can be unit-tested against the closed schema.
224
+ const build_retry_envelope = (query, reason, clarification_turns_used, retry_count) => {
225
+ const { build_envelope } = require('../ui/envelope')
226
+ return build_envelope({
227
+ data: {},
228
+ error: {
229
+ code: 'RANKING_SCHEMA_MISMATCH',
230
+ message: reason,
231
+ },
232
+ next_step: {
233
+ kind: 'recovery',
234
+ action: 'retry_rank',
235
+ instructions: 'Your ranking failed validation. Re-emit a JSON object matching the `rerank_response_schema` from the original search response, then re-pipe to `happyskills postlex --query "<q>" --ranking -`. Max one retry — if it fails again, render the search results without rerank (the API\'s relevance_score order is the fallback).',
236
+ context: {
237
+ original_query: query,
238
+ clarification_turns_used: clarification_turns_used | 0,
239
+ retry_count: (retry_count | 0) + 1,
240
+ },
233
241
  },
234
- },
235
- })
242
+ meta_overrides: { command: 'postlex' },
243
+ })
244
+ }
236
245
 
237
246
  // ─── I/O helpers ───────────────────────────────────────────────────────────
238
247
 
@@ -405,7 +414,10 @@ const run = (args) => catch_errors('Postlex failed', async () => {
405
414
  process.stderr.write(`postlex: ${parse_error}\n`)
406
415
  const env = build_retry_envelope(query, parse_error, clarification_turns_used, 0)
407
416
  print_json(env)
408
- return process.exit(EXIT_CODES.SUCCESS)
417
+ // Spec § 9.2 — process exit code must mirror meta.exit_code. The
418
+ // retry envelope carries error.code=RANKING_SCHEMA_MISMATCH which
419
+ // maps to a non-zero exit; honor that rather than forcing 0.
420
+ return process.exit(env.meta.exit_code)
409
421
  }
410
422
 
411
423
  // Validate per-item