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.
- package/.github/workflows/ci.yml +30 -0
- package/.github/workflows/release.yml +67 -0
- package/bun.lock +33 -0
- package/dist/index.js +321 -0
- package/dist/tinyclaw.exe +0 -0
- package/install.ps1 +119 -0
- package/package.json +25 -0
- package/skills/business.md +77 -0
- package/skills/default.md +77 -0
- package/src/ansi.ts +164 -0
- package/src/approval.ts +74 -0
- package/src/auth.ts +125 -0
- package/src/briefing.ts +52 -0
- package/src/claude.ts +267 -0
- package/src/cli.ts +137 -0
- package/src/clipboard.ts +27 -0
- package/src/config.ts +87 -0
- package/src/context-window.ts +190 -0
- package/src/context.ts +125 -0
- package/src/decisions.ts +122 -0
- package/src/email.ts +123 -0
- package/src/errors.ts +78 -0
- package/src/export.ts +82 -0
- package/src/finance.ts +148 -0
- package/src/git.ts +62 -0
- package/src/history.ts +100 -0
- package/src/images.ts +68 -0
- package/src/index.ts +1431 -0
- package/src/investigate.ts +415 -0
- package/src/markdown.ts +125 -0
- package/src/memos.ts +191 -0
- package/src/models.ts +94 -0
- package/src/monitor.ts +169 -0
- package/src/morning.ts +108 -0
- package/src/news.ts +329 -0
- package/src/openai-provider.ts +127 -0
- package/src/people.ts +472 -0
- package/src/personas.ts +99 -0
- package/src/platform.ts +84 -0
- package/src/plugins.ts +125 -0
- package/src/pomodoro.ts +169 -0
- package/src/providers.ts +70 -0
- package/src/retry.ts +108 -0
- package/src/session.ts +128 -0
- package/src/skills.ts +102 -0
- package/src/tasks.ts +418 -0
- package/src/tokens.ts +102 -0
- package/src/tool-safety.ts +100 -0
- package/src/tools.ts +1479 -0
- package/src/tui.ts +693 -0
- package/src/types.ts +55 -0
- package/src/undo.ts +83 -0
- package/src/windows.ts +299 -0
- package/src/workflows.ts +197 -0
- package/tests/ansi.test.ts +58 -0
- package/tests/approval.test.ts +43 -0
- package/tests/briefing.test.ts +10 -0
- package/tests/cli.test.ts +53 -0
- package/tests/context-window.test.ts +83 -0
- package/tests/images.test.ts +28 -0
- package/tests/memos.test.ts +116 -0
- package/tests/models.test.ts +34 -0
- package/tests/news.test.ts +13 -0
- package/tests/path-guard.test.ts +37 -0
- package/tests/people.test.ts +204 -0
- package/tests/skills.test.ts +35 -0
- package/tests/ssrf.test.ts +80 -0
- package/tests/tasks.test.ts +152 -0
- package/tests/tokens.test.ts +44 -0
- package/tests/tool-safety.test.ts +55 -0
- package/tests/windows-security.test.ts +59 -0
- package/tests/windows.test.ts +20 -0
- 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
|
+
}
|
package/src/workflows.ts
ADDED
|
@@ -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
|
+
})
|