autark-cli 0.1.6 → 0.1.8

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 (2) hide show
  1. package/autark.mjs +488 -18
  2. package/package.json +2 -2
package/autark.mjs CHANGED
@@ -9,19 +9,138 @@ import fs from 'node:fs'
9
9
  import os from 'node:os'
10
10
  import path from 'node:path'
11
11
  import process from 'node:process'
12
+ import { spawn, spawnSync as childSpawnSync } from 'node:child_process'
12
13
 
13
14
  const API = process.env.AUTARK_API_URL || 'https://autark-api.kushalsokke.workers.dev'
14
15
  const AGENTMAIL_API = process.env.AGENTMAIL_API_URL || 'https://api.agentmail.to/v0'
15
- const CREDS_PATH = path.join(os.homedir(), '.autark', 'credentials.json')
16
+ const AUTARK_HOME = process.env.AUTARK_HOME || path.join(os.homedir(), '.autark')
17
+ const CREDS_PATH = process.env.AUTARK_CREDENTIALS || path.join(AUTARK_HOME, 'credentials.json')
16
18
 
17
19
  const args = process.argv.slice(2)
18
- main().catch(err => {
19
- console.error(err.message)
20
- process.exit(1)
21
- })
20
+ main()
21
+ .then(() => maybeNudge())
22
+ .catch(err => {
23
+ console.error(err.message)
24
+ maybeNudge()
25
+ process.exit(1)
26
+ })
27
+
28
+ // ============================================================== nudge
29
+ // Tail-print "[autark] update available" on stderr at the end of every
30
+ // command if the cached cloud state doesn't match local. The actual check
31
+ // runs in a detached child so it doesn't slow down the current command
32
+ // (the result lands in the cache for the NEXT invocation to read).
33
+ //
34
+ // Disable in `autark update` itself (don't nudge the user to do the thing
35
+ // they're already doing) and when AUTARK_NO_NUDGE=1.
36
+
37
+ const NUDGE_CACHE = path.join(
38
+ process.env.AUTARK_HOME || path.join(os.homedir(), '.autark'),
39
+ 'runtime',
40
+ 'update-check.json',
41
+ )
42
+ const NUDGE_TTL_MS = 24 * 60 * 60 * 1000 // 24h
43
+
44
+ function maybeNudge() {
45
+ try {
46
+ if (process.env.AUTARK_NO_NUDGE === '1') return
47
+ // The first arg group governs whether we print. update / login / logout
48
+ // don't need a nudge (login/logout aren't authenticated yet; update is
49
+ // already the action).
50
+ const group = args[0]
51
+ if (group === 'update' || group === 'login' || group === 'logout' || group === 'help'
52
+ || group === '--help' || group === '-h' || group === '--version' || !group) return
53
+
54
+ // Schedule a detached refresh — this exits immediately, the child
55
+ // writes to the cache file in the background.
56
+ scheduleBackgroundRefresh()
57
+
58
+ // Read whatever's in the cache RIGHT NOW (possibly stale) and decide
59
+ // whether to print. The cache holds the cloud state from the previous
60
+ // refresh; first invocation sees no cache and prints nothing.
61
+ const cache = readNudgeCache()
62
+ if (!cache) return
63
+ const localVersion = readLocalVersion()
64
+ const localInbox = (loadCredentials() || {}).agentmail_email || ''
65
+ const localCliVer = readInstalledVersion('autark-cli') || '0.0.0'
66
+
67
+ const runtimeBehind = cache.runtime_version && cache.runtime_version !== localVersion
68
+ const inboxBehind = cache.agentmail_email && cache.agentmail_email !== localInbox
69
+ const cliBehind = cache.autark_cli_latest && cmpVersions(localCliVer, cache.autark_cli_latest) < 0
70
+
71
+ if (runtimeBehind || inboxBehind || cliBehind) {
72
+ // Throttle: at most one nudge per CLI session (which is one
73
+ // invocation, so always true). Mark cache.nudged_at so future
74
+ // ticks within the same minute don't re-print (rapid agent loops).
75
+ const now = Date.now()
76
+ if (!cache.nudged_at || (now - cache.nudged_at) > 60_000) {
77
+ const reasons = []
78
+ if (cliBehind) reasons.push(`cli ${localCliVer} → ${cache.autark_cli_latest}`)
79
+ if (runtimeBehind) reasons.push(`runtime ${localVersion?.slice(0, 8) || '<none>'} → ${cache.runtime_version?.slice(0, 8) || '?'}`)
80
+ if (inboxBehind) reasons.push('inbox')
81
+ process.stderr.write(`[autark] update available (${reasons.join(', ')}) — run: autark update\n`)
82
+ cache.nudged_at = now
83
+ writeNudgeCache(cache)
84
+ }
85
+ }
86
+ } catch {
87
+ // Nudge errors are silent. The CLI does its real work; this is decoration.
88
+ }
89
+ }
90
+
91
+ function readNudgeCache() {
92
+ try {
93
+ if (!fs.existsSync(NUDGE_CACHE)) return null
94
+ return JSON.parse(fs.readFileSync(NUDGE_CACHE, 'utf8'))
95
+ } catch { return null }
96
+ }
97
+
98
+ function writeNudgeCache(obj) {
99
+ try {
100
+ fs.mkdirSync(path.dirname(NUDGE_CACHE), { recursive: true })
101
+ fs.writeFileSync(NUDGE_CACHE, JSON.stringify(obj))
102
+ } catch {}
103
+ }
104
+
105
+ function scheduleBackgroundRefresh() {
106
+ try {
107
+ const cache = readNudgeCache()
108
+ const now = Date.now()
109
+ if (cache?.fetched_at && (now - cache.fetched_at) < NUDGE_TTL_MS) return
110
+ // Spawn a detached child that fetches /v1/me + npm latest, writes the
111
+ // cache, exits. Doesn't block the parent.
112
+ const child = spawn(
113
+ process.argv[0],
114
+ [process.argv[1], '__nudge_refresh__'],
115
+ { detached: true, stdio: 'ignore', env: { ...process.env, AUTARK_NO_NUDGE: '1' } },
116
+ )
117
+ child.unref()
118
+ } catch {}
119
+ }
120
+
121
+ // Invoked by scheduleBackgroundRefresh as a detached child. Refreshes the
122
+ // cache then exits. Hidden internal subcommand — not in the usage doc.
123
+ async function nudgeRefresh() {
124
+ try {
125
+ const creds = loadCredentials() || {}
126
+ if (!creds.token) return
127
+ const me = await api('GET', '/v1/me')
128
+ const cliLatest = await fetchNpmLatest('autark-cli')
129
+ const cache = readNudgeCache() || {}
130
+ writeNudgeCache({
131
+ ...cache,
132
+ runtime_version: me.runtime_version,
133
+ agentmail_email: me.agentmail_email,
134
+ autark_cli_latest: cliLatest,
135
+ fetched_at: Date.now(),
136
+ })
137
+ } catch {}
138
+ }
22
139
 
23
140
  async function main() {
24
141
  const [group, command, ...rest] = args
142
+ // Hidden subcommand used by the background nudge refresh.
143
+ if (group === '__nudge_refresh__') return nudgeRefresh()
25
144
  if (!group || group === 'help' || group === '--help' || group === '-h') return usage()
26
145
 
27
146
  if (group === 'login') {
@@ -56,6 +175,8 @@ async function main() {
56
175
  return context([command, ...rest].filter(Boolean))
57
176
  }
58
177
  if (group === 'mail') return mail(command, rest)
178
+ if (group === 'settings') return settings(command, rest)
179
+ if (group === 'update') return update(rest)
59
180
 
60
181
  usage()
61
182
  process.exit(1)
@@ -99,6 +220,293 @@ async function me() {
99
220
  console.log(`${result.email} (${result.id})`)
100
221
  }
101
222
 
223
+ // ============================================================== settings
224
+ // Account-wide settings the agent reads at run start.
225
+
226
+ async function settings(command, rest) {
227
+ if (!command || command === 'show') return settingsShow(rest)
228
+ if (command === '--help' || command === '-h') return settingsUsage()
229
+ throw new Error(`unknown settings subcommand: ${command}`)
230
+ }
231
+
232
+ async function settingsShow(rest) {
233
+ const opts = parseArgs(rest)
234
+ const result = await api('GET', '/v1/settings')
235
+ const s = result?.settings || {}
236
+ if (opts.json) {
237
+ console.log(JSON.stringify(s, null, 2))
238
+ return
239
+ }
240
+ // Plain key=value output the prompt can grep against. Empty values render
241
+ // as `<unset>` so the agent can branch.
242
+ const fmt = (v) => (v === null || v === undefined || v === '') ? '<unset>' : v
243
+ console.log(`scheduler_link ${fmt(s.scheduler_link)}`)
244
+ console.log(`personal_link ${fmt(s.personal_link)}`)
245
+ console.log(`agentmail_email ${fmt(s.agentmail_email)}`)
246
+ console.log(`custom_domain ${fmt(s.custom_domain)}`)
247
+ console.log(`domain_status ${fmt(s.custom_domain_status)}`)
248
+ }
249
+
250
+ function settingsUsage() {
251
+ console.log('usage:')
252
+ console.log(' autark settings show [--json] account-wide settings (scheduler link, inbox, custom domain)')
253
+ }
254
+
255
+ // ================================================================ update
256
+ // `autark update` — keep the laptop in sync with the cloud canon.
257
+ //
258
+ // Steps (see docs/autark-update.md):
259
+ // 0. Self-update the CLI ecosystem (autark-cli + plumcake-cli + chrome-relay)
260
+ // via pnpm. Re-exec autark if its own version moved.
261
+ // 1. GET /v1/me — compare runtime_version + agentmail_email against local.
262
+ // 2. If runtime_version differs: curl the canonical files from the repo,
263
+ // refresh the 6 kstack skills via pnpm dlx, reload launchd, stamp the
264
+ // new version into ~/.autark/runtime/version.txt.
265
+ // 3. If agentmail_email differs: POST /v1/inbox/rotate to re-mint a fresh
266
+ // inbox-scoped key, rewrite credentials.json.
267
+ //
268
+ // Rollback on partial failure: the version stamp is only written if every
269
+ // step succeeded. "Either fully on version X or fully on version Y."
270
+ //
271
+ // AUTARK_UPDATE_DRY_RUN=1 prints what would happen, doesn't execute.
272
+ // AUTARK_UPDATE_OFFLINE=1 skips the self-update step (for tests).
273
+ // AUTARK_HOME overrides the install root (for sandbox tests).
274
+
275
+ // The autark repo is private. Pages serves the canonical files at
276
+ // autark.kushalsm.com/runtime/* (synced from runtime/ at build time).
277
+ const RAW_RUNTIME_BASE = process.env.AUTARK_RUNTIME_RAW_BASE
278
+ || 'https://autark.kushalsm.com/runtime'
279
+ const SKILL_NAMES = ['autark', 'plumcake', 'chrome-relay', 'email', 'outreach', 'email-finder']
280
+ const ECOSYSTEM_CLIS = ['autark-cli', 'plumcake-cli', 'chrome-relay']
281
+ const PROGRAM_FILES = ['start.md', 'double-down.md', 'check.md', 'followup.md']
282
+
283
+ async function update(rest) {
284
+ const opts = parseArgs(rest)
285
+ const dryRun = !!process.env.AUTARK_UPDATE_DRY_RUN || !!opts['dry-run']
286
+ const skipSelfUpdate = !!process.env.AUTARK_UPDATE_OFFLINE
287
+ const log = (msg) => console.log(msg)
288
+ const sub = (msg) => console.log(` ${msg}`)
289
+
290
+ log('autark update')
291
+
292
+ // ---- Step 0: self-update CLI ecosystem ---------------------------------
293
+ if (!skipSelfUpdate) {
294
+ log('checking CLI ecosystem versions…')
295
+ const upgrades = []
296
+ for (const pkg of ECOSYSTEM_CLIS) {
297
+ const installed = readInstalledVersion(pkg)
298
+ const latest = await fetchNpmLatest(pkg)
299
+ if (!latest) { sub(`! ${pkg}: could not reach npm registry, skipping`); continue }
300
+ if (!installed || cmpVersions(installed, latest) < 0) {
301
+ upgrades.push({ pkg, installed, latest })
302
+ sub(`${pkg}: ${installed || '(not installed)'} → ${latest}`)
303
+ } else {
304
+ sub(`${pkg}: ${installed} (current)`)
305
+ }
306
+ }
307
+
308
+ if (upgrades.length && !dryRun) {
309
+ log(`upgrading ${upgrades.length} CLI(s) via pnpm…`)
310
+ const args = ['add', '-g', ...upgrades.map((u) => `${u.pkg}@latest`)]
311
+ const res = await spawnSync('pnpm', args)
312
+ if (res.status !== 0) {
313
+ console.error(' pnpm add -g failed; aborting (rollback). Stay on previous version.')
314
+ process.exit(2)
315
+ }
316
+ // chrome-relay's npm package ships with a native-host installer that
317
+ // registers the host with Chrome at the OS level. Refresh after upgrade.
318
+ if (upgrades.some((u) => u.pkg === 'chrome-relay')) {
319
+ sub('refreshing chrome-relay native host…')
320
+ await spawnSync('chrome-relay', ['install']) // non-fatal if it fails
321
+ }
322
+ // If autark-cli itself was upgraded, re-exec with the new binary so the
323
+ // rest of the run uses the new code.
324
+ if (upgrades.some((u) => u.pkg === 'autark-cli')) {
325
+ sub('re-exec autark with new binary…')
326
+ const child = spawn(process.argv[0], process.argv.slice(1), {
327
+ stdio: 'inherit',
328
+ env: { ...process.env, AUTARK_UPDATE_OFFLINE: '1' }, // don't re-self-update
329
+ })
330
+ child.on('exit', (code) => process.exit(code ?? 0))
331
+ return // hand off to child
332
+ }
333
+ }
334
+ }
335
+
336
+ // ---- Step 1+2: compare /v1/me + refresh runtime + credentials ---------
337
+ log('fetching /v1/me…')
338
+ const me = await api('GET', '/v1/me')
339
+ const localVersion = readLocalVersion()
340
+ const localInbox = (loadCredentials() || {}).agentmail_email || ''
341
+
342
+ const runtimeBehind = me.runtime_version && me.runtime_version !== localVersion
343
+ const inboxBehind = me.agentmail_email && me.agentmail_email !== localInbox
344
+
345
+ sub(`runtime_version local=${localVersion || '<none>'} cloud=${me.runtime_version} ${runtimeBehind ? '→ refresh' : 'ok'}`)
346
+ sub(`agentmail_email local=${localInbox || '<none>'} cloud=${me.agentmail_email} ${inboxBehind ? '→ rotate' : 'ok'}`)
347
+
348
+ if (!runtimeBehind && !inboxBehind) {
349
+ log('already up to date')
350
+ return
351
+ }
352
+ if (dryRun) {
353
+ log(`(dry-run) would refresh runtime=${runtimeBehind} inbox=${inboxBehind}`)
354
+ return
355
+ }
356
+
357
+ // Refresh runtime files + skills (all-or-nothing for rollback safety).
358
+ if (runtimeBehind) {
359
+ try {
360
+ log('refreshing runtime files…')
361
+ await refreshRuntimeFiles()
362
+ log('refreshing kstack skills…')
363
+ await refreshSkills()
364
+ log('reloading launchd…')
365
+ await reloadLaunchd()
366
+ // Only stamp the version AFTER every step succeeded.
367
+ writeLocalVersion(me.runtime_version)
368
+ sub(`stamped ~/.autark/runtime/version.txt → ${me.runtime_version}`)
369
+ } catch (e) {
370
+ console.error(` runtime refresh failed: ${e.message}`)
371
+ console.error(' rolled back: version stamp NOT written. Re-run autark update.')
372
+ process.exit(2)
373
+ }
374
+ }
375
+
376
+ // Rotate credentials (separate from runtime refresh — independent stream).
377
+ if (inboxBehind) {
378
+ log('rotating AgentMail credentials…')
379
+ const rotated = await api('POST', '/v1/inbox/rotate', {})
380
+ saveCredentials({
381
+ ...loadCredentials(),
382
+ agentmail_email: rotated.agentmail_email,
383
+ agentmail_inbox_id: rotated.agentmail_inbox_id,
384
+ agentmail_token: rotated.agentmail_token,
385
+ rotated_at: new Date().toISOString(),
386
+ })
387
+ sub(`new inbox: ${rotated.agentmail_email}`)
388
+ }
389
+
390
+ log(`autark updated → ${me.runtime_version}`)
391
+ }
392
+
393
+ // -----------------------------------------------------------------------
394
+ // update() helpers — kept inline because they're only used by `autark update`.
395
+
396
+ function readInstalledVersion(pkg) {
397
+ // Locate the global pnpm install path and read package.json's version.
398
+ try {
399
+ const child = childSpawnSync('pnpm', ['root', '-g'], { encoding: 'utf8' })
400
+ if (child.status !== 0) return null
401
+ const root = child.stdout.toString().trim()
402
+ const pj = path.join(root, pkg, 'package.json')
403
+ if (!fs.existsSync(pj)) return null
404
+ return JSON.parse(fs.readFileSync(pj, 'utf8')).version || null
405
+ } catch {
406
+ return null
407
+ }
408
+ }
409
+
410
+ async function fetchNpmLatest(pkg) {
411
+ try {
412
+ const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(pkg)}/latest`)
413
+ if (!res.ok) return null
414
+ const j = await res.json()
415
+ return j?.version || null
416
+ } catch {
417
+ return null
418
+ }
419
+ }
420
+
421
+ // Tiny semver compare — handles N.N.N strings. Returns -1/0/1.
422
+ function cmpVersions(a, b) {
423
+ const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0)
424
+ const pb = String(b).split('.').map((n) => parseInt(n, 10) || 0)
425
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
426
+ const x = pa[i] || 0, y = pb[i] || 0
427
+ if (x !== y) return x < y ? -1 : 1
428
+ }
429
+ return 0
430
+ }
431
+
432
+ function readLocalVersion() {
433
+ const p = path.join(AUTARK_HOME, 'runtime', 'version.txt')
434
+ if (!fs.existsSync(p)) return ''
435
+ return fs.readFileSync(p, 'utf8').trim()
436
+ }
437
+
438
+ function writeLocalVersion(v) {
439
+ const p = path.join(AUTARK_HOME, 'runtime', 'version.txt')
440
+ fs.mkdirSync(path.dirname(p), { recursive: true })
441
+ fs.writeFileSync(p, v + '\n')
442
+ }
443
+
444
+ async function curlFile(url, dest) {
445
+ const res = await fetch(url)
446
+ if (!res.ok) throw new Error(`${res.status} fetching ${url}`)
447
+ const body = await res.text()
448
+ fs.mkdirSync(path.dirname(dest), { recursive: true })
449
+ fs.writeFileSync(dest, body)
450
+ }
451
+
452
+ async function refreshRuntimeFiles() {
453
+ const runtime = path.join(AUTARK_HOME, 'runtime')
454
+ fs.mkdirSync(path.join(runtime, 'programs'), { recursive: true })
455
+ await curlFile(`${RAW_RUNTIME_BASE}/agent_keepalive.py`, path.join(runtime, 'agent_keepalive.py'))
456
+ fs.chmodSync(path.join(runtime, 'agent_keepalive.py'), 0o755)
457
+ for (const f of PROGRAM_FILES) {
458
+ await curlFile(`${RAW_RUNTIME_BASE}/programs/${f}`, path.join(runtime, 'programs', f))
459
+ }
460
+ const agentSh = path.join(AUTARK_HOME, 'agent.sh')
461
+ await curlFile(`${RAW_RUNTIME_BASE}/agent.sh`, agentSh)
462
+ fs.chmodSync(agentSh, 0o755)
463
+ }
464
+
465
+ async function refreshSkills() {
466
+ for (const name of SKILL_NAMES) {
467
+ const res = await spawnSync('pnpm', ['dlx', '--silent', 'skills', 'add', `kiluazen/skills@${name}`, '-y', '-g'])
468
+ if (res.status !== 0) throw new Error(`skill ${name} install failed`)
469
+ }
470
+ }
471
+
472
+ async function reloadLaunchd() {
473
+ if (process.platform !== 'darwin') return
474
+ // Safety: only touch launchd when AUTARK_HOME is the canonical install
475
+ // location. Sandbox tests (AUTARK_HOME=/tmp/...) must never rewrite the
476
+ // global plist — it would point launchd at a tempdir that disappears.
477
+ const canonicalHome = path.join(os.homedir(), '.autark')
478
+ if (path.resolve(AUTARK_HOME) !== path.resolve(canonicalHome)) {
479
+ return
480
+ }
481
+ const plist = path.join(os.homedir(), 'Library', 'LaunchAgents', 'com.autark.runner.plist')
482
+ if (!fs.existsSync(plist)) return // user hasn't installed launchd unit
483
+ // Re-render plist from template (template was refreshed alongside agent.sh).
484
+ const tmplUrl = `${RAW_RUNTIME_BASE}/com.autark.runner.plist.tmpl`
485
+ const tmplRes = await fetch(tmplUrl)
486
+ if (tmplRes.ok) {
487
+ const tmpl = await tmplRes.text()
488
+ const agentSh = path.join(AUTARK_HOME, 'agent.sh')
489
+ const agentLog = path.join(AUTARK_HOME, 'agent.log')
490
+ const rendered = tmpl
491
+ .replaceAll('${HOME_AUTARK}', AUTARK_HOME)
492
+ .replaceAll('${AGENT_SH}', agentSh)
493
+ .replaceAll('${AGENT_LOG}', agentLog)
494
+ fs.writeFileSync(plist, rendered)
495
+ }
496
+ await spawnSync('launchctl', ['unload', plist])
497
+ await spawnSync('launchctl', ['load', plist])
498
+ }
499
+
500
+ // Promise-wrapped spawn — returns { status: number }. stdio inherited so the
501
+ // user sees pnpm's output. Used for self-update + skills refresh.
502
+ function spawnSync(cmd, args) {
503
+ return new Promise((resolve) => {
504
+ const child = spawn(cmd, args, { stdio: 'inherit' })
505
+ child.on('exit', (code) => resolve({ status: code ?? 0 }))
506
+ child.on('error', () => resolve({ status: 1 }))
507
+ })
508
+ }
509
+
102
510
  // =========================================================== products
103
511
 
104
512
  async function productUpsert(rest) {
@@ -457,30 +865,92 @@ async function context(rest) {
457
865
  return printHypothesisContext(await api('GET', `/v1/context/${encodeURIComponent(productSlug)}/${encodeURIComponent(code)}`))
458
866
  }
459
867
 
868
+ // Output shape: a structured key-block (one fact per line, stable dotted-path
869
+ // keys) first, then a `---` separator, then the narrative prose. Agents can
870
+ // `grep '^action\.[a-f0-9]+\.meeting_link_sent_at'` for clean structured
871
+ // access; the narrative is still there for context.
872
+
873
+ function kv(k, v) {
874
+ if (v === undefined || v === null || v === '') return
875
+ // Single line, value as-is. No quoting — values with spaces are fine; the
876
+ // first whitespace run after the key marks the boundary.
877
+ console.log(`${k.padEnd(40)} ${v}`)
878
+ }
879
+
880
+ function actionShortId(id) {
881
+ return String(id || '').slice(0, 8)
882
+ }
883
+
460
884
  function printProductContext(r) {
461
- console.log(`# ${r.product.slug} ${r.product.name}`)
462
- console.log(`id: ${r.product.id}`)
463
- if (r.product.tagline) console.log(`> ${r.product.tagline}`)
464
- if (r.product.url) console.log(`> ${r.product.url}`)
465
- console.log(`\n## Brief\n`)
885
+ // ---- structured key-block ----
886
+ kv('product.id', r.product.id)
887
+ kv('product.slug', r.product.slug)
888
+ kv('product.name', r.product.name)
889
+ kv('product.tagline', r.product.tagline)
890
+ kv('product.url', r.product.url)
891
+ kv('product.visibility', r.product.visibility)
892
+ kv('product.hypothesis_count', (r.hypotheses || []).length)
893
+ for (const h of (r.hypotheses || [])) {
894
+ kv(`hypothesis.${h.code}.id`, h.id)
895
+ kv(`hypothesis.${h.code}.title`, h.title)
896
+ kv(`hypothesis.${h.code}.status`, h.status)
897
+ kv(`hypothesis.${h.code}.run_count`, h.run_count)
898
+ }
899
+ console.log('---')
900
+ // ---- narrative ----
901
+ console.log(`# ${r.product.slug} — ${r.product.name}\n`)
902
+ if (r.product.tagline) console.log(`> ${r.product.tagline}\n`)
903
+ console.log(`## Brief\n`)
466
904
  console.log(r.product.brief?.trim() || '(no brief set — owner can add one at https://autark.kushalsm.com)')
467
- console.log(`\n## Hypotheses (${r.hypotheses.length})\n`)
468
- if (!r.hypotheses.length) {
469
- console.log('(none yet — create one with: autark hypothesis create --product-id <product_id> --md @hyp.md)')
905
+ if (!r.hypotheses?.length) {
906
+ console.log(`\n## Hypotheses\n\n(none yet — create one with: autark hypothesis create --product-id <product_id> --md @hyp.md)`)
470
907
  } else {
908
+ console.log(`\n## Hypotheses (${r.hypotheses.length})\n`)
471
909
  for (const h of r.hypotheses) {
472
- console.log(`- [${h.status}] ${h.code} id=${h.id} — ${h.title} (runs: ${h.run_count})`)
910
+ console.log(`- [${h.status}] ${h.code} — ${h.title} (runs: ${h.run_count})`)
473
911
  }
474
912
  }
475
913
  }
476
914
 
477
915
  function printHypothesisContext(result) {
916
+ // ---- structured key-block ----
917
+ kv('product.id', result.product.id)
918
+ kv('product.slug', result.product.slug)
919
+ kv('hypothesis.id', result.hypothesis.id)
920
+ kv('hypothesis.code', result.hypothesis.code)
921
+ kv('hypothesis.title', result.hypothesis.title)
922
+ kv('hypothesis.status', result.hypothesis.status)
923
+ for (const run of (result.runs || [])) {
924
+ const shortRun = actionShortId(run.id)
925
+ kv(`run.${shortRun}.id`, run.id)
926
+ kv(`run.${shortRun}.run_number`, run.run_number)
927
+ kv(`run.${shortRun}.started_at`, run.started_at)
928
+ if (run.finished_at) kv(`run.${shortRun}.finished_at`, run.finished_at)
929
+ for (const a of (run.actions || [])) {
930
+ const shortA = actionShortId(a.id)
931
+ kv(`action.${shortA}.id`, a.id)
932
+ kv(`action.${shortA}.run_id`, run.id)
933
+ kv(`action.${shortA}.channel`, a.channel)
934
+ kv(`action.${shortA}.title`, a.title)
935
+ if (a.url) kv(`action.${shortA}.url`, a.url)
936
+ if (a.recipient) kv(`action.${shortA}.recipient`, a.recipient)
937
+ if (a.agentmail_thread_id) kv(`action.${shortA}.agentmail_thread_id`, a.agentmail_thread_id)
938
+ if (a.agentmail_inbox_id) kv(`action.${shortA}.agentmail_inbox_id`, a.agentmail_inbox_id)
939
+ if (a.occurred_at) kv(`action.${shortA}.occurred_at`, a.occurred_at)
940
+ // Spread well-known metadata keys to their own lines for greppability.
941
+ const md = a.metadata || {}
942
+ for (const [mk, mv] of Object.entries(md)) {
943
+ if (typeof mv === 'string' || typeof mv === 'number' || typeof mv === 'boolean') {
944
+ kv(`action.${shortA}.metadata.${mk}`, mv)
945
+ }
946
+ }
947
+ }
948
+ }
949
+ console.log('---')
950
+ // ---- narrative ----
478
951
  console.log(`# ${result.product.slug}/${result.hypothesis.code} — ${result.hypothesis.title}\n`)
479
- console.log(`product_id: ${result.product.id}`)
480
- console.log(`hypothesis_id: ${result.hypothesis.id}`)
481
- console.log(`Status: ${result.hypothesis.status}\n`)
482
952
  console.log(result.hypothesis.hypothesis_md)
483
- for (const run of result.runs) {
953
+ for (const run of (result.runs || [])) {
484
954
  console.log(`\n\n## Run ${run.run_number} (${run.id})`)
485
955
  console.log(`started: ${run.started_at}${run.finished_at ? ` finished: ${run.finished_at}` : ' (in progress)'}`)
486
956
  if (run.actions?.length) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autark-cli",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "CLI for autark — hypothesis-driven product runbooks. Track products, hypotheses, runs, and actions from the terminal.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -19,4 +19,4 @@
19
19
  "engines": {
20
20
  "node": ">=18"
21
21
  }
22
- }
22
+ }