smolerclaw 0.1.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 (73) hide show
  1. package/.github/workflows/ci.yml +30 -0
  2. package/.github/workflows/release.yml +67 -0
  3. package/bun.lock +33 -0
  4. package/dist/index.js +321 -0
  5. package/dist/tinyclaw.exe +0 -0
  6. package/install.ps1 +119 -0
  7. package/package.json +25 -0
  8. package/skills/business.md +77 -0
  9. package/skills/default.md +77 -0
  10. package/src/ansi.ts +164 -0
  11. package/src/approval.ts +74 -0
  12. package/src/auth.ts +125 -0
  13. package/src/briefing.ts +52 -0
  14. package/src/claude.ts +267 -0
  15. package/src/cli.ts +137 -0
  16. package/src/clipboard.ts +27 -0
  17. package/src/config.ts +87 -0
  18. package/src/context-window.ts +190 -0
  19. package/src/context.ts +125 -0
  20. package/src/decisions.ts +122 -0
  21. package/src/email.ts +123 -0
  22. package/src/errors.ts +78 -0
  23. package/src/export.ts +82 -0
  24. package/src/finance.ts +148 -0
  25. package/src/git.ts +62 -0
  26. package/src/history.ts +100 -0
  27. package/src/images.ts +68 -0
  28. package/src/index.ts +1431 -0
  29. package/src/investigate.ts +415 -0
  30. package/src/markdown.ts +125 -0
  31. package/src/memos.ts +191 -0
  32. package/src/models.ts +94 -0
  33. package/src/monitor.ts +169 -0
  34. package/src/morning.ts +108 -0
  35. package/src/news.ts +329 -0
  36. package/src/openai-provider.ts +127 -0
  37. package/src/people.ts +472 -0
  38. package/src/personas.ts +99 -0
  39. package/src/platform.ts +84 -0
  40. package/src/plugins.ts +125 -0
  41. package/src/pomodoro.ts +169 -0
  42. package/src/providers.ts +70 -0
  43. package/src/retry.ts +108 -0
  44. package/src/session.ts +128 -0
  45. package/src/skills.ts +102 -0
  46. package/src/tasks.ts +418 -0
  47. package/src/tokens.ts +102 -0
  48. package/src/tool-safety.ts +100 -0
  49. package/src/tools.ts +1479 -0
  50. package/src/tui.ts +693 -0
  51. package/src/types.ts +55 -0
  52. package/src/undo.ts +83 -0
  53. package/src/windows.ts +299 -0
  54. package/src/workflows.ts +197 -0
  55. package/tests/ansi.test.ts +58 -0
  56. package/tests/approval.test.ts +43 -0
  57. package/tests/briefing.test.ts +10 -0
  58. package/tests/cli.test.ts +53 -0
  59. package/tests/context-window.test.ts +83 -0
  60. package/tests/images.test.ts +28 -0
  61. package/tests/memos.test.ts +116 -0
  62. package/tests/models.test.ts +34 -0
  63. package/tests/news.test.ts +13 -0
  64. package/tests/path-guard.test.ts +37 -0
  65. package/tests/people.test.ts +204 -0
  66. package/tests/skills.test.ts +35 -0
  67. package/tests/ssrf.test.ts +80 -0
  68. package/tests/tasks.test.ts +152 -0
  69. package/tests/tokens.test.ts +44 -0
  70. package/tests/tool-safety.test.ts +55 -0
  71. package/tests/windows-security.test.ts +59 -0
  72. package/tests/windows.test.ts +20 -0
  73. package/tsconfig.json +19 -0
@@ -0,0 +1,190 @@
1
+ import type { Message } from './types'
2
+
3
+ // Approximate token limits per model family
4
+ const MODEL_LIMITS: Record<string, number> = {
5
+ haiku: 200_000,
6
+ sonnet: 200_000,
7
+ opus: 200_000,
8
+ }
9
+ const DEFAULT_LIMIT = 200_000
10
+
11
+ // Reserve tokens for system prompt + tool definitions + response
12
+ const RESERVED_TOKENS = 20_000
13
+
14
+ /**
15
+ * Estimate token count for a string.
16
+ * Uses a fast heuristic: ~4 chars per token for English.
17
+ * More accurate than counting words, faster than a real tokenizer.
18
+ */
19
+ export function estimateTokens(text: string): number {
20
+ return Math.ceil(text.length / 3.5)
21
+ }
22
+
23
+ /**
24
+ * Estimate total tokens for a list of messages.
25
+ */
26
+ export function estimateMessageTokens(messages: Message[]): number {
27
+ let total = 0
28
+ for (const msg of messages) {
29
+ total += estimateTokens(msg.content)
30
+ if (msg.toolCalls) {
31
+ for (const tc of msg.toolCalls) {
32
+ total += estimateTokens(JSON.stringify(tc.input))
33
+ total += estimateTokens(tc.result)
34
+ }
35
+ }
36
+ total += 10 // overhead per message (role, metadata)
37
+ }
38
+ return total
39
+ }
40
+
41
+ /**
42
+ * Get the effective context limit for a model.
43
+ */
44
+ function getContextLimit(model: string): number {
45
+ const lower = model.toLowerCase()
46
+ for (const [key, limit] of Object.entries(MODEL_LIMITS)) {
47
+ if (lower.includes(key)) return limit
48
+ }
49
+ return DEFAULT_LIMIT
50
+ }
51
+
52
+ /**
53
+ * Trim messages to fit within context window.
54
+ * Strategy:
55
+ * 1. Keep the first message (often sets context)
56
+ * 2. Keep the last N messages that fit the budget
57
+ * 3. Insert a summary marker where messages were dropped
58
+ *
59
+ * Returns a new array — never mutates the input.
60
+ */
61
+ export function trimToContextWindow(
62
+ messages: Message[],
63
+ model: string,
64
+ systemPromptTokens: number,
65
+ ): Message[] {
66
+ const limit = getContextLimit(model) - RESERVED_TOKENS - systemPromptTokens
67
+
68
+ const totalTokens = estimateMessageTokens(messages)
69
+ if (totalTokens <= limit) return messages
70
+
71
+ // Strategy: keep recent messages, drop older ones from the middle
72
+ // Always keep the first user message if it exists (sets project context)
73
+ const result: Message[] = []
74
+ let budget = limit
75
+
76
+ // Scan from newest to oldest to keep recent context
77
+ const reversed = [...messages].reverse()
78
+ const kept: Message[] = []
79
+
80
+ for (const msg of reversed) {
81
+ const msgTokens = estimateTokens(msg.content) +
82
+ (msg.toolCalls?.reduce((sum, tc) =>
83
+ sum + estimateTokens(JSON.stringify(tc.input)) + estimateTokens(tc.result), 0) ?? 0) +
84
+ 10
85
+
86
+ if (budget - msgTokens < 0) break
87
+ budget -= msgTokens
88
+ kept.unshift(msg)
89
+ }
90
+
91
+ // If we dropped messages, add a system note
92
+ const dropped = messages.length - kept.length
93
+ if (dropped > 0) {
94
+ result.push({
95
+ role: 'user' as const,
96
+ content: `[Note: ${dropped} earlier messages were trimmed to fit context. The conversation continues below.]`,
97
+ timestamp: Date.now(),
98
+ })
99
+ }
100
+
101
+ result.push(...kept)
102
+ return result
103
+ }
104
+
105
+ /**
106
+ * Check if context needs summarization (at 70% capacity).
107
+ */
108
+ export function needsSummary(
109
+ messages: Message[],
110
+ model: string,
111
+ systemPromptTokens: number,
112
+ ): boolean {
113
+ const limit = getContextLimit(model) - RESERVED_TOKENS - systemPromptTokens
114
+ const total = estimateMessageTokens(messages)
115
+ return total > limit * 0.7
116
+ }
117
+
118
+ /**
119
+ * Build a summarization prompt from old messages.
120
+ * Returns the messages to summarize and how many to keep intact.
121
+ */
122
+ export function buildSummaryRequest(
123
+ messages: Message[],
124
+ model: string,
125
+ systemPromptTokens: number,
126
+ ): { toSummarize: Message[]; toKeep: Message[] } | null {
127
+ const limit = getContextLimit(model) - RESERVED_TOKENS - systemPromptTokens
128
+ const total = estimateMessageTokens(messages)
129
+ if (total <= limit * 0.7) return null
130
+
131
+ // Keep the last 30% of messages intact, summarize the rest
132
+ const keepCount = Math.max(4, Math.floor(messages.length * 0.3))
133
+ const toSummarize = messages.slice(0, messages.length - keepCount)
134
+ const toKeep = messages.slice(messages.length - keepCount)
135
+
136
+ if (toSummarize.length < 2) return null
137
+
138
+ return { toSummarize, toKeep }
139
+ }
140
+
141
+ /**
142
+ * Generate the prompt to send to Claude for summarization.
143
+ */
144
+ export function summarizationPrompt(messages: Message[]): string {
145
+ const transcript = messages.map((m) => {
146
+ let text = `[${m.role}]: ${m.content.slice(0, 500)}`
147
+ if (m.toolCalls?.length) {
148
+ text += `\n Tools used: ${m.toolCalls.map((tc) => tc.name).join(', ')}`
149
+ }
150
+ return text
151
+ }).join('\n\n')
152
+
153
+ return `Summarize this conversation concisely. Focus on:
154
+ 1. Key decisions made
155
+ 2. Files created or modified
156
+ 3. Important context the user shared
157
+ 4. Current state of the task
158
+
159
+ Be brief but preserve actionable information. Output ONLY the summary.
160
+
161
+ ---
162
+ ${transcript}`
163
+ }
164
+
165
+ /**
166
+ * Summarize tool call results to reduce token usage.
167
+ * Truncates long tool results but preserves the first/last lines.
168
+ */
169
+ export function compressToolResults(messages: Message[], maxResultLen: number = 2000): Message[] {
170
+ return messages.map((msg) => {
171
+ if (!msg.toolCalls?.length) return msg
172
+
173
+ const compressedCalls = msg.toolCalls.map((tc) => {
174
+ if (tc.result.length <= maxResultLen) return tc
175
+
176
+ const lines = tc.result.split('\n')
177
+ const headCount = Math.min(10, lines.length)
178
+ const tailCount = Math.min(5, Math.max(0, lines.length - headCount))
179
+ const omitted = lines.length - headCount - tailCount
180
+ const parts = [...lines.slice(0, headCount)]
181
+ if (omitted > 0) parts.push(`... (${omitted} lines omitted)`)
182
+ if (tailCount > 0) parts.push(...lines.slice(-tailCount))
183
+ const truncated = parts.join('\n')
184
+
185
+ return { ...tc, result: truncated }
186
+ })
187
+
188
+ return { ...msg, toolCalls: compressedCalls }
189
+ })
190
+ }
package/src/context.ts ADDED
@@ -0,0 +1,125 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { basename, join } from 'node:path'
3
+ import { getShellName, IS_WINDOWS } from './platform'
4
+
5
+ /**
6
+ * Gather context about the current working environment.
7
+ * Injected into the system prompt so Claude knows where it is.
8
+ */
9
+ export function gatherContext(): string {
10
+ const cwd = process.cwd()
11
+ const parts: string[] = []
12
+
13
+ parts.push(`Working directory: ${cwd}`)
14
+ parts.push(`Platform: ${process.platform} (${process.arch})`)
15
+ parts.push(`Shell: ${getShellName()}`)
16
+ parts.push(`Runtime: Bun ${Bun.version}`)
17
+ parts.push(`Date: ${new Date().toISOString().split('T')[0]}`)
18
+
19
+ if (IS_WINDOWS) {
20
+ parts.push('Note: Use PowerShell syntax for commands (e.g., Get-ChildItem instead of ls, Get-Content instead of cat).')
21
+ }
22
+
23
+ const project = detectProject(cwd)
24
+ if (project) parts.push(`Project: ${project}`)
25
+
26
+ const git = detectGit(cwd)
27
+ if (git) parts.push(git)
28
+
29
+ return parts.join('\n')
30
+ }
31
+
32
+ function detectProject(cwd: string): string | null {
33
+ const indicators: [string, string][] = [
34
+ ['package.json', 'Node.js/JavaScript'],
35
+ ['Cargo.toml', 'Rust'],
36
+ ['go.mod', 'Go'],
37
+ ['pyproject.toml', 'Python'],
38
+ ['requirements.txt', 'Python'],
39
+ ['pom.xml', 'Java (Maven)'],
40
+ ['build.gradle', 'Java (Gradle)'],
41
+ ['Gemfile', 'Ruby'],
42
+ ['composer.json', 'PHP'],
43
+ ['Makefile', 'Make'],
44
+ ['CMakeLists.txt', 'C/C++ (CMake)'],
45
+ ['Dockerfile', 'Docker'],
46
+ ]
47
+
48
+ const detected: string[] = []
49
+ for (const [file, label] of indicators) {
50
+ if (existsSync(join(cwd, file))) {
51
+ detected.push(label)
52
+ }
53
+ }
54
+
55
+ if (detected.length === 0) return null
56
+
57
+ let name = basename(cwd)
58
+ try {
59
+ const pkg = join(cwd, 'package.json')
60
+ if (existsSync(pkg)) {
61
+ const data = JSON.parse(readFileSync(pkg, 'utf-8'))
62
+ if (data.name) name = data.name
63
+ }
64
+ } catch { /* ignore */ }
65
+
66
+ return `Project: ${name} (${detected.join(', ')})`
67
+ }
68
+
69
+ /**
70
+ * Gather rich git context: branch, last commit, changed files summary.
71
+ */
72
+ function detectGit(cwd: string): string | null {
73
+ if (!existsSync(join(cwd, '.git'))) return null
74
+
75
+ const lines: string[] = []
76
+
77
+ // Branch
78
+ try {
79
+ const head = readFileSync(join(cwd, '.git', 'HEAD'), 'utf-8').trim()
80
+ const branch = head.startsWith('ref: refs/heads/')
81
+ ? head.slice('ref: refs/heads/'.length)
82
+ : head.slice(0, 8)
83
+ lines.push(`Git branch: ${branch}`)
84
+ } catch {
85
+ lines.push('Git: initialized')
86
+ return lines.join('\n')
87
+ }
88
+
89
+ // Last commit (read from git log via COMMIT_EDITMSG or packed-refs is unreliable, use spawn)
90
+ try {
91
+ const proc = Bun.spawnSync(['git', 'log', '--oneline', '-1'], { cwd, stdout: 'pipe', stderr: 'pipe' })
92
+ if (proc.exitCode === 0) {
93
+ const lastCommit = new TextDecoder().decode(proc.stdout).trim()
94
+ if (lastCommit) lines.push(`Last commit: ${lastCommit}`)
95
+ }
96
+ } catch { /* ignore */ }
97
+
98
+ // Changed files summary (git diff --stat, limited)
99
+ try {
100
+ const proc = Bun.spawnSync(['git', 'diff', '--stat', '--stat-width=60'], { cwd, stdout: 'pipe', stderr: 'pipe' })
101
+ if (proc.exitCode === 0) {
102
+ const diff = new TextDecoder().decode(proc.stdout).trim()
103
+ if (diff) {
104
+ const diffLines = diff.split('\n')
105
+ const shown = diffLines.slice(0, 15)
106
+ if (diffLines.length > 15) shown.push(`... and ${diffLines.length - 15} more files`)
107
+ lines.push('Uncommitted changes:\n' + shown.join('\n'))
108
+ }
109
+ }
110
+ } catch { /* ignore */ }
111
+
112
+ // Staged files
113
+ try {
114
+ const proc = Bun.spawnSync(['git', 'diff', '--cached', '--stat', '--stat-width=60'], { cwd, stdout: 'pipe', stderr: 'pipe' })
115
+ if (proc.exitCode === 0) {
116
+ const staged = new TextDecoder().decode(proc.stdout).trim()
117
+ if (staged) {
118
+ const stagedLines = staged.split('\n').slice(0, 10)
119
+ lines.push('Staged:\n' + stagedLines.join('\n'))
120
+ }
121
+ }
122
+ } catch { /* ignore */ }
123
+
124
+ return lines.length > 0 ? lines.join('\n') : null
125
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Decision log — record important decisions with context and rationale.
3
+ */
4
+
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
6
+ import { join } from 'node:path'
7
+
8
+ // ─── Types ──────────────────────────────────────────────────
9
+
10
+ export interface Decision {
11
+ id: string
12
+ title: string
13
+ context: string // why this decision was needed
14
+ chosen: string // what was decided
15
+ alternatives?: string // what was considered but rejected
16
+ tags: string[]
17
+ date: string
18
+ }
19
+
20
+ // ─── Storage ────────────────────────────────────────────────
21
+
22
+ let _dataDir = ''
23
+ let _decisions: Decision[] = []
24
+
25
+ const DATA_FILE = () => join(_dataDir, 'decisions.json')
26
+
27
+ function save(): void {
28
+ writeFileSync(DATA_FILE(), JSON.stringify(_decisions, null, 2))
29
+ }
30
+
31
+ function load(): void {
32
+ const file = DATA_FILE()
33
+ if (!existsSync(file)) { _decisions = []; return }
34
+ try { _decisions = JSON.parse(readFileSync(file, 'utf-8')) }
35
+ catch { _decisions = [] }
36
+ }
37
+
38
+ // ─── Init ───────────────────────────────────────────────────
39
+
40
+ export function initDecisions(dataDir: string): void {
41
+ _dataDir = dataDir
42
+ if (!existsSync(dataDir)) mkdirSync(dataDir, { recursive: true })
43
+ load()
44
+ }
45
+
46
+ // ─── Operations ─────────────────────────────────────────────
47
+
48
+ export function logDecision(
49
+ title: string,
50
+ context: string,
51
+ chosen: string,
52
+ alternatives?: string,
53
+ tags: string[] = [],
54
+ ): Decision {
55
+ const decision: Decision = {
56
+ id: genId(),
57
+ title: title.trim(),
58
+ context: context.trim(),
59
+ chosen: chosen.trim(),
60
+ alternatives: alternatives?.trim(),
61
+ tags: tags.map((t) => t.toLowerCase()),
62
+ date: new Date().toISOString(),
63
+ }
64
+ _decisions = [..._decisions, decision]
65
+ save()
66
+ return decision
67
+ }
68
+
69
+ export function searchDecisions(query: string): Decision[] {
70
+ const lower = query.toLowerCase()
71
+ return _decisions.filter((d) =>
72
+ d.title.toLowerCase().includes(lower) ||
73
+ d.chosen.toLowerCase().includes(lower) ||
74
+ d.context.toLowerCase().includes(lower) ||
75
+ d.tags.some((t) => t.includes(lower)),
76
+ ).sort((a, b) =>
77
+ new Date(b.date).getTime() - new Date(a.date).getTime(),
78
+ )
79
+ }
80
+
81
+ export function listDecisions(limit = 15): Decision[] {
82
+ return [..._decisions]
83
+ .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
84
+ .slice(0, limit)
85
+ }
86
+
87
+ // ─── Formatting ─────────────────────────────────────────────
88
+
89
+ export function formatDecisionList(decisions: Decision[]): string {
90
+ if (decisions.length === 0) return 'Nenhuma decisao registrada.'
91
+
92
+ const lines = decisions.map((d) => {
93
+ const date = new Date(d.date).toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' })
94
+ const tags = d.tags.length > 0 ? ` [${d.tags.join(', ')}]` : ''
95
+ return ` [${date}] ${d.title}${tags} {${d.id}}`
96
+ })
97
+
98
+ return `Decisoes (${decisions.length}):\n${lines.join('\n')}`
99
+ }
100
+
101
+ export function formatDecisionDetail(d: Decision): string {
102
+ const date = new Date(d.date).toLocaleDateString('pt-BR')
103
+ const lines = [
104
+ `--- Decisao {${d.id}} ---`,
105
+ `Titulo: ${d.title}`,
106
+ `Data: ${date}`,
107
+ `\nContexto: ${d.context}`,
108
+ `\nEscolha: ${d.chosen}`,
109
+ ]
110
+ if (d.alternatives) lines.push(`\nAlternativas descartadas: ${d.alternatives}`)
111
+ if (d.tags.length > 0) lines.push(`\nTags: ${d.tags.join(', ')}`)
112
+ return lines.join('\n')
113
+ }
114
+
115
+ // ─── Helpers ────────────────────────────────────────────────
116
+
117
+ function genId(): string {
118
+ const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'
119
+ let id = ''
120
+ for (let i = 0; i < 6; i++) id += chars[Math.floor(Math.random() * chars.length)]
121
+ return id
122
+ }
package/src/email.ts ADDED
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Email draft system — generate drafts and open in Outlook.
3
+ * Uses mailto: URI for cross-platform or Outlook COM on Windows.
4
+ */
5
+
6
+ import { IS_WINDOWS } from './platform'
7
+
8
+ export interface EmailDraft {
9
+ to: string
10
+ subject: string
11
+ body: string
12
+ cc?: string
13
+ }
14
+
15
+ /**
16
+ * Open an email draft in the default mail client.
17
+ * On Windows, tries Outlook COM first, then falls back to mailto:.
18
+ */
19
+ export async function openEmailDraft(draft: EmailDraft): Promise<string> {
20
+ if (IS_WINDOWS) {
21
+ return openInOutlook(draft)
22
+ }
23
+ return openMailto(draft)
24
+ }
25
+
26
+ /**
27
+ * Open draft via Outlook COM (Windows only).
28
+ * Creates a new mail item with fields pre-filled.
29
+ */
30
+ async function openInOutlook(draft: EmailDraft): Promise<string> {
31
+ // Escape single quotes for PowerShell
32
+ const to = draft.to.replace(/'/g, "''")
33
+ const subject = draft.subject.replace(/'/g, "''")
34
+ const body = draft.body.replace(/'/g, "''").replace(/\n/g, '`n')
35
+ const cc = draft.cc?.replace(/'/g, "''") || ''
36
+
37
+ const cmd = [
38
+ 'try {',
39
+ ' $outlook = New-Object -ComObject Outlook.Application -ErrorAction Stop',
40
+ ' $mail = $outlook.CreateItem(0)', // olMailItem = 0
41
+ ` $mail.To = '${to}'`,
42
+ ` $mail.Subject = '${subject}'`,
43
+ ` $mail.Body = '${body}'`,
44
+ cc ? ` $mail.CC = '${cc}'` : '',
45
+ ' $mail.Display()',
46
+ ' "Email aberto no Outlook."',
47
+ '} catch {',
48
+ ' "Outlook nao disponivel. Usando mailto..."',
49
+ '}',
50
+ ].filter(Boolean).join('\n')
51
+
52
+ try {
53
+ const proc = Bun.spawn(
54
+ ['powershell', '-NoProfile', '-NonInteractive', '-Command', cmd],
55
+ { stdout: 'pipe', stderr: 'pipe' },
56
+ )
57
+ const timer = setTimeout(() => proc.kill(), 15_000)
58
+ const [stdout] = await Promise.all([
59
+ new Response(proc.stdout).text(),
60
+ new Response(proc.stderr).text(),
61
+ ])
62
+ await proc.exited
63
+ clearTimeout(timer)
64
+
65
+ const result = stdout.trim()
66
+
67
+ // If Outlook failed, fall back to mailto
68
+ if (result.includes('mailto')) {
69
+ return openMailto(draft)
70
+ }
71
+
72
+ return result || 'Email aberto no Outlook.'
73
+ } catch {
74
+ return openMailto(draft)
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Open draft via mailto: URI (cross-platform fallback).
80
+ */
81
+ async function openMailto(draft: EmailDraft): Promise<string> {
82
+ const params: string[] = []
83
+ if (draft.subject) params.push(`subject=${encodeURIComponent(draft.subject)}`)
84
+ if (draft.body) params.push(`body=${encodeURIComponent(draft.body)}`)
85
+ if (draft.cc) params.push(`cc=${encodeURIComponent(draft.cc)}`)
86
+
87
+ const mailto = `mailto:${encodeURIComponent(draft.to)}${params.length ? '?' + params.join('&') : ''}`
88
+
89
+ try {
90
+ const openCmd = IS_WINDOWS
91
+ ? ['powershell', '-NoProfile', '-NonInteractive', '-Command', `Start-Process '${mailto}'`]
92
+ : ['xdg-open', mailto]
93
+
94
+ const proc = Bun.spawn(openCmd, { stdout: 'pipe', stderr: 'pipe' })
95
+ const timer = setTimeout(() => proc.kill(), 10_000)
96
+ await Promise.all([
97
+ new Response(proc.stdout).text(),
98
+ new Response(proc.stderr).text(),
99
+ ])
100
+ await proc.exited
101
+ clearTimeout(timer)
102
+
103
+ return 'Email aberto no cliente de email padrao.'
104
+ } catch (err) {
105
+ return `Error: ${err instanceof Error ? err.message : String(err)}`
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Format a draft for preview in the TUI.
111
+ */
112
+ export function formatDraftPreview(draft: EmailDraft): string {
113
+ const lines = [
114
+ '--- Rascunho de Email ---',
115
+ `Para: ${draft.to}`,
116
+ ]
117
+ if (draft.cc) lines.push(`CC: ${draft.cc}`)
118
+ lines.push(`Assunto: ${draft.subject}`)
119
+ lines.push('')
120
+ lines.push(draft.body)
121
+ lines.push('------------------------')
122
+ return lines.join('\n')
123
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Translate Anthropic API errors to actionable user messages.
3
+ */
4
+ export function humanizeError(err: unknown): string {
5
+ if (!(err instanceof Error)) return String(err)
6
+
7
+ const status = (err as { status?: number }).status
8
+ const msg = err.message
9
+
10
+ // HTTP status-based errors
11
+ if (status) {
12
+ switch (status) {
13
+ case 400:
14
+ if (msg.includes('context_length') || msg.includes('too many tokens')) {
15
+ return 'Message too long for the model\'s context window. Try /clear to start fresh or use a shorter prompt.'
16
+ }
17
+ return `Bad request: ${extractDetail(msg)}`
18
+
19
+ case 401:
20
+ return 'Authentication failed. Your API key or subscription token may be expired.\n' +
21
+ 'Try: Set ANTHROPIC_API_KEY env var, or run `claude` to refresh subscription credentials.'
22
+
23
+ case 403:
24
+ return 'Access denied. Your API key may not have permission for this model.\n' +
25
+ 'Try: /model haiku (uses a more accessible model).'
26
+
27
+ case 404:
28
+ return `Model not found. The model "${extractModel(msg)}" may not exist or be unavailable.\n` +
29
+ 'Try: /model to see available models.'
30
+
31
+ case 429:
32
+ return 'Rate limited. Too many requests in a short period.\n' +
33
+ 'The request will be retried automatically. If this persists, wait a minute.'
34
+
35
+ case 500:
36
+ case 502:
37
+ case 503:
38
+ return 'Anthropic API is temporarily unavailable. Retrying automatically...'
39
+
40
+ case 529:
41
+ return 'Anthropic API is overloaded. Retrying with backoff...'
42
+ }
43
+ }
44
+
45
+ // Network errors
46
+ const lower = msg.toLowerCase()
47
+ if (lower.includes('econnrefused') || lower.includes('enotfound')) {
48
+ return 'Cannot connect to Anthropic API. Check your internet connection.'
49
+ }
50
+ if (lower.includes('etimedout') || lower.includes('socket hang up')) {
51
+ return 'Connection to Anthropic API timed out. Retrying...'
52
+ }
53
+ if (lower.includes('econnreset')) {
54
+ return 'Connection was reset. This usually recovers automatically.'
55
+ }
56
+
57
+ // Subscription-specific
58
+ if (lower.includes('expired') || lower.includes('invalid_api_key')) {
59
+ return 'Your authentication has expired. Run `claude` to refresh, or set a new ANTHROPIC_API_KEY.'
60
+ }
61
+
62
+ // Default: return original with prefix
63
+ return msg
64
+ }
65
+
66
+ function extractDetail(msg: string): string {
67
+ // Try to extract the "detail" or "message" field from API error JSON
68
+ try {
69
+ const match = msg.match(/"message"\s*:\s*"([^"]+)"/)
70
+ if (match) return match[1]
71
+ } catch { /* ignore */ }
72
+ return msg.length > 200 ? msg.slice(0, 200) + '...' : msg
73
+ }
74
+
75
+ function extractModel(msg: string): string {
76
+ const match = msg.match(/model[:\s]+"?([a-z0-9-]+)"?/i)
77
+ return match ? match[1] : 'unknown'
78
+ }