happyskills 0.53.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 (43) hide show
  1. package/CHANGELOG.md +30 -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 +53 -35
  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 +34 -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
  43. package/src/utils/intent.js +22 -1
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.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
+
34
+ ## [0.54.0] - 2026-05-26
35
+
36
+ ### Added
37
+
38
+ - **`intent_id` is now attached to every `/telemetry/discovery` beacon payload** (spec 260521-03 addendum § 4 — CLI half of Phase A). The CLI sources the value from the active intent envelope via a new `get_intent_id()` helper in `cli/src/utils/intent.js`, which decodes the envelope's base64url payload portion and extracts `intent_id`. Sent on all four beacon types fired from `search` and `postlex`: `rerank_started`, `rerank_completed`, `clarify_triggered`, `clarify_completed`. When no envelope is active the payload includes `intent_id: null`. Before this change, every `search.rerank` and `search.clarify` row in `analytics.events` landed with `intent_id = NULL`, defeating the addendum's goal of correlating beacons with the discovery chain.
39
+
10
40
  ## [0.53.0] - 2026-05-25
11
41
 
12
42
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.53.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
 
@@ -15,6 +15,7 @@ const { exit_with_error, UsageError, CliError } = require('../utils/errors')
15
15
  const { EXIT_CODES } = require('../constants')
16
16
  const { slug_token_set, compute_lex_tier } = require('../utils/slug_tokens')
17
17
  const { fire_discovery_telemetry } = require('../api/telemetry')
18
+ const { get_intent_id } = require('../utils/intent')
18
19
 
19
20
  const HELP_TEXT = `Usage: happyskills postlex --query <q> --ranking <file|-> [options]
20
21
 
@@ -177,61 +178,70 @@ const determine_next_step = (final_ordering, query, clarification_turns_used) =>
177
178
 
178
179
  if (all_strong || final_ordering.length === 0) {
179
180
  return {
181
+ kind: 'continuation',
180
182
  action: 'present_to_user',
181
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.',
182
- context: null,
184
+ context: { original_query: query, clarification_turns_used: turns_used },
183
185
  }
184
186
  }
185
187
 
186
188
  if (turns_remaining <= 0) {
187
189
  return {
190
+ kind: 'continuation',
188
191
  action: 'present_to_user',
189
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.',
190
- context: null,
193
+ context: { original_query: query, clarification_turns_used: turns_used },
191
194
  }
192
195
  }
193
196
 
194
197
  return {
195
- action: 'clarify',
196
- 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.`,
197
- suggested_questions: [
198
- {
199
- question: 'After reranking I still don\'t have a strong match. Could you narrow the scope a bit?',
200
- options: [
201
- { label: 'Focus on a specific stack (Node / Python / Go / etc.)', refined_query_hint: 'stack-specific' },
202
- { label: 'Focus on a specific platform (AWS / GCP / Vercel / etc.)', refined_query_hint: 'platform-specific' },
203
- { label: 'Broaden the search — show me more options', refined_query_hint: 'broader' },
204
- { label: 'Just search anyway', refined_query_hint: null },
205
- ],
206
- },
207
- ],
208
- 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.`,
209
201
  context: {
210
202
  original_query: query,
211
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
+ ],
212
216
  },
217
+ principal_authorization_required: true,
213
218
  }
214
219
  }
215
220
 
216
221
  // Envelope used when the LLM's ranking is malformed and we need to ask it
217
- // to re-emit. The CLI exits 0 the protocol continues via the envelope.
218
- const build_retry_envelope = (query, reason, clarification_turns_used, retry_count) => ({
219
- data: null,
220
- error: {
221
- code: 'ranking_schema_mismatch',
222
- message: reason,
223
- exit_code: 0,
224
- },
225
- next_step: {
226
- action: 'retry_rank',
227
- 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).',
228
- context: {
229
- original_query: query,
230
- clarification_turns_used: clarification_turns_used | 0,
231
- 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
+ },
232
241
  },
233
- },
234
- })
242
+ meta_overrides: { command: 'postlex' },
243
+ })
244
+ }
235
245
 
236
246
  // ─── I/O helpers ───────────────────────────────────────────────────────────
237
247
 
@@ -404,7 +414,10 @@ const run = (args) => catch_errors('Postlex failed', async () => {
404
414
  process.stderr.write(`postlex: ${parse_error}\n`)
405
415
  const env = build_retry_envelope(query, parse_error, clarification_turns_used, 0)
406
416
  print_json(env)
407
- 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)
408
421
  }
409
422
 
410
423
  // Validate per-item
@@ -425,9 +438,13 @@ const run = (args) => catch_errors('Postlex failed', async () => {
425
438
  const next_step = determine_next_step(final_ordering, query, clarification_turns_used)
426
439
 
427
440
  // Fire telemetry — rerank_completed first (the rerank step succeeded);
428
- // clarify_triggered separately if applicable.
441
+ // clarify_triggered separately if applicable. `intent_id` is sourced from
442
+ // the active intent envelope so these beacons correlate with the same
443
+ // discovery chain (spec 260521-03 addendum § 4).
444
+ const intent_id = get_intent_id()
429
445
  fire_discovery_telemetry({
430
446
  event: 'rerank_completed',
447
+ intent_id,
431
448
  query,
432
449
  promoted,
433
450
  promoted_from_rank,
@@ -436,6 +453,7 @@ const run = (args) => catch_errors('Postlex failed', async () => {
436
453
  if (next_step.action === 'clarify') {
437
454
  fire_discovery_telemetry({
438
455
  event: 'clarify_triggered',
456
+ intent_id,
439
457
  query,
440
458
  reason: 'post_rerank_weak',
441
459
  turn_number: clarification_turns_used + 1,