autark-cli 0.4.0 → 0.4.2
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/autark.mjs +100 -2
- package/package.json +1 -1
package/autark.mjs
CHANGED
|
@@ -859,10 +859,84 @@ async function mail(command, rest) {
|
|
|
859
859
|
if (command === 'suppress') return mailSuppress(rest)
|
|
860
860
|
if (command === 'unsuppress') return mailUnsuppress(rest)
|
|
861
861
|
if (command === 'suppressed') return mailSuppressed(rest)
|
|
862
|
+
if (command === 'lint') return mailLint(rest)
|
|
862
863
|
mailUsage()
|
|
863
864
|
process.exit(1)
|
|
864
865
|
}
|
|
865
866
|
|
|
867
|
+
// Read a draft from stdin or a @file path and grade against the AI-tells
|
|
868
|
+
// rules defined in the outreach skill. Exits 0 on clean, 1 on violations.
|
|
869
|
+
// The lint rules are intentionally aggressive and slightly opinionated —
|
|
870
|
+
// the goal is to make agents abstain on slop, not to nitpick humans.
|
|
871
|
+
async function mailLint(rest) {
|
|
872
|
+
const opts = parseArgs(rest)
|
|
873
|
+
let body = ''
|
|
874
|
+
if (opts.body && String(opts.body).startsWith('@')) {
|
|
875
|
+
body = fs.readFileSync(String(opts.body).slice(1), 'utf8')
|
|
876
|
+
} else if (opts._?.[0] && String(opts._[0]).startsWith('@')) {
|
|
877
|
+
body = fs.readFileSync(String(opts._[0]).slice(1), 'utf8')
|
|
878
|
+
} else if (!process.stdin.isTTY) {
|
|
879
|
+
body = await new Promise((resolve) => {
|
|
880
|
+
const chunks = []
|
|
881
|
+
process.stdin.on('data', (c) => chunks.push(c))
|
|
882
|
+
process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
|
883
|
+
})
|
|
884
|
+
} else {
|
|
885
|
+
console.error('autark mail lint: pass a draft via --body @./draft.txt or pipe on stdin')
|
|
886
|
+
process.exit(2)
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
const STRUCTURE_WORDS = [
|
|
890
|
+
'structurally', 'structurally different', 'fundamentally', 'specifically',
|
|
891
|
+
'essentially', 'actually', 'notably',
|
|
892
|
+
'the key insight', 'what this means is', 'the answer is', 'the trick is',
|
|
893
|
+
'that\'s exactly', 'that\'s precisely', 'that\'s the whole point',
|
|
894
|
+
]
|
|
895
|
+
|
|
896
|
+
const violations = []
|
|
897
|
+
const lower = body.toLowerCase()
|
|
898
|
+
|
|
899
|
+
// 1. structure-words
|
|
900
|
+
for (const w of STRUCTURE_WORDS) {
|
|
901
|
+
if (lower.includes(w)) violations.push({ rule: 'structure-word', detail: w })
|
|
902
|
+
}
|
|
903
|
+
// 2. em-dash
|
|
904
|
+
if (/—/.test(body)) violations.push({ rule: 'em-dash', detail: 'use a period or comma instead' })
|
|
905
|
+
// 3. URLs in body (allow only inside markdown anchor [text](url) or HTML <a href>)
|
|
906
|
+
const urlMatches = body.match(/https?:\/\/[^\s<>"')]+/g) || []
|
|
907
|
+
for (const u of urlMatches) {
|
|
908
|
+
const idx = body.indexOf(u)
|
|
909
|
+
const before = body.slice(Math.max(0, idx - 40), idx)
|
|
910
|
+
const after = body.slice(idx + u.length, idx + u.length + 10)
|
|
911
|
+
const insideMdAnchor = /\]\(\s*$/.test(before)
|
|
912
|
+
const insideHtmlAnchor = /href\s*=\s*["'][^"']*$/.test(before) || /<a\b[^>]*$/.test(before)
|
|
913
|
+
if (!insideMdAnchor && !insideHtmlAnchor) violations.push({ rule: 'body-url', detail: u })
|
|
914
|
+
}
|
|
915
|
+
// 4. multiple question marks (more than one ? in the body)
|
|
916
|
+
const qmarks = (body.match(/\?/g) || []).length
|
|
917
|
+
if (qmarks > 1) violations.push({ rule: 'too-many-questions', detail: `${qmarks} question marks; cold outreach gets one max` })
|
|
918
|
+
// 5. compound question with "or" connecting clauses
|
|
919
|
+
const compoundQ = /\b(are|is|do|does|did|will|would|can|could|should|have|has)\b[^?]{0,200}\bor\b[^?]{0,200}\?/i
|
|
920
|
+
if (compoundQ.test(body)) violations.push({ rule: 'compound-question', detail: 'a/b question with "or" splits attention; ask one thing' })
|
|
921
|
+
// 6. body length (excluding signature heuristically)
|
|
922
|
+
const sigSplit = body.split(/\n\s*\n(?:best|thanks|cheers|—|-)[\s,]*\n/i)[0] || body
|
|
923
|
+
const len = sigSplit.trim().length
|
|
924
|
+
if (len > 400) violations.push({ rule: 'too-long', detail: `${len} chars of body; cold outreach should fit in ~400` })
|
|
925
|
+
// 7. signature presence: should have `- <name>` and an anchor (md or html)
|
|
926
|
+
const hasMdAnchor = /\[[^\]]+\]\(https?:\/\/[^)]+\)/.test(body)
|
|
927
|
+
const hasHtmlAnchor = /<a\b[^>]*href\s*=\s*["']https?:\/\/[^"']+["'][^>]*>[^<]+<\/a>/i.test(body)
|
|
928
|
+
if (!hasMdAnchor && !hasHtmlAnchor) {
|
|
929
|
+
violations.push({ rule: 'no-anchor-sig', detail: 'signature must include a clickable link via [name](url) or <a href="url">name</a>' })
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
if (violations.length === 0) {
|
|
933
|
+
console.log(JSON.stringify({ clean: true, length: len }, null, 2))
|
|
934
|
+
return
|
|
935
|
+
}
|
|
936
|
+
console.error(JSON.stringify({ clean: false, length: len, violations }, null, 2))
|
|
937
|
+
process.exit(1)
|
|
938
|
+
}
|
|
939
|
+
|
|
866
940
|
async function mailSuppress(rest) {
|
|
867
941
|
const opts = parseArgs(rest)
|
|
868
942
|
const entry = required(opts.email || opts._[0], 'email')
|
|
@@ -916,9 +990,10 @@ async function mailSend(rest) {
|
|
|
916
990
|
rejectUnsendableAddress(to)
|
|
917
991
|
const subject = opts.subject || ''
|
|
918
992
|
const body = mailBody(opts, { to, subject })
|
|
993
|
+
const anchor = readAnchorOpts(opts)
|
|
919
994
|
const dryRun = opts['dry-run'] === true || opts.dry_run === true
|
|
920
995
|
if (dryRun) {
|
|
921
|
-
printJson({ dry_run: true, would_send: { inbox: creds.inboxId, to, subject, body } })
|
|
996
|
+
printJson({ dry_run: true, would_send: { inbox: creds.inboxId, to, subject, body, anchor } })
|
|
922
997
|
return
|
|
923
998
|
}
|
|
924
999
|
const result = await agentmailRequest('POST', `/inboxes/${encodeURIComponent(creds.inboxId)}/messages/send`, body)
|
|
@@ -928,10 +1003,30 @@ async function mailSend(rest) {
|
|
|
928
1003
|
recipient: to.join(','),
|
|
929
1004
|
subject,
|
|
930
1005
|
response: result,
|
|
1006
|
+
metadata: anchor ? { anchor_quote: anchor.quote, anchor_url: anchor.url } : {},
|
|
931
1007
|
})
|
|
932
1008
|
printJson({ ...result, autark_action_id: action?.id })
|
|
933
1009
|
}
|
|
934
1010
|
|
|
1011
|
+
// Optional anchor-quote primitive. The cold-outreach skill recommends that
|
|
1012
|
+
// every send carry a verbatim quote from the recipient's public surface
|
|
1013
|
+
// (HN comment, GitHub issue, tweet) plus the URL it was lifted from. The
|
|
1014
|
+
// CLI doesn't (yet) enforce — when both are present they're validated and
|
|
1015
|
+
// logged into the action's metadata; when absent the send still goes
|
|
1016
|
+
// through. Flip to required once the prompt-side discipline catches up.
|
|
1017
|
+
function readAnchorOpts(opts) {
|
|
1018
|
+
let quote = opts['anchor-quote'] || opts.anchor_quote
|
|
1019
|
+
const url = opts['anchor-url'] || opts.anchor_url
|
|
1020
|
+
if (quote && String(quote).startsWith('@')) {
|
|
1021
|
+
quote = fs.readFileSync(String(quote).slice(1), 'utf8').trim()
|
|
1022
|
+
}
|
|
1023
|
+
if (!quote && !url) return null
|
|
1024
|
+
if (!quote || !url) throw new Error('--anchor-quote and --anchor-url must both be provided (or both omitted)')
|
|
1025
|
+
if (String(quote).length < 30) throw new Error(`--anchor-quote too short (${String(quote).length} chars); need >=30 chars of verbatim recipient text`)
|
|
1026
|
+
if (!/^https?:\/\//.test(String(url))) throw new Error(`--anchor-url must be a full URL, got: ${url}`)
|
|
1027
|
+
return { quote: String(quote).trim(), url: String(url).trim() }
|
|
1028
|
+
}
|
|
1029
|
+
|
|
935
1030
|
async function mailReply(rest, mode) {
|
|
936
1031
|
const opts = parseArgs(rest)
|
|
937
1032
|
const creds = requireAgentmailCredentials()
|
|
@@ -1330,6 +1425,7 @@ function mailUsage() {
|
|
|
1330
1425
|
|
|
1331
1426
|
setup --prefix <name> [--force]
|
|
1332
1427
|
send --to <email[,email]> --subject <s> --text @body.txt [--run-id <id>] [--dry-run]
|
|
1428
|
+
[--anchor-quote @./q.txt --anchor-url <url>] record the recipient's verbatim public quote
|
|
1333
1429
|
reply --message-id <id> --text @reply.txt [--run-id <id>]
|
|
1334
1430
|
reply-all --message-id <id> --text @reply.txt [--run-id <id>]
|
|
1335
1431
|
forward --message-id <id> --to <email> [--text @body.txt] [--run-id <id>]
|
|
@@ -1343,10 +1439,12 @@ function mailUsage() {
|
|
|
1343
1439
|
suppress <email> [--reason "<text>"] add to org-wide send-block list
|
|
1344
1440
|
unsuppress <email> remove from send-block list
|
|
1345
1441
|
suppressed [--limit N] list blocked addresses
|
|
1442
|
+
lint --body @draft.txt | <stdin> grade a draft for AI-tells; exit 1 on violations
|
|
1346
1443
|
|
|
1347
1444
|
Mail uses the inbox-scoped token in ~/.autark/credentials.json. Suppression
|
|
1348
1445
|
verbs proxy through the autark worker (org-scoped). --dry-run prints the
|
|
1349
|
-
payload without hitting SES
|
|
1446
|
+
payload without hitting SES. lint is fully local — runs the outreach skill's
|
|
1447
|
+
hard-fail checks against a draft.`)
|
|
1350
1448
|
}
|
|
1351
1449
|
|
|
1352
1450
|
function usage() {
|
package/package.json
CHANGED