autark-cli 0.1.8 → 0.2.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 (2) hide show
  1. package/autark.mjs +237 -13
  2. package/package.json +2 -2
package/autark.mjs CHANGED
@@ -16,6 +16,17 @@ const AGENTMAIL_API = process.env.AGENTMAIL_API_URL || 'https://api.agentmail.to
16
16
  const AUTARK_HOME = process.env.AUTARK_HOME || path.join(os.homedir(), '.autark')
17
17
  const CREDS_PATH = process.env.AUTARK_CREDENTIALS || path.join(AUTARK_HOME, 'credentials.json')
18
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
+
19
30
  const args = process.argv.slice(2)
20
31
  main()
21
32
  .then(() => maybeNudge())
@@ -152,13 +163,18 @@ async function main() {
152
163
  if (group === 'me') return me()
153
164
 
154
165
  if (group === 'product') {
155
- if (command === 'upsert') return productUpsert(rest)
156
- if (command === 'list') return productList()
166
+ if (command === 'upsert') return productUpsert(rest)
167
+ if (command === 'list') return productList()
168
+ if (command === 'pause') return productPause(rest, 'paused')
169
+ if (command === 'unpause') return productPause(rest, 'active')
170
+ if (command === 'run-status') return productPause(rest)
157
171
  if (!command || command === '--help' || command === '-h') return productUsage()
158
172
  }
159
173
  if (group === 'hypothesis') {
160
174
  if (command === 'create') return hypothesisCreate(rest)
161
175
  if (command === 'status') return hypothesisStatus(rest)
176
+ if (command === 'pause') return hypothesisPause(rest, 'inactive')
177
+ if (command === 'unpause') return hypothesisPause(rest, 'active')
162
178
  if (!command || command === '--help' || command === '-h') return hypothesisUsage()
163
179
  }
164
180
  if (group === 'run') {
@@ -170,6 +186,20 @@ async function main() {
170
186
  if (command === 'action') return logAction(rest)
171
187
  if (!command || command === '--help' || command === '-h') return logUsage()
172
188
  }
189
+ if (group === 'action') {
190
+ if (command === 'escalate') return actionEscalate(rest)
191
+ if (command === 'unescalate') return actionEscalate(rest, { clear: true })
192
+ if (!command || command === '--help' || command === '-h') return actionUsage()
193
+ }
194
+ if (group === 'feedback') {
195
+ if (command === 'record') return feedbackRecord(rest)
196
+ if (command === 'delete') return feedbackDelete(rest)
197
+ if (!command || command === '--help' || command === '-h') return feedbackUsage()
198
+ }
199
+ if (group === 'deliverability') {
200
+ if (command === 'check') return deliverabilityCheck(rest)
201
+ if (!command || command === '--help' || command === '-h') return deliverabilityUsage()
202
+ }
173
203
  if (group === 'context') {
174
204
  if (!command || command === '--help' || command === '-h') return contextUsage()
175
205
  return context([command, ...rest].filter(Boolean))
@@ -245,6 +275,9 @@ async function settingsShow(rest) {
245
275
  console.log(`agentmail_email ${fmt(s.agentmail_email)}`)
246
276
  console.log(`custom_domain ${fmt(s.custom_domain)}`)
247
277
  console.log(`domain_status ${fmt(s.custom_domain_status)}`)
278
+ if (Array.isArray(s.custom_domains) && s.custom_domains.length) {
279
+ console.log(`custom_domains ${s.custom_domains.map((d) => `${d.domain}:${d.status}`).join(', ')}`)
280
+ }
248
281
  }
249
282
 
250
283
  function settingsUsage() {
@@ -272,14 +305,6 @@ function settingsUsage() {
272
305
  // AUTARK_UPDATE_OFFLINE=1 skips the self-update step (for tests).
273
306
  // AUTARK_HOME overrides the install root (for sandbox tests).
274
307
 
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
308
  async function update(rest) {
284
309
  const opts = parseArgs(rest)
285
310
  const dryRun = !!process.env.AUTARK_UPDATE_DRY_RUN || !!opts['dry-run']
@@ -609,6 +634,20 @@ async function logAction(rest) {
609
634
  const run = required(opts['run-id'] || opts.run_id || opts.run, '--run-id')
610
635
  const channel = required(opts.channel, '--channel')
611
636
  const title = required(opts.title, '--title')
637
+ const needsHuman = Boolean(opts['needs-human'] || opts.needs_human || opts.escalate)
638
+ const escalationReason = opts['escalation-reason'] || opts.escalation_reason || opts.reason || undefined
639
+
640
+ // Merge `our_comment_id` into metadata if passed as a sugar flag. This is
641
+ // the per-channel anchor the reply-state cron uses to detect engagement on
642
+ // OUR specific comment (vs the parent we were replying to). Always pass
643
+ // it when you posted a comment somewhere we can re-fetch — github issue/PR
644
+ // comments, gists, HN replies, Substack comments, Reddit comments.
645
+ const meta = opts.metadata ? JSON.parse(readValue(opts.metadata)) : undefined
646
+ const ourCommentId = opts['our-comment-id'] || opts.our_comment_id
647
+ const metadata = ourCommentId
648
+ ? { ...(meta || {}), our_comment_id: String(ourCommentId) }
649
+ : meta
650
+
612
651
  const result = await api('POST', `/v1/runs/${encodeURIComponent(run)}/actions`, {
613
652
  channel,
614
653
  title,
@@ -616,12 +655,156 @@ async function logAction(rest) {
616
655
  agentmail_thread_id: opts['agentmail-thread-id'] || opts['thread-id'] || opts.thread_id || undefined,
617
656
  agentmail_inbox_id: opts['agentmail-inbox-id'] || opts.inbox_id || opts.agentmail_inbox_id || undefined,
618
657
  recipient: opts.recipient || undefined,
619
- metadata: opts.metadata ? JSON.parse(readValue(opts.metadata)) : undefined,
658
+ metadata,
659
+ needs_human: needsHuman || undefined,
660
+ escalation_reason: needsHuman ? escalationReason : undefined,
620
661
  occurred_at: opts['occurred-at'] || opts.occurred_at || undefined,
621
662
  })
622
663
  console.log(result.id)
623
664
  }
624
665
 
666
+ // ============================================================ action escalate
667
+
668
+ async function actionEscalate(rest, { clear = false } = {}) {
669
+ const opts = parseArgs(rest)
670
+ const id = required(opts['action-id'] || opts.action_id || opts._[0], 'action id')
671
+ const reason = opts.reason || opts._.slice(1).join(' ') || undefined
672
+ const body = { needs_human: !clear }
673
+ if (!clear) body.reason = reason || ''
674
+ const result = await api('PATCH', `/v1/actions/${encodeURIComponent(id)}/escalate`, body)
675
+ console.log(`${result.id} needs_human=${result.needs_human}${result.escalation_reason ? ` reason="${result.escalation_reason}"` : ''}`)
676
+ }
677
+
678
+ function actionUsage() {
679
+ console.log(`autark action escalate <action-id> [--reason "<one-line why>"]
680
+ autark action unescalate <action-id>`)
681
+ }
682
+
683
+ // ============================================================ product pause
684
+
685
+ async function productPause(rest, fixedStatus) {
686
+ const opts = parseArgs(rest)
687
+ const slug = opts.slug || opts._[0]
688
+ let productId = opts['product-id'] || opts.product_id
689
+ if (!productId && slug) {
690
+ const list = await api('GET', '/v1/products')
691
+ const found = (list.products || []).find((p) => p.slug === slug)
692
+ if (!found) throw new Error(`product not found: ${slug}`)
693
+ productId = found.id
694
+ }
695
+ if (!productId) throw new Error('pass product slug or --product-id')
696
+ const status = fixedStatus || required(opts.status, '--status (active|paused)')
697
+ const result = await api('PATCH', `/v1/products/${encodeURIComponent(productId)}/run-status`, { run_status: status })
698
+ console.log(`${slug || productId} → run_status=${result.run_status}`)
699
+ }
700
+
701
+ // ============================================================ hypothesis pause
702
+ // Hypothesis-level pause is just a status flip. Convenience wrappers around the
703
+ // existing /hypotheses/:id/status endpoint so the CLI surface matches "pause".
704
+
705
+ async function hypothesisPause(rest, status) {
706
+ const opts = parseArgs(rest)
707
+ const ref = opts._[0] || opts.hypothesis
708
+ let hypId = opts['hypothesis-id'] || opts.hypothesis_id || opts.id
709
+ let label = hypId || ref
710
+ if (!hypId) {
711
+ if (!ref) throw new Error('pass product/Hxx or --hypothesis-id')
712
+ const [productSlug, code] = splitHypothesisRef(ref)
713
+ const ctx = await api('GET', `/v1/context/${encodeURIComponent(productSlug)}/${encodeURIComponent(code)}`)
714
+ hypId = ctx.hypothesis?.id
715
+ label = ref
716
+ }
717
+ if (!hypId) throw new Error('hypothesis not found')
718
+ const result = await api('PATCH', `/v1/hypotheses/${encodeURIComponent(hypId)}/status`, { status })
719
+ console.log(`${label} → ${result.status}`)
720
+ }
721
+
722
+ // ============================================================ feedback
723
+
724
+ async function feedbackRecord(rest) {
725
+ const opts = parseArgs(rest)
726
+ const ref = opts.hypothesis || opts._[0]
727
+ let hypId = opts['hypothesis-id'] || opts.hypothesis_id
728
+ if (!hypId) {
729
+ if (!ref) throw new Error('pass product/Hxx or --hypothesis-id')
730
+ const [productSlug, code] = splitHypothesisRef(ref)
731
+ const ctx = await api('GET', `/v1/context/${encodeURIComponent(productSlug)}/${encodeURIComponent(code)}`)
732
+ hypId = ctx.hypothesis?.id
733
+ }
734
+ if (!hypId) throw new Error('hypothesis not found')
735
+ const text = readValue(required(opts.text || opts.message || opts.body, '--text "<feedback>"'))
736
+ const body = { text }
737
+ if (opts['action-id'] || opts.action_id) body.action_id = opts['action-id'] || opts.action_id
738
+ const result = await api('POST', `/v1/hypotheses/${encodeURIComponent(hypId)}/feedback`, body)
739
+ console.log(result.id)
740
+ }
741
+
742
+ async function feedbackDelete(rest) {
743
+ const opts = parseArgs(rest)
744
+ const id = required(opts['feedback-id'] || opts.id || opts._[0], 'feedback id')
745
+ const result = await api('DELETE', `/v1/feedback/${encodeURIComponent(id)}`)
746
+ console.log(`${result.id} deleted`)
747
+ }
748
+
749
+ function feedbackUsage() {
750
+ console.log(`autark feedback record <product/Hxx> --text "<nudge>" [--action-id <id>]
751
+ autark feedback delete <feedback-id>`)
752
+ }
753
+
754
+ // ====================================================== deliverability
755
+
756
+ // Pre-send email validator. Calls AgentMail's recipient-check endpoint so the
757
+ // agent can avoid blasting a guessed/permutated address and inflating the
758
+ // bounce rate. Falls back to a syntactic check if the endpoint is unreachable.
759
+ async function deliverabilityCheck(rest) {
760
+ const opts = parseArgs(rest)
761
+ const email = required(opts.email || opts._[0], 'email')
762
+ const creds = loadCredentials() || {}
763
+ const token = creds.agentmail_token
764
+ let verdict = { email, deliverable: null, source: 'syntactic', reason: '' }
765
+
766
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
767
+ verdict = { ...verdict, deliverable: false, reason: 'malformed address' }
768
+ console.log(JSON.stringify(verdict, null, 2))
769
+ process.exitCode = 1
770
+ return
771
+ }
772
+ if (!token) {
773
+ verdict.reason = 'no agentmail token; syntactic check only'
774
+ console.log(JSON.stringify(verdict, null, 2))
775
+ return
776
+ }
777
+
778
+ try {
779
+ const res = await fetch('https://api.agentmail.to/v0/deliverability/check', {
780
+ method: 'POST',
781
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
782
+ body: JSON.stringify({ email }),
783
+ })
784
+ if (!res.ok) {
785
+ verdict.reason = `agentmail check ${res.status}; fell back to syntactic`
786
+ verdict.deliverable = null
787
+ } else {
788
+ const body = await res.json().catch(() => ({}))
789
+ verdict.source = 'agentmail'
790
+ verdict.deliverable = body.deliverable !== false
791
+ verdict.reason = body.reason || ''
792
+ if (body.risk) verdict.risk = body.risk
793
+ }
794
+ } catch (err) {
795
+ verdict.reason = `network error: ${err.message}`
796
+ }
797
+ console.log(JSON.stringify(verdict, null, 2))
798
+ if (verdict.deliverable === false) process.exitCode = 1
799
+ }
800
+
801
+ function deliverabilityUsage() {
802
+ console.log(`autark deliverability check <email>
803
+ Returns JSON {email, deliverable, source, reason}. Exits non-zero when the
804
+ address is known-undeliverable. Call this before logging an email action so
805
+ bounces don't tank your domain rep.`)
806
+ }
807
+
625
808
  // ============================================================ mail
626
809
 
627
810
  async function mail(command, rest) {
@@ -1103,9 +1286,14 @@ function usage() {
1103
1286
  me show signed-in user
1104
1287
 
1105
1288
  product upsert|list create/edit/list products
1289
+ product pause|unpause stop / resume cron on a product
1106
1290
  hypothesis create|status create or update hypotheses
1291
+ hypothesis pause|unpause stop / resume work on a hypothesis
1107
1292
  run start|finish start / finish a run
1108
1293
  log action record one outreach touch
1294
+ action escalate flag an action for human attention
1295
+ feedback record|delete leave a free-text nudge on a hypothesis
1296
+ deliverability check pre-send check for an email address
1109
1297
  context [<slug>|...] pull product or hypothesis context
1110
1298
 
1111
1299
  mail setup|send|reply|... send/read mail via your AgentMail inbox
@@ -1123,6 +1311,9 @@ function productUsage() {
1123
1311
 
1124
1312
  list prints slug, visibility, id, name
1125
1313
 
1314
+ pause <slug> cron will skip this product on start ticks
1315
+ unpause <slug> resume cron
1316
+
1126
1317
  The brief is the markdown the agent reads at the top of every run
1127
1318
  to understand what the product is and who it might serve.`)
1128
1319
  }
@@ -1136,6 +1327,9 @@ function hypothesisUsage() {
1136
1327
  status --hypothesis-id <id> --status active|inactive|dead
1137
1328
  status <slug>/<H01> --status active|inactive|dead # slug/code alias
1138
1329
 
1330
+ pause <slug>/<H01> flip to inactive (cron skips it)
1331
+ unpause <slug>/<H01> flip back to active
1332
+
1139
1333
  Hypotheses are frozen on create. Only --status changes after.
1140
1334
  If --code is omitted, autark picks the next H## for the product.`)
1141
1335
  }
@@ -1159,10 +1353,40 @@ function logUsage() {
1159
1353
  [--agentmail-thread-id <uuid>] # email
1160
1354
  [--agentmail-inbox-id <email>] # email — defaults to your inbox
1161
1355
  [--recipient <email>] # email
1162
- [--metadata @./meta.json] # any channel-specific extras
1356
+ [--our-comment-id <id>] # see "Always capture our_comment_id" below
1357
+ [--metadata @./meta.json] # any other channel-specific extras
1358
+ [--escalate --reason "<why>"] # flag this action as needs_human
1163
1359
 
1164
1360
  One row per external touch. Body content stays in AgentMail/GitHub/etc;
1165
- this just records the pointer + the title agents see on the dashboard.`)
1361
+ this just records the pointer + the title agents see on the dashboard.
1362
+
1363
+ Log the URL of YOUR comment, not the parent
1364
+
1365
+ The reply-state cron parses the comment id out of the --url you log to
1366
+ detect "did anyone reply to you?". Most channels' URLs already contain
1367
+ the id; you just have to log the permalink of your OWN comment, not the
1368
+ issue/post URL you replied to.
1369
+
1370
+ github (issue or PR comment):
1371
+ use the response's html_url, ends in #issuecomment-<id>
1372
+ NEW_URL=$(gh api repos/<o>/<r>/issues/<n>/comments \\
1373
+ -X POST -f body=@body.md --jq .html_url)
1374
+
1375
+ gist:
1376
+ ends in #gistcomment-<id>
1377
+
1378
+ hn:
1379
+ .../item?id=<your_new_item_id> (your id, not the parent's)
1380
+
1381
+ reddit:
1382
+ .../r/<sub>/comments/<thread>/<title>/<your_comment_id>/
1383
+
1384
+ substack:
1385
+ Substack permalinks don't always include the comment id, so for
1386
+ this channel use --our-comment-id <id> from the response body.
1387
+
1388
+ Escape hatch: --our-comment-id <id> tucks the id into metadata. Use it
1389
+ when the URL pattern can't encode the comment (Substack, edge HN cases).`)
1166
1390
  }
1167
1391
 
1168
1392
  function contextUsage() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autark-cli",
3
- "version": "0.1.8",
3
+ "version": "0.2.0",
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
+ }