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.
- package/CHANGELOG.md +32 -2
- package/package.json +1 -1
- package/src/api/auth.js +18 -2
- package/src/api/client.js +29 -3
- package/src/api/feedback.js +14 -5
- package/src/api/repos.js +28 -10
- package/src/api/translate.js +90 -0
- package/src/commands/delete.js +15 -1
- package/src/commands/feedback.js +4 -4
- package/src/commands/init.js +5 -1
- package/src/commands/install.js +58 -32
- package/src/commands/postlex.js +74 -38
- package/src/commands/postlex.test.js +96 -18
- package/src/commands/pull.js +5 -1
- package/src/commands/reconcile.js +52 -4
- package/src/commands/release.js +45 -15
- package/src/commands/schema.js +179 -0
- package/src/commands/search.js +27 -23
- package/src/commands/search.test.js +59 -33
- package/src/commands/uninstall.js +20 -11
- package/src/commands/validate.js +33 -11
- package/src/constants/error_codes.js +197 -0
- package/src/constants/exit_codes.js +54 -0
- package/src/constants/next_step_actions.js +133 -0
- package/src/constants/next_step_by_error_code.js +249 -0
- package/src/constants.js +2 -1
- package/src/index.js +51 -7
- package/src/integration/api_envelope.test.js +499 -0
- package/src/integration/bump.test.js +13 -4
- package/src/integration/cli.test.js +169 -147
- package/src/integration/drift.test.js +16 -4
- package/src/integration/install_fresh.test.js +37 -29
- package/src/integration/reconcile.test.js +77 -56
- package/src/integration/release.test.js +48 -31
- package/src/integration/schema.test.js +167 -0
- package/src/schema/envelope.schema.json +73 -0
- package/src/schema/envelope_test_helpers.js +94 -0
- package/src/schema/envelope_validator.js +239 -0
- package/src/schema/envelope_validator.test.js +333 -0
- package/src/ui/envelope.js +171 -0
- package/src/ui/output.js +66 -2
- 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
|
|
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
|
|
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
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
package/src/api/feedback.js
CHANGED
|
@@ -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 —
|
|
11
|
-
// the
|
|
12
|
-
//
|
|
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,
|
|
16
|
+
const [errors, raw] = await client.post('/feedback', payload, { unwrap: false })
|
|
15
17
|
if (errors) throw errors[errors.length - 1]
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 }
|
package/src/commands/delete.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/src/commands/feedback.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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
|
|
package/src/commands/init.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/src/commands/install.js
CHANGED
|
@@ -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
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
|