autark-cli 0.1.7 → 0.1.9

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