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.
- package/CHANGELOG.md +24 -0
- 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 +2 -2
- package/src/commands/init.js +5 -1
- package/src/commands/install.js +58 -32
- package/src/commands/postlex.js +46 -34
- package/src/commands/postlex.test.js +48 -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 +26 -22
- 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,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
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 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
|
@@ -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
|
|
package/src/commands/postlex.js
CHANGED
|
@@ -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:
|
|
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:
|
|
193
|
+
context: { original_query: query, clarification_turns_used: turns_used },
|
|
192
194
|
}
|
|
193
195
|
}
|
|
194
196
|
|
|
195
197
|
return {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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.
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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
|