happyskills 0.52.1 → 0.54.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 +17 -0
- package/package.json +1 -1
- package/src/api/client.js +21 -1
- package/src/commands/install.js +5 -0
- package/src/commands/login.js +43 -1
- package/src/commands/postlex.js +7 -1
- package/src/commands/search.js +8 -0
- package/src/commands/uninstall.js +2 -0
- package/src/index.js +10 -0
- package/src/utils/analytics.js +31 -0
- package/src/utils/attribution.js +55 -0
- package/src/utils/intent.js +63 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.54.0] - 2026-05-26
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`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.
|
|
15
|
+
|
|
16
|
+
## [0.53.0] - 2026-05-25
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- **First-launch attribution prompt on `happyskills login`** (spec 260521-03 § 10.1). The first successful login from a fresh install prints a one-shot, single-keypress prompt asking "what brought you here?" (Reddit / HN / Twitter / YouTube / blog / podcast / friend / AI search / other). Skippable (Enter). The answer is emitted as a `cli.attribution_answered` event to the API and updates `users.signup_source` (only when previously NULL — never overwrites). A marker file at `$XDG_CONFIG_HOME/happyskills/attribution-prompted` prevents re-prompting on subsequent logins.
|
|
21
|
+
- **`auth.cli_first_link` event** emitted alongside the attribution prompt — the principal-to-operator handoff signal for the time-to-CLI-link metric.
|
|
22
|
+
- **Surface tagging on every API request.** All API calls now carry `X-Client-Surface: cli` and `X-Client-Version: <cli_version>` headers so the API can bucket analytics events by source.
|
|
23
|
+
- **Intent envelope threading** (spec 260521-03 § 8.4) — every API call attaches the active `X-Intent-Envelope` request header; the refreshed value returned by the server is captured from the response and used on the next call (sliding TTL). The envelope is propagated to subprocesses via the `HAPPYSKILLS_INTENT_ENVELOPE` env var or the new top-level `--intent <base64>` flag, so a parent process (e.g. MCP server) can correlate a multi-step discovery workflow into one trace.
|
|
24
|
+
- **Install/uninstall analytics events** — `install.started` / `install.completed` / `install.failed` (with `error_code` on failure) emitted from `happyskills install`; `uninstall` emitted from `happyskills uninstall`. Fire-and-forget — never block the user-visible flow.
|
|
25
|
+
- **`cli/src/utils/{analytics,attribution,intent}.js`** — internal modules powering the above. All three are CommonJS, lazy-loaded, and fail silently on network errors so analytics never affects exit codes.
|
|
26
|
+
|
|
10
27
|
## [0.52.1] - 2026-05-25
|
|
11
28
|
|
|
12
29
|
### Fixed
|
package/package.json
CHANGED
package/src/api/client.js
CHANGED
|
@@ -3,6 +3,7 @@ const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
|
|
|
3
3
|
const { API_URL, CLI_VERSION } = require('../constants')
|
|
4
4
|
const { ApiError, NetworkError, AuthError } = require('../utils/errors')
|
|
5
5
|
const { load_token } = require('../auth/token_store')
|
|
6
|
+
const { get_envelope, set_envelope } = require('../utils/intent')
|
|
6
7
|
|
|
7
8
|
const get_base_url = () => process.env.HAPPYSKILLS_API_URL || API_URL
|
|
8
9
|
const USER_AGENT = `happyskills-cli/${CLI_VERSION}`
|
|
@@ -12,7 +13,19 @@ const request = (method, path, options = {}) => catch_errors(`API ${method} ${pa
|
|
|
12
13
|
const url = `${get_base_url()}${path}`
|
|
13
14
|
// User-Agent enables the API to identify CLI traffic (e.g. feedback API
|
|
14
15
|
// derives `source = 'cli'` from this header). Spec 260524-01 § 12.
|
|
15
|
-
|
|
16
|
+
// X-Client-Surface / X-Client-Version are required by spec 260521-03 § 10.2
|
|
17
|
+
// so analytics rows can be bucketed by source. X-Intent-Envelope threads
|
|
18
|
+
// the active discovery intent across calls per § 8.4.
|
|
19
|
+
const headers = {
|
|
20
|
+
'User-Agent': USER_AGENT,
|
|
21
|
+
'X-Client-Surface': 'cli',
|
|
22
|
+
'X-Client-Version': CLI_VERSION,
|
|
23
|
+
...extra_headers,
|
|
24
|
+
}
|
|
25
|
+
const active_envelope = get_envelope()
|
|
26
|
+
if (active_envelope && !headers['X-Intent-Envelope']) {
|
|
27
|
+
headers['X-Intent-Envelope'] = active_envelope
|
|
28
|
+
}
|
|
16
29
|
|
|
17
30
|
if (auth) {
|
|
18
31
|
const [, token_data] = await load_token()
|
|
@@ -42,6 +55,13 @@ const request = (method, path, options = {}) => catch_errors(`API ${method} ${pa
|
|
|
42
55
|
throw new NetworkError(`Failed to connect to API: ${err.message}`)
|
|
43
56
|
}
|
|
44
57
|
|
|
58
|
+
// Capture refreshed X-Intent-Envelope so subsequent calls (and any
|
|
59
|
+
// subprocess we later spawn via HAPPYSKILLS_INTENT_ENVELOPE) carry the
|
|
60
|
+
// updated TTL. Best-effort: missing header just means the endpoint
|
|
61
|
+
// doesn't refresh the envelope (most endpoints don't).
|
|
62
|
+
const refreshed_envelope = res.headers && res.headers.get && res.headers.get('x-intent-envelope')
|
|
63
|
+
if (refreshed_envelope) set_envelope(refreshed_envelope)
|
|
64
|
+
|
|
45
65
|
if (raw_response) return res
|
|
46
66
|
|
|
47
67
|
const data = await res.json().catch(() => null)
|
package/src/commands/install.js
CHANGED
|
@@ -12,6 +12,8 @@ const { file_exists } = require('../utils/fs')
|
|
|
12
12
|
const { hash_directory } = require('../lock/integrity')
|
|
13
13
|
const { get_all_locked_skills } = require('../lock/reader')
|
|
14
14
|
const snapshot_storage = require('../snapshot/storage')
|
|
15
|
+
const { emit: emit_analytics } = require('../utils/analytics')
|
|
16
|
+
const { CLI_VERSION } = require('../constants')
|
|
15
17
|
|
|
16
18
|
const HELP_TEXT = `Usage: happyskills install [<owner>/<skill>[@<version>]] [...] [options]
|
|
17
19
|
|
|
@@ -184,14 +186,17 @@ const run = (args) => catch_errors('Install failed', async () => {
|
|
|
184
186
|
const failures = []
|
|
185
187
|
for (const { skill, version: inline_version } of parsed) {
|
|
186
188
|
const version = flag_version || inline_version
|
|
189
|
+
emit_analytics('install.started', { cli_version: CLI_VERSION })
|
|
187
190
|
const [errors, result] = await install(skill, { ...base_options, version })
|
|
188
191
|
if (errors) {
|
|
189
192
|
const chain = errors?.map ? errors.map(x => x?.message).filter(Boolean) : [errors?.message || String(errors)]
|
|
190
193
|
const msg = chain[chain.length - 1] || chain[0] || 'Unknown error'
|
|
191
194
|
print_warn(`Failed to install ${skill}: ${msg}`)
|
|
192
195
|
failures.push({ skill, error: msg })
|
|
196
|
+
emit_analytics('install.failed', { error_code: 'INSTALL_FAILED', cli_version: CLI_VERSION })
|
|
193
197
|
} else {
|
|
194
198
|
results.push({ skill, result })
|
|
199
|
+
emit_analytics('install.completed', { cli_version: CLI_VERSION })
|
|
195
200
|
}
|
|
196
201
|
}
|
|
197
202
|
|
package/src/commands/login.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
const path = require('path')
|
|
1
3
|
const readline = require('readline')
|
|
2
4
|
const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
|
|
3
5
|
const auth_api = require('../api/auth')
|
|
@@ -5,8 +7,44 @@ const { save_token, load_token } = require('../auth/token_store')
|
|
|
5
7
|
const { create_spinner } = require('../ui/spinner')
|
|
6
8
|
const { print_help, print_success, print_info, print_hint, print_json, code } = require('../ui/output')
|
|
7
9
|
const { exit_with_error } = require('../utils/errors')
|
|
8
|
-
const { EXIT_CODES } = require('../constants')
|
|
10
|
+
const { EXIT_CODES, CLI_VERSION } = require('../constants')
|
|
9
11
|
const { run_browser_flow } = require('./login_device')
|
|
12
|
+
const { config_dir } = require('../config/paths')
|
|
13
|
+
const { prompt_for_source } = require('../utils/attribution')
|
|
14
|
+
const { emit } = require('../utils/analytics')
|
|
15
|
+
|
|
16
|
+
// Spec 260521-03 § 10.1 — One-shot first-launch attribution prompt. After
|
|
17
|
+
// the user has answered (or skipped), this marker file prevents re-prompting
|
|
18
|
+
// across subsequent logins on the same install.
|
|
19
|
+
const ATTRIBUTION_MARKER = () => path.join(config_dir(), 'attribution-prompted')
|
|
20
|
+
|
|
21
|
+
const attribution_already_prompted = () => {
|
|
22
|
+
try { return fs.existsSync(ATTRIBUTION_MARKER()) } catch { return false }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const mark_attribution_prompted = () => {
|
|
26
|
+
try {
|
|
27
|
+
fs.mkdirSync(config_dir(), { recursive: true })
|
|
28
|
+
fs.writeFileSync(ATTRIBUTION_MARKER(), new Date().toISOString())
|
|
29
|
+
} catch {
|
|
30
|
+
// Best-effort. Re-prompting once is annoying but not catastrophic.
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Fire-and-forget. Emits cli.attribution_answered + auth.cli_first_link.
|
|
35
|
+
const handle_post_login_telemetry = async () => {
|
|
36
|
+
// Always emit auth.cli_first_link on the first successful login from this
|
|
37
|
+
// install (gated by the same marker — once we've prompted/marked, we
|
|
38
|
+
// don't fire again).
|
|
39
|
+
if (attribution_already_prompted()) return
|
|
40
|
+
emit('auth.cli_first_link', { cli_version: CLI_VERSION })
|
|
41
|
+
|
|
42
|
+
const source = await prompt_for_source()
|
|
43
|
+
mark_attribution_prompted()
|
|
44
|
+
if (source) {
|
|
45
|
+
emit('cli.attribution_answered', { source })
|
|
46
|
+
}
|
|
47
|
+
}
|
|
10
48
|
|
|
11
49
|
const HELP_TEXT = `Usage: happyskills login [options]
|
|
12
50
|
|
|
@@ -95,6 +133,7 @@ const run_password_flow = () => catch_errors('Password login failed', async () =
|
|
|
95
133
|
if (save_err) { spinner.fail('Failed to save credentials'); throw e('Failed to save token', save_err) }
|
|
96
134
|
|
|
97
135
|
spinner.succeed('Logged in successfully')
|
|
136
|
+
await handle_post_login_telemetry()
|
|
98
137
|
})
|
|
99
138
|
|
|
100
139
|
const decode_jwt_payload = (token) => {
|
|
@@ -170,6 +209,9 @@ const run = (args) => catch_errors('Login failed', async () => {
|
|
|
170
209
|
} else {
|
|
171
210
|
const [errors] = await run_browser_flow()
|
|
172
211
|
if (errors) throw e('Browser login failed', errors)
|
|
212
|
+
// Browser flow doesn't go through run_password_flow's success branch,
|
|
213
|
+
// so we trigger the post-login telemetry here.
|
|
214
|
+
await handle_post_login_telemetry()
|
|
173
215
|
}
|
|
174
216
|
|
|
175
217
|
console.log()
|
package/src/commands/postlex.js
CHANGED
|
@@ -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
|
|
|
@@ -425,9 +426,13 @@ const run = (args) => catch_errors('Postlex failed', async () => {
|
|
|
425
426
|
const next_step = determine_next_step(final_ordering, query, clarification_turns_used)
|
|
426
427
|
|
|
427
428
|
// Fire telemetry — rerank_completed first (the rerank step succeeded);
|
|
428
|
-
// clarify_triggered separately if applicable.
|
|
429
|
+
// clarify_triggered separately if applicable. `intent_id` is sourced from
|
|
430
|
+
// the active intent envelope so these beacons correlate with the same
|
|
431
|
+
// discovery chain (spec 260521-03 addendum § 4).
|
|
432
|
+
const intent_id = get_intent_id()
|
|
429
433
|
fire_discovery_telemetry({
|
|
430
434
|
event: 'rerank_completed',
|
|
435
|
+
intent_id,
|
|
431
436
|
query,
|
|
432
437
|
promoted,
|
|
433
438
|
promoted_from_rank,
|
|
@@ -436,6 +441,7 @@ const run = (args) => catch_errors('Postlex failed', async () => {
|
|
|
436
441
|
if (next_step.action === 'clarify') {
|
|
437
442
|
fire_discovery_telemetry({
|
|
438
443
|
event: 'clarify_triggered',
|
|
444
|
+
intent_id,
|
|
439
445
|
query,
|
|
440
446
|
reason: 'post_rerank_weak',
|
|
441
447
|
turn_number: clarification_turns_used + 1,
|
package/src/commands/search.js
CHANGED
|
@@ -6,6 +6,7 @@ const { exit_with_error, UsageError, AuthError } = require('../utils/errors')
|
|
|
6
6
|
const { EXIT_CODES, VALID_SKILL_TYPES } = require('../constants')
|
|
7
7
|
const { load_token } = require('../auth/token_store')
|
|
8
8
|
const { fire_discovery_telemetry } = require('../api/telemetry')
|
|
9
|
+
const { get_intent_id } = require('../utils/intent')
|
|
9
10
|
|
|
10
11
|
const HELP_TEXT = `Usage: happyskills search [query] [options]
|
|
11
12
|
|
|
@@ -221,9 +222,14 @@ const run_smart_search = (args, query, options) => catch_errors('Smart search fa
|
|
|
221
222
|
const next_step = build_search_next_step(response, query, { with_rerank, clarification_turns_used })
|
|
222
223
|
|
|
223
224
|
// Telemetry beacons (fire-and-forget). Spec § 5.1 + § 5.3.
|
|
225
|
+
// `intent_id` is sourced from the active intent envelope (spec 260521-03
|
|
226
|
+
// addendum § 4) so search.rerank / search.clarify rows correlate with
|
|
227
|
+
// the same discovery chain as the search.query that minted the envelope.
|
|
228
|
+
const intent_id = get_intent_id()
|
|
224
229
|
if (with_rerank && next_step?.action === 'rank_digests_inline') {
|
|
225
230
|
fire_discovery_telemetry({
|
|
226
231
|
event: 'rerank_started',
|
|
232
|
+
intent_id,
|
|
227
233
|
query,
|
|
228
234
|
rerank_prompt_version: response?.rerank_prompt_version || null,
|
|
229
235
|
})
|
|
@@ -231,6 +237,7 @@ const run_smart_search = (args, query, options) => catch_errors('Smart search fa
|
|
|
231
237
|
if (with_rerank && next_step?.action === 'clarify') {
|
|
232
238
|
fire_discovery_telemetry({
|
|
233
239
|
event: 'clarify_triggered',
|
|
240
|
+
intent_id,
|
|
234
241
|
query,
|
|
235
242
|
reason: 'match_notice',
|
|
236
243
|
turn_number: clarification_turns_used + 1,
|
|
@@ -241,6 +248,7 @@ const run_smart_search = (args, query, options) => catch_errors('Smart search fa
|
|
|
241
248
|
// with a refined query.
|
|
242
249
|
fire_discovery_telemetry({
|
|
243
250
|
event: 'clarify_completed',
|
|
251
|
+
intent_id,
|
|
244
252
|
query,
|
|
245
253
|
turn_number: clarification_turns_used,
|
|
246
254
|
})
|
|
@@ -4,6 +4,7 @@ const { print_help, print_json, print_warn } = require('../ui/output')
|
|
|
4
4
|
const { exit_with_error, UsageError } = require('../utils/errors')
|
|
5
5
|
const { find_project_root } = require('../config/paths')
|
|
6
6
|
const { EXIT_CODES } = require('../constants')
|
|
7
|
+
const { emit: emit_analytics } = require('../utils/analytics')
|
|
7
8
|
|
|
8
9
|
const HELP_TEXT = `Usage: happyskills uninstall <owner/skill> [...] [options]
|
|
9
10
|
|
|
@@ -59,6 +60,7 @@ const run = (args) => catch_errors('Uninstall failed', async () => {
|
|
|
59
60
|
failures.push({ skill, error: msg })
|
|
60
61
|
} else {
|
|
61
62
|
results.push({ skill, result })
|
|
63
|
+
emit_analytics('uninstall', {})
|
|
62
64
|
}
|
|
63
65
|
}
|
|
64
66
|
|
package/src/index.js
CHANGED
|
@@ -134,6 +134,16 @@ const run = (argv) => {
|
|
|
134
134
|
set_json_mode()
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
// Spec 260521-03 § 8.4 — propagate intent envelope from --intent flag or
|
|
138
|
+
// the HAPPYSKILLS_INTENT_ENVELOPE env var (set by a parent process such
|
|
139
|
+
// as the MCP server). Initializing here makes the envelope visible to
|
|
140
|
+
// every subsequent API call within this CLI invocation.
|
|
141
|
+
if (args.flags.intent || process.env.HAPPYSKILLS_INTENT_ENVELOPE) {
|
|
142
|
+
const { init_from_flag, init_from_env } = require('./utils/intent')
|
|
143
|
+
if (args.flags.intent) init_from_flag(args.flags.intent)
|
|
144
|
+
else init_from_env()
|
|
145
|
+
}
|
|
146
|
+
|
|
137
147
|
if (args.flags.version === true) {
|
|
138
148
|
show_version()
|
|
139
149
|
return process.exit(EXIT_CODES.SUCCESS)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Spec 260521-03 § 7.3 — CLI client-emitted events.
|
|
2
|
+
//
|
|
3
|
+
// Fire-and-forget. Each event is its own POST to /events (no batching —
|
|
4
|
+
// CLI volume is low). A failure logs to stderr at debug level and never
|
|
5
|
+
// affects the user-visible flow. Lazy-loads the API client to keep startup
|
|
6
|
+
// fast.
|
|
7
|
+
|
|
8
|
+
const { error: { catch_errors } } = require('puffy-core')
|
|
9
|
+
|
|
10
|
+
const post_event = (event_type, metadata) => catch_errors('Failed to post analytics event', async () => {
|
|
11
|
+
const { post } = require('../api/client')
|
|
12
|
+
// Use unwrap:false so we don't trip on the empty body.
|
|
13
|
+
await post('/events', { events: [{
|
|
14
|
+
event_type,
|
|
15
|
+
metadata: metadata || {},
|
|
16
|
+
client_ts: Date.now(),
|
|
17
|
+
}] }, { auth: true, unwrap: false })
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
// Caller-friendly wrapper. Errors are swallowed — engagement telemetry must
|
|
21
|
+
// never block the user.
|
|
22
|
+
const emit = (event_type, metadata) => {
|
|
23
|
+
// Don't return the promise — fire-and-forget. Errors stay quiet.
|
|
24
|
+
post_event(event_type, metadata).then(([errors]) => {
|
|
25
|
+
if (errors && process.env.HAPPYSKILLS_DEBUG) {
|
|
26
|
+
process.stderr.write(`[analytics] ${event_type} failed: ${errors[0]?.message || 'unknown'}\n`)
|
|
27
|
+
}
|
|
28
|
+
}).catch(() => {})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
module.exports = { emit }
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Spec 260521-03 § 10.1, § 18.6 — First-launch attribution prompt.
|
|
2
|
+
//
|
|
3
|
+
// Shown once after the first successful `happyskills login` for a fresh
|
|
4
|
+
// install. Single keypress, skippable, no re-prompt.
|
|
5
|
+
|
|
6
|
+
const readline = require('readline')
|
|
7
|
+
|
|
8
|
+
const SOURCES = [
|
|
9
|
+
{ key: '1', value: 'reddit', label: 'Reddit' },
|
|
10
|
+
{ key: '2', value: 'hacker_news', label: 'Hacker News' },
|
|
11
|
+
{ key: '3', value: 'twitter', label: 'Twitter / X' },
|
|
12
|
+
{ key: '4', value: 'youtube', label: 'YouTube' },
|
|
13
|
+
{ key: '5', value: 'blog', label: 'Blog or article' },
|
|
14
|
+
{ key: '6', value: 'podcast', label: 'Podcast' },
|
|
15
|
+
{ key: '7', value: 'friend', label: 'A friend / colleague' },
|
|
16
|
+
{ key: '8', value: 'ai_search', label: 'Search (Google / ChatGPT / Claude / Perplexity)' },
|
|
17
|
+
{ key: '9', value: 'other', label: 'Other' },
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
const read_single_keypress = () => new Promise((resolve) => {
|
|
21
|
+
if (!process.stdin.isTTY) return resolve('')
|
|
22
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr })
|
|
23
|
+
process.stdin.setRawMode(true)
|
|
24
|
+
const on_data = (chunk) => {
|
|
25
|
+
const c = chunk.toString()
|
|
26
|
+
process.stdin.setRawMode(false)
|
|
27
|
+
process.stdin.removeListener('data', on_data)
|
|
28
|
+
rl.close()
|
|
29
|
+
// Ctrl-C → treat as skip but stop the process so callers don't continue.
|
|
30
|
+
if (c === '\x03') { process.stderr.write('\n'); process.exit(0) }
|
|
31
|
+
resolve(c)
|
|
32
|
+
}
|
|
33
|
+
process.stdin.on('data', on_data)
|
|
34
|
+
process.stdin.resume()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
// Returns the chosen source value (one of SOURCES[].value), or null if
|
|
38
|
+
// the user skipped (Enter or non-TTY).
|
|
39
|
+
const prompt_for_source = async () => {
|
|
40
|
+
process.stderr.write('\n')
|
|
41
|
+
process.stderr.write(' One quick thing — what brought you here? Choose one (or press Enter to skip):\n')
|
|
42
|
+
for (const s of SOURCES) {
|
|
43
|
+
process.stderr.write(` ${s.key}) ${s.label}\n`)
|
|
44
|
+
}
|
|
45
|
+
process.stderr.write('\n > ')
|
|
46
|
+
|
|
47
|
+
const keypress = await read_single_keypress()
|
|
48
|
+
process.stderr.write('\n')
|
|
49
|
+
|
|
50
|
+
if (!keypress || keypress === '\n' || keypress === '\r') return null
|
|
51
|
+
const match = SOURCES.find(s => s.key === keypress)
|
|
52
|
+
return match ? match.value : null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { prompt_for_source, SOURCES }
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Spec 260521-03 § 8.4 — Process-local intent envelope state.
|
|
2
|
+
//
|
|
3
|
+
// The CLI propagates the active envelope to subprocesses via the
|
|
4
|
+
// HAPPYSKILLS_INTENT_ENVELOPE env var or the --intent CLI flag. Within a
|
|
5
|
+
// single CLI process, the envelope lives in memory and is updated whenever
|
|
6
|
+
// the server sends back a refreshed value on the X-Intent-Envelope response
|
|
7
|
+
// header.
|
|
8
|
+
|
|
9
|
+
let _current = null
|
|
10
|
+
|
|
11
|
+
const ENV_VAR = 'HAPPYSKILLS_INTENT_ENVELOPE'
|
|
12
|
+
|
|
13
|
+
const init_from_env = () => {
|
|
14
|
+
if (_current) return _current
|
|
15
|
+
const from_env = process.env[ENV_VAR]
|
|
16
|
+
if (from_env) _current = from_env
|
|
17
|
+
return _current
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const init_from_flag = (envelope) => {
|
|
21
|
+
if (envelope) _current = envelope
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const get_envelope = () => init_from_env()
|
|
25
|
+
|
|
26
|
+
const set_envelope = (envelope) => {
|
|
27
|
+
if (envelope) {
|
|
28
|
+
_current = envelope
|
|
29
|
+
// Propagate to subprocesses spawned from this point on. We mutate
|
|
30
|
+
// process.env intentionally — child_process spawn() copies env at
|
|
31
|
+
// fork time, so setting it here makes the refreshed value visible
|
|
32
|
+
// to any subprocess started later in the same CLI invocation.
|
|
33
|
+
process.env[ENV_VAR] = envelope
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const clear_envelope = () => {
|
|
38
|
+
_current = null
|
|
39
|
+
delete process.env[ENV_VAR]
|
|
40
|
+
}
|
|
41
|
+
|
|
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 }
|