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
@@ -1,44 +1,85 @@
1
- const { EXIT_CODES } = require('../constants')
1
+ 'use strict'
2
+ // Error classes + central exit_with_error marshaller. Spec
3
+ // 260525-cli-default-json § 4 + § 5. Every error class carries a closed
4
+ // error.code that emit_envelope writes into the `error` slot. Domain
5
+ // errors not modelled by a dedicated class can be raised as CliError(msg,
6
+ // { code, exit_code, details, context, next_step }).
7
+ //
8
+ // exit_with_error builds the envelope, emits to stdout (json mode) or
9
+ // stderr (text mode), and exits with the mapped exit code.
10
+
11
+ const { ERROR_CODES, ERROR_CODE_SET } = require('../constants/error_codes')
12
+ const { exit_code_for_error } = require('../constants/exit_codes')
13
+ const { next_step_for_error } = require('../constants/next_step_by_error_code')
2
14
 
3
15
  class CliError extends Error {
4
- constructor(message, code = EXIT_CODES.ERROR) {
16
+ // Accepts the legacy positional shape `new CliError(msg, exit_code)` and
17
+ // the new options shape `new CliError(msg, { code, exit_code, details,
18
+ // context, next_step })`. The legacy form maps to code 'INTERNAL_ERROR'.
19
+ constructor(message, opts_or_exit_code) {
5
20
  super(message)
6
21
  this.name = 'CliError'
7
- this.exit_code = code
22
+ if (typeof opts_or_exit_code === 'object' && opts_or_exit_code !== null) {
23
+ const { code, exit_code, details, context, next_step } = opts_or_exit_code
24
+ this.code = code || ERROR_CODES.INTERNAL_ERROR
25
+ this.exit_code = exit_code != null ? exit_code : exit_code_for_error(this.code)
26
+ if (details) this.details = details
27
+ if (context) this.context = context
28
+ if (next_step) this.next_step = next_step
29
+ } else {
30
+ this.code = ERROR_CODES.INTERNAL_ERROR
31
+ this.exit_code = typeof opts_or_exit_code === 'number' ? opts_or_exit_code : 1
32
+ }
8
33
  }
9
34
  }
10
35
 
11
36
  class UsageError extends CliError {
12
- constructor(message) {
13
- super(message, EXIT_CODES.USAGE)
37
+ constructor(message, opts = {}) {
38
+ super(message, { code: ERROR_CODES.USAGE_ERROR, exit_code: 2, ...opts })
14
39
  this.name = 'UsageError'
15
40
  }
16
41
  }
17
42
 
18
43
  class AuthError extends CliError {
19
- constructor(message = 'Authentication required. Run `happyskills login` first.') {
20
- super(message, EXIT_CODES.AUTH_REQUIRED)
44
+ constructor(message = 'Authentication required. Run `happyskills login` first.', opts = {}) {
45
+ super(message, { code: ERROR_CODES.AUTH_REQUIRED, exit_code: 3, ...opts })
21
46
  this.name = 'AuthError'
22
47
  }
23
48
  }
24
49
 
25
50
  class NetworkError extends CliError {
26
- constructor(message = 'Network error. Check your connection and try again.') {
27
- super(message, EXIT_CODES.NETWORK)
51
+ constructor(message = 'Network error. Check your connection and try again.', opts = {}) {
52
+ super(message, { code: ERROR_CODES.NETWORK_ERROR, exit_code: 4, ...opts })
28
53
  this.name = 'NetworkError'
29
54
  }
30
55
  }
31
56
 
32
57
  class ApiError extends CliError {
33
- constructor(message, status_code, error_code) {
34
- super(message)
58
+ constructor(message, status_code, error_code, opts = {}) {
59
+ const code = (() => {
60
+ if (error_code && ERROR_CODE_SET.has(error_code)) return error_code
61
+ if (status_code === 401) return ERROR_CODES.AUTH_REQUIRED
62
+ if (status_code === 404) return ERROR_CODES.NOT_FOUND
63
+ if (status_code === 429) return ERROR_CODES.RATE_LIMITED
64
+ if (error_code) return ERROR_CODES.UNKNOWN_CODE
65
+ return ERROR_CODES.INTERNAL_ERROR
66
+ })()
67
+ // Status-code-driven override: 401 always exits 3 regardless of code.
68
+ const exit_code = opts.exit_code != null
69
+ ? opts.exit_code
70
+ : (status_code === 401 ? 3 : exit_code_for_error(code))
71
+ super(message, { code, exit_code, ...opts })
35
72
  this.name = 'ApiError'
36
73
  this.status_code = status_code
37
- this.error_code = error_code
38
- if (status_code === 401) this.exit_code = EXIT_CODES.AUTH_REQUIRED
74
+ this.error_code = error_code
75
+ if (error_code && !ERROR_CODE_SET.has(error_code)) {
76
+ this.details = { ...(this.details || {}), original_code: error_code }
77
+ }
39
78
  }
40
79
  }
41
80
 
81
+ // ── Helpers ────────────────────────────────────────────────────────────────
82
+
42
83
  const print_log_hint = (log_path) => {
43
84
  if (log_path) console.error(` Log: ${log_path}`)
44
85
  }
@@ -51,13 +92,8 @@ const is_usage_error = (err) => {
51
92
 
52
93
  const extract_root_cause = (err) => {
53
94
  if (!Array.isArray(err)) return err?.message || 'An unexpected error occurred'
54
- // Walk the error array to find the deepest, most specific message.
55
- // catch_errors wraps errors outermost-first: [outermost, ..., root_cause].
56
- // We prefer the last error (root cause) for the user-facing message,
57
- // but fall back to the first CliError if one exists.
58
95
  const messages = err.map(x => x?.message).filter(Boolean)
59
96
  if (messages.length === 0) return 'An unexpected error occurred'
60
- // The last message is typically the root cause (deepest in the chain)
61
97
  return messages[messages.length - 1]
62
98
  }
63
99
 
@@ -66,54 +102,87 @@ const format_error_chain = (err) => {
66
102
  return err.map(x => x?.stack || x?.message || String(x)).filter(Boolean)
67
103
  }
68
104
 
69
- const get_error_info = (err) => {
70
- let target = err
71
- if (Array.isArray(err)) {
72
- target = err.find(e => e instanceof CliError) || err[err.length - 1] || err[0]
105
+ const get_error_target = (err) => {
106
+ if (!Array.isArray(err)) return err
107
+ // Prefer the deepest CliError; that is where the structured code lives.
108
+ for (let i = err.length - 1; i >= 0; i--) {
109
+ if (err[i] instanceof CliError) return err[i]
73
110
  }
111
+ return err[err.length - 1] || err[0]
112
+ }
74
113
 
75
- let code = 'ERROR'
76
- let exit_code = EXIT_CODES.ERROR
114
+ const get_error_info = (err) => {
115
+ const target = get_error_target(err)
77
116
  const message = extract_root_cause(err)
78
-
79
- if (target instanceof UsageError) {
80
- code = 'USAGE_ERROR'; exit_code = EXIT_CODES.USAGE
81
- } else if (target instanceof AuthError) {
82
- code = 'AUTH_REQUIRED'; exit_code = EXIT_CODES.AUTH_REQUIRED
83
- } else if (target instanceof NetworkError) {
84
- code = 'NETWORK_ERROR'; exit_code = EXIT_CODES.NETWORK
85
- } else if (target instanceof ApiError) {
86
- code = target.error_code || 'API_ERROR'
87
- exit_code = target.exit_code || EXIT_CODES.ERROR
88
- } else if (target instanceof CliError) {
89
- code = 'ERROR'; exit_code = target.exit_code
117
+ let code = ERROR_CODES.INTERNAL_ERROR
118
+ let exit_code = 1
119
+ let details = null
120
+ let context = null
121
+ let next_step = null
122
+
123
+ if (target instanceof CliError) {
124
+ code = target.code || code
125
+ exit_code = target.exit_code != null ? target.exit_code : exit_code_for_error(code)
126
+ if (target.details) details = target.details
127
+ if (target.context) context = target.context
128
+ if (target.next_step) next_step = target.next_step
129
+ } else {
130
+ exit_code = exit_code_for_error(code)
90
131
  }
91
132
 
92
- return { code, message, exit_code }
133
+ return { code, message, exit_code, details, context, next_step }
93
134
  }
94
135
 
136
+ // ── Central marshaller ────────────────────────────────────────────────────
137
+
95
138
  const exit_with_error = (err) => {
96
139
  const { is_json_mode } = require('../state')
140
+ const { code, message, exit_code, details, context, next_step: explicit_next_step } = get_error_info(err)
97
141
 
98
142
  if (is_json_mode()) {
99
- const { code, message, exit_code } = get_error_info(err)
100
- const chain = format_error_chain(err)
101
- const json_err = { code, message, exit_code }
102
- if (chain.length > 1) json_err.details = chain
103
- console.log(JSON.stringify({ error: json_err }, null, 2))
104
- process.exit(exit_code)
143
+ // Build envelope. Default next_step from the mapping table, unless
144
+ // the throwing site provided one explicitly.
145
+ const { emit_and_exit } = require('../ui/envelope')
146
+ const error_payload = { code, message }
147
+ if (details) error_payload.details = details
148
+ // Domain-specific extras land alongside code/message (spec § 4.4).
149
+ if (context) {
150
+ for (const k of Object.keys(context)) {
151
+ if (k === 'commands' || k === 'options' || k === 'suggestion' || k === 'got'
152
+ || k === 'available' || k === 'requested' || k === 'modified_files'
153
+ || k === 'candidates' || k === 'dependencies' || k === 'would_remove'
154
+ || k === 'min_cli_version' || k === 'current_cli_version'
155
+ || k === 'retry_after_seconds' || k === 'target_version' || k === 'current_top_entry'
156
+ || k === 'disk_version' || k === 'requested_bump' || k === 'lock_version'
157
+ || k === 'bump' || k === 'current_version' || k === 'skill') {
158
+ // These belong on next_step.context, not on error.
159
+ continue
160
+ }
161
+ if (!(k in error_payload)) error_payload[k] = context[k]
162
+ }
163
+ }
164
+ const next_step = explicit_next_step || next_step_for_error(code, message, context || {}) || {}
165
+ emit_and_exit({
166
+ data: {},
167
+ error: error_payload,
168
+ next_step,
169
+ meta_overrides: { exit_code },
170
+ })
105
171
  return
106
172
  }
107
173
 
108
174
  const { print_error } = require('../ui/output')
109
175
  const { write_error_log } = require('./logger')
110
-
111
176
  const log_path = is_usage_error(err) ? null : write_error_log(err)
112
- const { message, exit_code } = get_error_info(err)
113
-
114
177
  print_error(message)
115
178
  print_log_hint(log_path)
116
179
  process.exit(exit_code)
117
180
  }
118
181
 
119
- module.exports = { CliError, UsageError, AuthError, NetworkError, ApiError, exit_with_error }
182
+ module.exports = {
183
+ CliError, UsageError, AuthError, NetworkError, ApiError,
184
+ exit_with_error,
185
+ get_error_info,
186
+ extract_root_cause,
187
+ format_error_chain,
188
+ }
@@ -39,4 +39,25 @@ const clear_envelope = () => {
39
39
  delete process.env[ENV_VAR]
40
40
  }
41
41
 
42
- module.exports = { init_from_env, init_from_flag, get_envelope, set_envelope, clear_envelope, ENV_VAR }
42
+ // Spec 260521-03 addendum § 4 Extract the intent_id UUID from the active
43
+ // envelope so the CLI can attach it to /telemetry/discovery beacon payloads.
44
+ // The envelope is `<base64url-payload>.<base64url-hmac>`; the CLI doesn't
45
+ // have the HMAC secret and doesn't need to verify — it just reads the
46
+ // payload portion. Returns null when no envelope is active or the payload
47
+ // can't be decoded.
48
+ const get_intent_id = () => {
49
+ const env = get_envelope()
50
+ if (!env) return null
51
+ try {
52
+ const dot = env.indexOf('.')
53
+ if (dot <= 0) return null
54
+ const body = env.slice(0, dot)
55
+ const json = Buffer.from(body, 'base64url').toString('utf-8')
56
+ const payload = JSON.parse(json)
57
+ return payload.intent_id || null
58
+ } catch {
59
+ return null
60
+ }
61
+ }
62
+
63
+ module.exports = { init_from_env, init_from_flag, get_envelope, set_envelope, clear_envelope, get_intent_id, ENV_VAR }