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
package/src/types.ts ADDED
@@ -0,0 +1,55 @@
1
+ export interface MessageImage {
2
+ mediaType: 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp'
3
+ base64: string
4
+ }
5
+
6
+ export interface Message {
7
+ role: 'user' | 'assistant'
8
+ content: string
9
+ images?: MessageImage[]
10
+ toolCalls?: ToolCall[]
11
+ usage?: { inputTokens: number; outputTokens: number; costCents: number }
12
+ timestamp: number
13
+ }
14
+
15
+ export interface ToolCall {
16
+ id: string
17
+ name: string
18
+ input: Record<string, unknown>
19
+ result: string
20
+ }
21
+
22
+ export interface Session {
23
+ id: string
24
+ name: string
25
+ messages: Message[]
26
+ created: number
27
+ updated: number
28
+ }
29
+
30
+ export interface TinyClawConfig {
31
+ apiKey: string
32
+ authMode: 'auto' | 'api-key' | 'subscription'
33
+ model: string
34
+ maxTokens: number
35
+ maxHistory: number
36
+ systemPrompt: string
37
+ skillsDir: string
38
+ dataDir: string
39
+ toolApproval: ToolApprovalMode
40
+ language: string
41
+ maxSessionCost: number // max cost in cents per session. 0 = unlimited
42
+ }
43
+
44
+ export type ToolApprovalMode = 'auto' | 'confirm-writes' | 'confirm-all'
45
+
46
+ export type ChatEvent =
47
+ | { type: 'text'; text: string }
48
+ | { type: 'tool_call'; id: string; name: string; input: unknown }
49
+ | { type: 'tool_result'; id: string; name: string; result: string }
50
+ | { type: 'tool_blocked'; id: string; name: string; reason: string }
51
+ | { type: 'usage'; inputTokens: number; outputTokens: number }
52
+ | { type: 'retry'; attempt: number; waitMs: number; reason: string }
53
+ | { type: 'context_trimmed'; dropped: number }
54
+ | { type: 'done' }
55
+ | { type: 'error'; error: string }
package/src/undo.ts ADDED
@@ -0,0 +1,83 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
2
+ import { join, dirname, basename } from 'node:path'
3
+
4
+ const MAX_UNDO_ENTRIES = 50
5
+
6
+ interface UndoEntry {
7
+ path: string
8
+ content: string // original content before change
9
+ timestamp: number
10
+ existed: boolean // false if file was newly created
11
+ }
12
+
13
+ /**
14
+ * Tracks file changes for undo support.
15
+ * Stores original content before each write/edit in memory.
16
+ */
17
+ export class UndoStack {
18
+ private entries: UndoEntry[] = []
19
+
20
+ /**
21
+ * Save the current state of a file before modifying it.
22
+ * Call this BEFORE writing/editing.
23
+ */
24
+ saveState(filePath: string): void {
25
+ const existed = existsSync(filePath)
26
+ const content = existed ? readFileSync(filePath, 'utf-8') : ''
27
+
28
+ this.entries.push({
29
+ path: filePath,
30
+ content,
31
+ timestamp: Date.now(),
32
+ existed,
33
+ })
34
+
35
+ // Cap the stack
36
+ if (this.entries.length > MAX_UNDO_ENTRIES) {
37
+ this.entries = this.entries.slice(-MAX_UNDO_ENTRIES)
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Undo the last file change. Restores original content.
43
+ * Returns description of what was undone, or null if stack is empty.
44
+ */
45
+ undo(): string | null {
46
+ const entry = this.entries.pop()
47
+ if (!entry) return null
48
+
49
+ if (!entry.existed) {
50
+ // File was newly created — we could delete it, but safer to leave it
51
+ // and just report. Deleting is destructive.
52
+ return `Undo: ${basename(entry.path)} was a new file. Remove it manually if needed.`
53
+ }
54
+
55
+ writeFileSync(entry.path, entry.content)
56
+ const lines = entry.content.split('\n').length
57
+ return `Undo: restored ${basename(entry.path)} (${lines} lines, from ${formatAge(entry.timestamp)})`
58
+ }
59
+
60
+ /**
61
+ * Get the stack size.
62
+ */
63
+ get size(): number {
64
+ return this.entries.length
65
+ }
66
+
67
+ /**
68
+ * Peek at what would be undone.
69
+ */
70
+ peek(): string | null {
71
+ if (this.entries.length === 0) return null
72
+ const entry = this.entries[this.entries.length - 1]
73
+ return `${basename(entry.path)} (${formatAge(entry.timestamp)})`
74
+ }
75
+ }
76
+
77
+ function formatAge(timestamp: number): string {
78
+ const seconds = Math.floor((Date.now() - timestamp) / 1000)
79
+ if (seconds < 60) return `${seconds}s ago`
80
+ const minutes = Math.floor(seconds / 60)
81
+ if (minutes < 60) return `${minutes}m ago`
82
+ return `${Math.floor(minutes / 60)}h ago`
83
+ }
package/src/windows.ts ADDED
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Windows-specific utilities for the business assistant.
3
+ * All operations are non-destructive (read-only or open-only).
4
+ *
5
+ * getDateTimeInfo() is cross-platform (pure JS, no Windows APIs).
6
+ */
7
+
8
+ import { IS_WINDOWS } from './platform'
9
+
10
+ // ─── Security: PowerShell input sanitization ────────────────
11
+
12
+ /** Characters that can break out of a PowerShell double-quoted string or inject commands */
13
+ const PS_DANGEROUS = /[";`$\n\r|&<>{}()]/
14
+
15
+ /**
16
+ * Validate a string is safe for embedding in a PowerShell command.
17
+ * Rejects any string containing shell metacharacters rather than
18
+ * trying to escape them (defense-in-depth).
19
+ */
20
+ function validatePsInput(value: string, label: string): string | null {
21
+ if (!value || typeof value !== 'string') {
22
+ return `Error: ${label} is required.`
23
+ }
24
+ if (value.length > 500) {
25
+ return `Error: ${label} too long (max 500 chars).`
26
+ }
27
+ if (PS_DANGEROUS.test(value)) {
28
+ return `Error: ${label} contains invalid characters. Avoid: " ; \` $ | & < > { } ( ) and newlines.`
29
+ }
30
+ return null
31
+ }
32
+
33
+ // ─── Process helpers with timeout and deadlock prevention ───
34
+
35
+ const SPAWN_TIMEOUT_MS = 15_000
36
+
37
+ /**
38
+ * Spawn a PowerShell command with timeout and concurrent pipe drainage.
39
+ * Returns { stdout, stderr, exitCode }.
40
+ */
41
+ async function runPowerShell(cmd: string): Promise<{ stdout: string; stderr: string; exitCode: number }> {
42
+ const proc = Bun.spawn(
43
+ ['powershell', '-NoProfile', '-NonInteractive', '-Command', cmd],
44
+ { stdout: 'pipe', stderr: 'pipe' },
45
+ )
46
+
47
+ const timer = setTimeout(() => proc.kill(), SPAWN_TIMEOUT_MS)
48
+
49
+ // Drain both pipes concurrently to prevent deadlock
50
+ const [stdout, stderr] = await Promise.all([
51
+ new Response(proc.stdout).text(),
52
+ new Response(proc.stderr).text(),
53
+ ])
54
+ const exitCode = await proc.exited
55
+ clearTimeout(timer)
56
+
57
+ return { stdout, stderr, exitCode }
58
+ }
59
+
60
+ // ─── App Launcher ───────────────────────────────────────────
61
+
62
+ /** Known Windows applications with their executable paths/commands */
63
+ const KNOWN_APPS: Record<string, string> = {
64
+ // Microsoft Office (protocol URIs for MSIX/Store apps)
65
+ excel: 'excel',
66
+ word: 'winword',
67
+ powerpoint: 'powerpnt',
68
+ outlook: 'ms-outlook:',
69
+ onenote: 'onenote',
70
+ teams: 'msteams:',
71
+
72
+ // Browsers
73
+ edge: 'msedge',
74
+ chrome: 'chrome',
75
+ firefox: 'firefox',
76
+
77
+ // System tools
78
+ calculator: 'calc',
79
+ notepad: 'notepad',
80
+ terminal: 'wt',
81
+ explorer: 'explorer',
82
+ taskmanager: 'taskmgr',
83
+ settings: 'ms-settings:',
84
+ paint: 'mspaint',
85
+ snip: 'snippingtool',
86
+
87
+ // Dev tools
88
+ vscode: 'code',
89
+ cursor: 'cursor',
90
+ postman: 'Postman',
91
+ }
92
+
93
+ /**
94
+ * Open a Windows application by name. Non-destructive.
95
+ */
96
+ export async function openApp(name: string, args?: string): Promise<string> {
97
+ if (!IS_WINDOWS) return 'Error: this command is only available on Windows.'
98
+
99
+ const key = name.toLowerCase().replace(/\s+/g, '')
100
+ const exe = KNOWN_APPS[key]
101
+
102
+ if (!exe) {
103
+ const available = Object.keys(KNOWN_APPS).join(', ')
104
+ return `Unknown app: "${name}". Available: ${available}`
105
+ }
106
+
107
+ // Validate optional argument (file path to open in the app)
108
+ if (args) {
109
+ const err = validatePsInput(args, 'argument')
110
+ if (err) return err
111
+ }
112
+
113
+ const cmd = args
114
+ ? `Start-Process '${exe}' -ArgumentList '${args}'`
115
+ : `Start-Process '${exe}'`
116
+
117
+ try {
118
+ const { exitCode, stderr } = await runPowerShell(cmd)
119
+ if (exitCode !== 0 && stderr.trim()) {
120
+ return `Error opening ${name}: ${stderr.trim()}`
121
+ }
122
+ return `Opened: ${name}`
123
+ } catch (err) {
124
+ return `Error opening ${name}: ${err instanceof Error ? err.message : String(err)}`
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Open a URL in the default browser.
130
+ */
131
+ export async function openUrl(url: string): Promise<string> {
132
+ if (!IS_WINDOWS) return 'Error: this command is only available on Windows.'
133
+
134
+ // Validate URL scheme
135
+ if (!url.startsWith('http://') && !url.startsWith('https://')) {
136
+ return 'Error: URL must start with http:// or https://'
137
+ }
138
+
139
+ const err = validatePsInput(url, 'URL')
140
+ if (err) return err
141
+
142
+ try {
143
+ const { exitCode, stderr } = await runPowerShell(`Start-Process '${url}'`)
144
+ if (exitCode !== 0 && stderr.trim()) {
145
+ return `Error: ${stderr.trim()}`
146
+ }
147
+ return `Opened in browser: ${url}`
148
+ } catch (err) {
149
+ return `Error: ${err instanceof Error ? err.message : String(err)}`
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Open a file with its default application.
155
+ */
156
+ export async function openFile(filePath: string): Promise<string> {
157
+ if (!IS_WINDOWS) return 'Error: this command is only available on Windows.'
158
+
159
+ const err = validatePsInput(filePath, 'file path')
160
+ if (err) return err
161
+
162
+ try {
163
+ const { exitCode, stderr } = await runPowerShell(`Invoke-Item '${filePath}'`)
164
+ if (exitCode !== 0 && stderr.trim()) {
165
+ return `Error: ${stderr.trim()}`
166
+ }
167
+ return `Opened: ${filePath}`
168
+ } catch (err) {
169
+ return `Error: ${err instanceof Error ? err.message : String(err)}`
170
+ }
171
+ }
172
+
173
+ // ─── System Info ────────────────────────────────────────────
174
+
175
+ /**
176
+ * Get a summary of running processes (top by CPU/memory).
177
+ * Non-destructive — read-only.
178
+ */
179
+ export async function getRunningApps(): Promise<string> {
180
+ if (!IS_WINDOWS) return 'Error: this command is only available on Windows.'
181
+
182
+ try {
183
+ const cmd = `Get-Process | Where-Object {$_.MainWindowTitle -ne ''} | Sort-Object -Property WorkingSet64 -Descending | Select-Object -First 15 Name, @{N='Memory(MB)';E={[math]::Round($_.WorkingSet64/1MB,1)}}, MainWindowTitle | Format-Table -AutoSize | Out-String -Width 200`
184
+ const { stdout, stderr } = await runPowerShell(cmd)
185
+ if (stderr.trim()) {
186
+ return `Error: ${stderr.trim()}`
187
+ }
188
+ return stdout.trim() || 'No windowed applications running.'
189
+ } catch (err) {
190
+ return `Error: ${err instanceof Error ? err.message : String(err)}`
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Get system resource summary (CPU, RAM, disk).
196
+ */
197
+ export async function getSystemInfo(): Promise<string> {
198
+ if (!IS_WINDOWS) return 'Error: this command is only available on Windows.'
199
+
200
+ const commands = [
201
+ `$cpu = (Get-CimInstance Win32_Processor | Measure-Object -Property LoadPercentage -Average).Average; "CPU: $cpu%"`,
202
+ `$os = Get-CimInstance Win32_OperatingSystem; $total = [math]::Round($os.TotalVisibleMemorySize/1MB,1); $free = [math]::Round($os.FreePhysicalMemory/1MB,1); $used = $total - $free; "RAM: $used GB / $total GB (Free: $free GB)"`,
203
+ `Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3" | ForEach-Object { $free = [math]::Round($_.FreeSpace/1GB,1); $total = [math]::Round($_.Size/1GB,1); "$($_.DeviceID) $free GB free / $total GB" }`,
204
+ `$uptime = (Get-Date) - (Get-CimInstance Win32_OperatingSystem).LastBootUpTime; "Uptime: $($uptime.Days)d $($uptime.Hours)h $($uptime.Minutes)m"`,
205
+ `$b = Get-CimInstance Win32_Battery -ErrorAction SilentlyContinue; if ($b) { "Battery: $($b.EstimatedChargeRemaining)%" } else { "Battery: N/A (desktop)" }`,
206
+ ]
207
+
208
+ try {
209
+ const { stdout, stderr } = await runPowerShell(commands.join('; '))
210
+ if (!stdout.trim() && stderr.trim()) {
211
+ return `Error: ${stderr.trim()}`
212
+ }
213
+ return stdout.trim() || 'System info unavailable.'
214
+ } catch (err) {
215
+ return `Error: ${err instanceof Error ? err.message : String(err)}`
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Get today's date/time info. Cross-platform (pure JS).
221
+ */
222
+ export async function getDateTimeInfo(): Promise<string> {
223
+ const now = new Date()
224
+ const lines: string[] = []
225
+
226
+ const weekday = now.toLocaleDateString('pt-BR', { weekday: 'long' })
227
+ const date = now.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric' })
228
+ const time = now.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })
229
+
230
+ lines.push(`${weekday}, ${date} — ${time}`)
231
+
232
+ // ISO 8601 week number calculation
233
+ const target = new Date(now.valueOf())
234
+ target.setDate(target.getDate() + 3 - ((target.getDay() + 6) % 7))
235
+ const jan4 = new Date(target.getFullYear(), 0, 4)
236
+ const weekNum = 1 + Math.round(((target.getTime() - jan4.getTime()) / 86_400_000 - 3 + ((jan4.getDay() + 6) % 7)) / 7)
237
+ lines.push(`Semana ${weekNum} do ano`)
238
+
239
+ // Business hours check
240
+ const hour = now.getHours()
241
+ if (hour >= 8 && hour < 18) {
242
+ lines.push('Status: horario comercial')
243
+ } else if (hour >= 18 && hour < 22) {
244
+ lines.push('Status: pos-expediente')
245
+ } else {
246
+ lines.push('Status: fora do horario comercial')
247
+ }
248
+
249
+ return lines.join('\n')
250
+ }
251
+
252
+ // ─── Outlook Calendar (if available) ────────────────────────
253
+
254
+ /**
255
+ * Get today's Outlook calendar events (read-only).
256
+ * Falls back gracefully if Outlook is not installed.
257
+ * Uses olFolderCalendar = 9 from the Outlook COM object model.
258
+ */
259
+ export async function getOutlookEvents(): Promise<string> {
260
+ if (!IS_WINDOWS) return 'Outlook integration only available on Windows.'
261
+
262
+ const cmd = [
263
+ 'try {',
264
+ ' $outlook = New-Object -ComObject Outlook.Application -ErrorAction Stop',
265
+ ' $ns = $outlook.GetNamespace("MAPI")',
266
+ ' $cal = $ns.GetDefaultFolder(9)', // olFolderCalendar
267
+ ' $today = (Get-Date).Date',
268
+ ' $tomorrow = $today.AddDays(1)',
269
+ ' $items = $cal.Items',
270
+ ' $items.Sort("[Start]")',
271
+ ' $items.IncludeRecurrences = $true',
272
+ ' $filter = "[Start] >= \'$($today.ToString(\'g\'))\' AND [Start] < \'$($tomorrow.ToString(\'g\'))\'"',
273
+ ' $events = $items.Restrict($filter)',
274
+ ' $results = @()',
275
+ ' foreach ($e in $events) {',
276
+ ' $start = ([DateTime]$e.Start).ToString("HH:mm")',
277
+ ' $end = ([DateTime]$e.End).ToString("HH:mm")',
278
+ ' $results += "$start-$end $($e.Subject)"',
279
+ ' }',
280
+ ' if ($results.Count -eq 0) { "Nenhum evento hoje." }',
281
+ ' else { $results -join [char]10 }',
282
+ '} catch {',
283
+ ' "Outlook nao disponivel ou sem eventos."',
284
+ '}',
285
+ ].join('\n')
286
+
287
+ try {
288
+ const { stdout } = await runPowerShell(cmd)
289
+ return stdout.trim() || 'Outlook nao disponivel.'
290
+ } catch {
291
+ return 'Outlook nao disponivel.'
292
+ }
293
+ }
294
+
295
+ // ─── Exports ────────────────────────────────────────────────
296
+
297
+ export function getKnownApps(): readonly string[] {
298
+ return Object.keys(KNOWN_APPS)
299
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Workflow automation — named sequences of Windows actions.
3
+ * E.g. "iniciar-dia" opens Outlook + Teams + Excel.
4
+ */
5
+
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
7
+ import { join } from 'node:path'
8
+ import { openApp, getKnownApps } from './windows'
9
+ import { IS_WINDOWS } from './platform'
10
+
11
+ // ─── Types ──────────────────────────────────────────────────
12
+
13
+ export interface WorkflowStep {
14
+ action: 'open_app' | 'open_url' | 'run_command' | 'wait'
15
+ target: string // app name, URL, command, or seconds
16
+ }
17
+
18
+ export interface Workflow {
19
+ name: string
20
+ description: string
21
+ steps: WorkflowStep[]
22
+ }
23
+
24
+ // ─── Storage ────────────────────────────────────────────────
25
+
26
+ let _dataDir = ''
27
+ let _workflows: Workflow[] = []
28
+
29
+ const DATA_FILE = () => join(_dataDir, 'workflows.json')
30
+
31
+ function save(): void {
32
+ writeFileSync(DATA_FILE(), JSON.stringify(_workflows, null, 2))
33
+ }
34
+
35
+ function load(): void {
36
+ const file = DATA_FILE()
37
+ if (!existsSync(file)) {
38
+ // Seed with default workflows
39
+ _workflows = DEFAULT_WORKFLOWS
40
+ save()
41
+ return
42
+ }
43
+ try { _workflows = JSON.parse(readFileSync(file, 'utf-8')) }
44
+ catch { _workflows = DEFAULT_WORKFLOWS; save() }
45
+ }
46
+
47
+ // ─── Defaults ───────────────────────────────────────────────
48
+
49
+ const DEFAULT_WORKFLOWS: Workflow[] = [
50
+ {
51
+ name: 'iniciar-dia',
52
+ description: 'Abrir terminal e Postman',
53
+ steps: [
54
+ { action: 'open_app', target: 'terminal' },
55
+ { action: 'wait', target: '2' },
56
+ { action: 'open_app', target: 'postman' },
57
+ ],
58
+ },
59
+ {
60
+ name: 'dev',
61
+ description: 'Abrir ambiente de desenvolvimento: VSCode e Terminal',
62
+ steps: [
63
+ { action: 'open_app', target: 'vscode' },
64
+ { action: 'wait', target: '1' },
65
+ { action: 'open_app', target: 'terminal' },
66
+ ],
67
+ },
68
+ ]
69
+
70
+ // ─── Init ───────────────────────────────────────────────────
71
+
72
+ export function initWorkflows(dataDir: string): void {
73
+ _dataDir = dataDir
74
+ if (!existsSync(dataDir)) mkdirSync(dataDir, { recursive: true })
75
+ load()
76
+ }
77
+
78
+ // ─── Operations ─────────────────────────────────────────────
79
+
80
+ export function getWorkflow(name: string): Workflow | null {
81
+ return _workflows.find((w) => w.name.toLowerCase() === name.toLowerCase()) || null
82
+ }
83
+
84
+ export function listWorkflows(): Workflow[] {
85
+ return [..._workflows]
86
+ }
87
+
88
+ export function createWorkflow(name: string, description: string, steps: WorkflowStep[]): Workflow {
89
+ // Remove existing with same name
90
+ _workflows = _workflows.filter((w) => w.name.toLowerCase() !== name.toLowerCase())
91
+ const workflow: Workflow = { name: name.toLowerCase(), description, steps }
92
+ _workflows = [..._workflows, workflow]
93
+ save()
94
+ return workflow
95
+ }
96
+
97
+ export function deleteWorkflow(name: string): boolean {
98
+ const before = _workflows.length
99
+ _workflows = _workflows.filter((w) => w.name.toLowerCase() !== name.toLowerCase())
100
+ if (_workflows.length === before) return false
101
+ save()
102
+ return true
103
+ }
104
+
105
+ /**
106
+ * Execute a workflow step by step.
107
+ * Returns a log of what was done.
108
+ */
109
+ export async function runWorkflow(
110
+ name: string,
111
+ onStep?: (msg: string) => void,
112
+ ): Promise<string> {
113
+ const workflow = getWorkflow(name)
114
+ if (!workflow) {
115
+ const available = _workflows.map((w) => w.name).join(', ')
116
+ return `Workflow nao encontrado: "${name}". Disponiveis: ${available}`
117
+ }
118
+
119
+ if (!IS_WINDOWS) {
120
+ return 'Error: workflows are only available on Windows.'
121
+ }
122
+
123
+ const log: string[] = [`Executando workflow: "${workflow.name}" — ${workflow.description}`]
124
+
125
+ for (let i = 0; i < workflow.steps.length; i++) {
126
+ const step = workflow.steps[i]
127
+
128
+ switch (step.action) {
129
+ case 'open_app': {
130
+ onStep?.(`[${i + 1}/${workflow.steps.length}] Abrindo ${step.target}...`)
131
+ const result = await openApp(step.target)
132
+ log.push(` ${i + 1}. ${result}`)
133
+ break
134
+ }
135
+
136
+ case 'open_url': {
137
+ onStep?.(`[${i + 1}/${workflow.steps.length}] Abrindo ${step.target}...`)
138
+ // Reuse openApp with 'edge' or just use Start-Process
139
+ const proc = Bun.spawn(
140
+ ['powershell', '-NoProfile', '-NonInteractive', '-Command', `Start-Process '${step.target}'`],
141
+ { stdout: 'pipe', stderr: 'pipe' },
142
+ )
143
+ const timer = setTimeout(() => proc.kill(), 10_000)
144
+ await Promise.all([
145
+ new Response(proc.stdout).text(),
146
+ new Response(proc.stderr).text(),
147
+ ])
148
+ await proc.exited
149
+ clearTimeout(timer)
150
+ log.push(` ${i + 1}. Opened: ${step.target}`)
151
+ break
152
+ }
153
+
154
+ case 'run_command': {
155
+ onStep?.(`[${i + 1}/${workflow.steps.length}] Executando: ${step.target}...`)
156
+ const proc = Bun.spawn(
157
+ ['powershell', '-NoProfile', '-NonInteractive', '-Command', step.target],
158
+ { stdout: 'pipe', stderr: 'pipe' },
159
+ )
160
+ const timer = setTimeout(() => proc.kill(), 30_000)
161
+ const [stdout] = await Promise.all([
162
+ new Response(proc.stdout).text(),
163
+ new Response(proc.stderr).text(),
164
+ ])
165
+ await proc.exited
166
+ clearTimeout(timer)
167
+ const preview = stdout.trim().slice(0, 100)
168
+ log.push(` ${i + 1}. Command: ${step.target}${preview ? ' -> ' + preview : ''}`)
169
+ break
170
+ }
171
+
172
+ case 'wait': {
173
+ const secs = parseInt(step.target) || 1
174
+ onStep?.(`[${i + 1}/${workflow.steps.length}] Aguardando ${secs}s...`)
175
+ await new Promise((r) => setTimeout(r, secs * 1000))
176
+ log.push(` ${i + 1}. Wait: ${secs}s`)
177
+ break
178
+ }
179
+ }
180
+ }
181
+
182
+ log.push(`\nWorkflow "${workflow.name}" concluido.`)
183
+ return log.join('\n')
184
+ }
185
+
186
+ // ─── Formatting ─────────────────────────────────────────────
187
+
188
+ export function formatWorkflowList(): string {
189
+ if (_workflows.length === 0) return 'Nenhum workflow configurado.'
190
+
191
+ const lines = _workflows.map((w) => {
192
+ const steps = w.steps.map((s) => `${s.action}:${s.target}`).join(' -> ')
193
+ return ` ${w.name.padEnd(15)} ${w.description}\n${' '.repeat(17)}${steps}`
194
+ })
195
+
196
+ return `Workflows (${_workflows.length}):\n${lines.join('\n\n')}`
197
+ }
@@ -0,0 +1,58 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import { stripAnsi, visibleLength, charWidth, displayWidth } from '../src/ansi'
3
+
4
+ describe('stripAnsi', () => {
5
+ test('strips ANSI escape codes', () => {
6
+ expect(stripAnsi('\x1b[1mBold\x1b[0m')).toBe('Bold')
7
+ expect(stripAnsi('\x1b[38;5;75mColored\x1b[0m')).toBe('Colored')
8
+ })
9
+
10
+ test('passes plain text through', () => {
11
+ expect(stripAnsi('Hello world')).toBe('Hello world')
12
+ })
13
+ })
14
+
15
+ describe('charWidth', () => {
16
+ test('ASCII is width 1', () => {
17
+ expect(charWidth('a')).toBe(1)
18
+ expect(charWidth(' ')).toBe(1)
19
+ expect(charWidth('Z')).toBe(1)
20
+ })
21
+
22
+ test('CJK characters are width 2', () => {
23
+ expect(charWidth('\u4e2d')).toBe(2) // 中
24
+ expect(charWidth('\u65e5')).toBe(2) // 日
25
+ })
26
+
27
+ test('fullwidth characters are width 2', () => {
28
+ expect(charWidth('\uff01')).toBe(2) // !
29
+ })
30
+ })
31
+
32
+ describe('visibleLength', () => {
33
+ test('ASCII string', () => {
34
+ expect(visibleLength('Hello')).toBe(5)
35
+ })
36
+
37
+ test('ANSI codes do not count', () => {
38
+ expect(visibleLength('\x1b[1mBold\x1b[0m')).toBe(4)
39
+ })
40
+
41
+ test('CJK characters count as 2', () => {
42
+ expect(visibleLength('\u4e2d\u6587')).toBe(4) // 中文 = 4 columns
43
+ })
44
+ })
45
+
46
+ describe('displayWidth', () => {
47
+ test('counts width of first N chars', () => {
48
+ expect(displayWidth('Hello', 3)).toBe(3)
49
+ })
50
+
51
+ test('handles CJK', () => {
52
+ expect(displayWidth('\u4e2d\u6587abc', 2)).toBe(4) // 2 CJK chars = 4 cols
53
+ })
54
+
55
+ test('handles mixed', () => {
56
+ expect(displayWidth('a\u4e2db', 2)).toBe(3) // 'a' + '中' = 1 + 2 = 3
57
+ })
58
+ })