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/briefing.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daily briefing — morning summary combining calendar, system, and news.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { getDateTimeInfo, getOutlookEvents, getSystemInfo } from './windows'
|
|
6
|
+
import { fetchNews } from './news'
|
|
7
|
+
import { IS_WINDOWS } from './platform'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Generate a daily briefing with date, calendar, system, and top news.
|
|
11
|
+
*/
|
|
12
|
+
export async function generateBriefing(): Promise<string> {
|
|
13
|
+
const sections: string[] = []
|
|
14
|
+
|
|
15
|
+
// Header
|
|
16
|
+
sections.push('=== BRIEFING DIARIO ===')
|
|
17
|
+
|
|
18
|
+
// Date & time
|
|
19
|
+
const dateInfo = await getDateTimeInfo()
|
|
20
|
+
sections.push(dateInfo)
|
|
21
|
+
|
|
22
|
+
// Calendar (Windows only, non-blocking)
|
|
23
|
+
if (IS_WINDOWS) {
|
|
24
|
+
try {
|
|
25
|
+
const events = await getOutlookEvents()
|
|
26
|
+
sections.push(`\n--- Agenda ---\n${events}`)
|
|
27
|
+
} catch {
|
|
28
|
+
sections.push('\n--- Agenda ---\nOutlook nao disponivel.')
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// System status
|
|
33
|
+
if (IS_WINDOWS) {
|
|
34
|
+
try {
|
|
35
|
+
const sys = await getSystemInfo()
|
|
36
|
+
sections.push(`\n--- Sistema ---\n${sys}`)
|
|
37
|
+
} catch {
|
|
38
|
+
// Skip system info on error
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Top news (limited to 3 per source for briefing)
|
|
43
|
+
try {
|
|
44
|
+
const news = await fetchNews(['finance', 'business', 'tech'], 3)
|
|
45
|
+
sections.push(`\n${news}`)
|
|
46
|
+
} catch {
|
|
47
|
+
sections.push('\n--- Noticias ---\nFalha ao carregar noticias.')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
sections.push('\n======================')
|
|
51
|
+
return sections.join('\n')
|
|
52
|
+
}
|
package/src/claude.ts
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import Anthropic from '@anthropic-ai/sdk'
|
|
2
|
+
import type { Message, ChatEvent, ToolApprovalMode } from './types'
|
|
3
|
+
import { TOOLS, executeTool } from './tools'
|
|
4
|
+
import { withRetry } from './retry'
|
|
5
|
+
import { trimToContextWindow, compressToolResults, estimateTokens, needsSummary, buildSummaryRequest, summarizationPrompt } from './context-window'
|
|
6
|
+
import { assessToolRisk } from './tool-safety'
|
|
7
|
+
import { humanizeError } from './errors'
|
|
8
|
+
import { needsApproval, type ApprovalCallback } from './approval'
|
|
9
|
+
|
|
10
|
+
export class ClaudeProvider {
|
|
11
|
+
private client: Anthropic
|
|
12
|
+
private approvalMode: ToolApprovalMode
|
|
13
|
+
private approvalCallback: ApprovalCallback | null = null
|
|
14
|
+
private autoApproveAll = false
|
|
15
|
+
private onAuthExpired: (() => boolean) | null = null
|
|
16
|
+
|
|
17
|
+
constructor(
|
|
18
|
+
apiKey: string,
|
|
19
|
+
private model: string,
|
|
20
|
+
private maxTokens: number,
|
|
21
|
+
approvalMode: ToolApprovalMode = 'auto',
|
|
22
|
+
) {
|
|
23
|
+
this.client = new Anthropic({ apiKey })
|
|
24
|
+
this.approvalMode = approvalMode
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Replace the API key and recreate the client (used after auth refresh) */
|
|
28
|
+
updateApiKey(newKey: string): void {
|
|
29
|
+
this.client = new Anthropic({ apiKey: newKey })
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Register a callback that fires on 401 to attempt credential refresh */
|
|
33
|
+
setAuthRefresh(cb: () => boolean): void {
|
|
34
|
+
this.onAuthExpired = cb
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
setModel(model: string): void {
|
|
38
|
+
this.model = model
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
setApprovalMode(mode: ToolApprovalMode): void {
|
|
42
|
+
this.approvalMode = mode
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
setApprovalCallback(cb: ApprovalCallback): void {
|
|
46
|
+
this.approvalCallback = cb
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
setAutoApproveAll(value: boolean): void {
|
|
50
|
+
this.autoApproveAll = value
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async *chat(
|
|
54
|
+
messages: Message[],
|
|
55
|
+
systemPrompt: string,
|
|
56
|
+
enableTools = true,
|
|
57
|
+
): AsyncGenerator<ChatEvent> {
|
|
58
|
+
let processed = compressToolResults(messages)
|
|
59
|
+
const systemTokens = estimateTokens(systemPrompt)
|
|
60
|
+
|
|
61
|
+
// Auto-summary when context is getting large
|
|
62
|
+
if (needsSummary(processed, this.model, systemTokens)) {
|
|
63
|
+
const req = buildSummaryRequest(processed, this.model, systemTokens)
|
|
64
|
+
if (req) {
|
|
65
|
+
try {
|
|
66
|
+
const summaryText = await this.generateSummary(req.toSummarize)
|
|
67
|
+
const summaryMsg: Message = {
|
|
68
|
+
role: 'assistant',
|
|
69
|
+
content: `[Conversation summary]\n${summaryText}`,
|
|
70
|
+
timestamp: Date.now(),
|
|
71
|
+
}
|
|
72
|
+
processed = [
|
|
73
|
+
{ role: 'user', content: 'Continue from this summary of our earlier conversation.', timestamp: Date.now() },
|
|
74
|
+
summaryMsg,
|
|
75
|
+
...req.toKeep,
|
|
76
|
+
]
|
|
77
|
+
} catch {
|
|
78
|
+
// Fallback to simple trim if summary fails
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const trimmed = trimToContextWindow(processed, this.model, systemTokens)
|
|
84
|
+
const apiMessages = toApiMessages(trimmed)
|
|
85
|
+
const tools = enableTools ? TOOLS : undefined
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
yield* this.streamLoop(apiMessages, systemPrompt, tools)
|
|
89
|
+
} catch (err) {
|
|
90
|
+
yield { type: 'error', error: humanizeError(err) }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private async generateSummary(messages: Message[]): Promise<string> {
|
|
95
|
+
const prompt = summarizationPrompt(messages)
|
|
96
|
+
const resp = await this.client.messages.create({
|
|
97
|
+
model: this.model,
|
|
98
|
+
max_tokens: 1024,
|
|
99
|
+
messages: [{ role: 'user', content: prompt }],
|
|
100
|
+
})
|
|
101
|
+
const textBlock = resp.content.find((b) => b.type === 'text')
|
|
102
|
+
return textBlock?.type === 'text' ? textBlock.text : 'Summary unavailable.'
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private async *streamLoop(
|
|
106
|
+
messages: Anthropic.MessageParam[],
|
|
107
|
+
system: string,
|
|
108
|
+
tools?: Anthropic.Tool[],
|
|
109
|
+
): AsyncGenerator<ChatEvent> {
|
|
110
|
+
const MAX_TOOL_ROUNDS = 25
|
|
111
|
+
const convo = [...messages]
|
|
112
|
+
let round = 0
|
|
113
|
+
|
|
114
|
+
while (round++ < MAX_TOOL_ROUNDS) {
|
|
115
|
+
let stream: ReturnType<typeof this.client.messages.stream>
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
stream = await withRetry(
|
|
119
|
+
async () => {
|
|
120
|
+
return this.client.messages.stream({
|
|
121
|
+
model: this.model,
|
|
122
|
+
max_tokens: this.maxTokens,
|
|
123
|
+
system,
|
|
124
|
+
messages: convo,
|
|
125
|
+
...(tools?.length ? { tools } : {}),
|
|
126
|
+
})
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
onAuthExpired: this.onAuthExpired ?? undefined,
|
|
130
|
+
},
|
|
131
|
+
)
|
|
132
|
+
} catch (err) {
|
|
133
|
+
yield { type: 'error', error: humanizeError(err) }
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
for await (const event of stream) {
|
|
138
|
+
if (
|
|
139
|
+
event.type === 'content_block_delta' &&
|
|
140
|
+
event.delta.type === 'text_delta'
|
|
141
|
+
) {
|
|
142
|
+
yield { type: 'text', text: event.delta.text }
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const final = await stream.finalMessage()
|
|
147
|
+
|
|
148
|
+
if (final.usage) {
|
|
149
|
+
yield {
|
|
150
|
+
type: 'usage',
|
|
151
|
+
inputTokens: final.usage.input_tokens,
|
|
152
|
+
outputTokens: final.usage.output_tokens,
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (final.stop_reason !== 'tool_use') {
|
|
157
|
+
yield { type: 'done' }
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const toolBlocks = final.content.filter(
|
|
162
|
+
(b: Anthropic.ContentBlock): b is Anthropic.ToolUseBlock => b.type === 'tool_use',
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
convo.push({ role: 'assistant', content: final.content })
|
|
166
|
+
|
|
167
|
+
const toolResults: Anthropic.ToolResultBlockParam[] = []
|
|
168
|
+
for (const tc of toolBlocks) {
|
|
169
|
+
const input = tc.input as Record<string, unknown>
|
|
170
|
+
const risk = assessToolRisk(tc.name, input)
|
|
171
|
+
|
|
172
|
+
// Block dangerous operations always
|
|
173
|
+
if (risk.level === 'dangerous') {
|
|
174
|
+
yield { type: 'tool_blocked', id: tc.id, name: tc.name, reason: `Blocked dangerous operation: ${risk.reason}` }
|
|
175
|
+
toolResults.push({
|
|
176
|
+
type: 'tool_result',
|
|
177
|
+
tool_use_id: tc.id,
|
|
178
|
+
content: `Error: Operation blocked for safety. Reason: ${risk.reason}. This command appears dangerous and was not executed.`,
|
|
179
|
+
})
|
|
180
|
+
continue
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Check if approval is needed
|
|
184
|
+
if (!this.autoApproveAll && needsApproval(this.approvalMode, tc.name, risk.level) && this.approvalCallback) {
|
|
185
|
+
yield { type: 'tool_call', id: tc.id, name: tc.name, input: tc.input }
|
|
186
|
+
const approved = await this.approvalCallback(tc.name, input, risk.level)
|
|
187
|
+
if (!approved) {
|
|
188
|
+
yield { type: 'tool_blocked', id: tc.id, name: tc.name, reason: 'Rejected by user' }
|
|
189
|
+
toolResults.push({
|
|
190
|
+
type: 'tool_result',
|
|
191
|
+
tool_use_id: tc.id,
|
|
192
|
+
content: 'Error: User rejected this operation.',
|
|
193
|
+
})
|
|
194
|
+
continue
|
|
195
|
+
}
|
|
196
|
+
// Approved — execute (tool_call already yielded above)
|
|
197
|
+
const result = await executeTool(tc.name, input)
|
|
198
|
+
yield { type: 'tool_result', id: tc.id, name: tc.name, result }
|
|
199
|
+
toolResults.push({ type: 'tool_result', tool_use_id: tc.id, content: result })
|
|
200
|
+
continue
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Auto-approved — execute normally
|
|
204
|
+
yield { type: 'tool_call', id: tc.id, name: tc.name, input: tc.input }
|
|
205
|
+
const result = await executeTool(tc.name, input)
|
|
206
|
+
yield { type: 'tool_result', id: tc.id, name: tc.name, result }
|
|
207
|
+
toolResults.push({ type: 'tool_result', tool_use_id: tc.id, content: result })
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
convo.push({ role: 'user', content: toolResults })
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
yield { type: 'error', error: `Stopped after ${MAX_TOOL_ROUNDS} tool rounds to prevent runaway execution.` }
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function toApiMessages(messages: Message[]): Anthropic.MessageParam[] {
|
|
218
|
+
const result: Anthropic.MessageParam[] = []
|
|
219
|
+
|
|
220
|
+
for (const msg of messages) {
|
|
221
|
+
if (msg.role === 'user') {
|
|
222
|
+
if (msg.images?.length) {
|
|
223
|
+
// Build multi-modal content with images + text
|
|
224
|
+
const content: Anthropic.ContentBlockParam[] = msg.images.map((img) => ({
|
|
225
|
+
type: 'image' as const,
|
|
226
|
+
source: {
|
|
227
|
+
type: 'base64' as const,
|
|
228
|
+
media_type: img.mediaType,
|
|
229
|
+
data: img.base64,
|
|
230
|
+
},
|
|
231
|
+
}))
|
|
232
|
+
content.push({ type: 'text', text: msg.content })
|
|
233
|
+
result.push({ role: 'user', content })
|
|
234
|
+
} else {
|
|
235
|
+
result.push({ role: 'user', content: msg.content })
|
|
236
|
+
}
|
|
237
|
+
} else if (msg.role === 'assistant') {
|
|
238
|
+
if (msg.toolCalls?.length) {
|
|
239
|
+
const content: Anthropic.ContentBlockParam[] = []
|
|
240
|
+
if (msg.content) {
|
|
241
|
+
content.push({ type: 'text', text: msg.content })
|
|
242
|
+
}
|
|
243
|
+
for (const tc of msg.toolCalls) {
|
|
244
|
+
content.push({
|
|
245
|
+
type: 'tool_use',
|
|
246
|
+
id: tc.id,
|
|
247
|
+
name: tc.name,
|
|
248
|
+
input: tc.input,
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
result.push({ role: 'assistant', content })
|
|
252
|
+
result.push({
|
|
253
|
+
role: 'user',
|
|
254
|
+
content: msg.toolCalls.map((tc) => ({
|
|
255
|
+
type: 'tool_result' as const,
|
|
256
|
+
tool_use_id: tc.id,
|
|
257
|
+
content: tc.result,
|
|
258
|
+
})),
|
|
259
|
+
})
|
|
260
|
+
} else {
|
|
261
|
+
result.push({ role: 'assistant', content: msg.content })
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return result
|
|
267
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join, dirname } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export interface CliArgs {
|
|
5
|
+
help: boolean
|
|
6
|
+
version: boolean
|
|
7
|
+
model?: string
|
|
8
|
+
session?: string
|
|
9
|
+
maxTokens?: number
|
|
10
|
+
noTools: boolean
|
|
11
|
+
print: boolean
|
|
12
|
+
prompt?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Parse CLI arguments. Zero dependencies.
|
|
17
|
+
*/
|
|
18
|
+
export function parseArgs(argv: string[]): CliArgs {
|
|
19
|
+
const args: CliArgs = {
|
|
20
|
+
help: false,
|
|
21
|
+
version: false,
|
|
22
|
+
noTools: false,
|
|
23
|
+
print: false,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const positional: string[] = []
|
|
27
|
+
let i = 0
|
|
28
|
+
|
|
29
|
+
while (i < argv.length) {
|
|
30
|
+
const arg = argv[i]
|
|
31
|
+
|
|
32
|
+
switch (arg) {
|
|
33
|
+
case '-h':
|
|
34
|
+
case '--help':
|
|
35
|
+
args.help = true
|
|
36
|
+
break
|
|
37
|
+
|
|
38
|
+
case '-v':
|
|
39
|
+
case '--version':
|
|
40
|
+
args.version = true
|
|
41
|
+
break
|
|
42
|
+
|
|
43
|
+
case '-m':
|
|
44
|
+
case '--model':
|
|
45
|
+
args.model = argv[++i]
|
|
46
|
+
if (!args.model) die('--model requires a value')
|
|
47
|
+
break
|
|
48
|
+
|
|
49
|
+
case '-s':
|
|
50
|
+
case '--session':
|
|
51
|
+
args.session = argv[++i]
|
|
52
|
+
if (!args.session) die('--session requires a value')
|
|
53
|
+
break
|
|
54
|
+
|
|
55
|
+
case '--max-tokens':
|
|
56
|
+
const n = Number(argv[++i])
|
|
57
|
+
if (!n || n <= 0) die('--max-tokens requires a positive number')
|
|
58
|
+
args.maxTokens = n
|
|
59
|
+
break
|
|
60
|
+
|
|
61
|
+
case '--no-tools':
|
|
62
|
+
args.noTools = true
|
|
63
|
+
break
|
|
64
|
+
|
|
65
|
+
case '-p':
|
|
66
|
+
case '--print':
|
|
67
|
+
args.print = true
|
|
68
|
+
break
|
|
69
|
+
|
|
70
|
+
default:
|
|
71
|
+
if (arg.startsWith('-')) {
|
|
72
|
+
die(`Unknown option: ${arg}. Try --help`)
|
|
73
|
+
}
|
|
74
|
+
positional.push(arg)
|
|
75
|
+
}
|
|
76
|
+
i++
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (positional.length > 0) {
|
|
80
|
+
args.prompt = positional.join(' ')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return args
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// BUILD_VERSION is injected at compile time via --define.
|
|
87
|
+
// Falls back to reading package.json at runtime (dev mode).
|
|
88
|
+
declare const BUILD_VERSION: string | undefined
|
|
89
|
+
|
|
90
|
+
export function getVersion(): string {
|
|
91
|
+
if (typeof BUILD_VERSION !== 'undefined') return BUILD_VERSION
|
|
92
|
+
try {
|
|
93
|
+
const pkgPath = join(dirname(import.meta.dir), 'package.json')
|
|
94
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
|
|
95
|
+
return pkg.version || '0.0.0'
|
|
96
|
+
} catch {
|
|
97
|
+
return '0.0.0'
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function printHelp(): void {
|
|
102
|
+
const version = getVersion()
|
|
103
|
+
console.log(`smolerclaw v${version} — the micro AI assistant
|
|
104
|
+
|
|
105
|
+
Usage:
|
|
106
|
+
smolerclaw [options] [prompt]
|
|
107
|
+
|
|
108
|
+
Options:
|
|
109
|
+
-h, --help Show this help
|
|
110
|
+
-v, --version Show version
|
|
111
|
+
-m, --model <name> Override model (e.g. claude-sonnet-4-20250514)
|
|
112
|
+
-s, --session <name> Start with a specific session
|
|
113
|
+
--max-tokens <n> Override max tokens per response
|
|
114
|
+
--no-tools Disable tool use for this session
|
|
115
|
+
-p, --print Print response and exit (no TUI)
|
|
116
|
+
|
|
117
|
+
Examples:
|
|
118
|
+
smolerclaw Interactive TUI mode
|
|
119
|
+
smolerclaw "explain this error" Launch TUI with initial prompt
|
|
120
|
+
smolerclaw -p "what is 2+2" Print answer and exit
|
|
121
|
+
echo "review" | smolerclaw -p Pipe input, print response
|
|
122
|
+
smolerclaw -m claude-sonnet-4-20250514 -s work
|
|
123
|
+
|
|
124
|
+
Commands (inside TUI):
|
|
125
|
+
/help Show commands /clear Clear conversation
|
|
126
|
+
/new New session /load Load session
|
|
127
|
+
/model Show/set model /persona Switch mode
|
|
128
|
+
/briefing Daily briefing /news News radar
|
|
129
|
+
/task Create task /tasks List tasks
|
|
130
|
+
/open Open Windows app /calendar Outlook calendar
|
|
131
|
+
/export Export markdown /exit Quit`)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function die(msg: string): never {
|
|
135
|
+
console.error(`smolerclaw: ${msg}`)
|
|
136
|
+
process.exit(2)
|
|
137
|
+
}
|
package/src/clipboard.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { IS_WINDOWS, IS_MAC } from './platform'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Copy text to system clipboard. Cross-platform.
|
|
5
|
+
*/
|
|
6
|
+
export async function copyToClipboard(text: string): Promise<boolean> {
|
|
7
|
+
try {
|
|
8
|
+
const cmd = IS_WINDOWS
|
|
9
|
+
? ['powershell', '-NoProfile', '-Command', 'Set-Clipboard -Value $input']
|
|
10
|
+
: IS_MAC
|
|
11
|
+
? ['pbcopy']
|
|
12
|
+
: ['xclip', '-selection', 'clipboard']
|
|
13
|
+
|
|
14
|
+
const proc = Bun.spawn(cmd, {
|
|
15
|
+
stdin: 'pipe',
|
|
16
|
+
stdout: 'pipe',
|
|
17
|
+
stderr: 'pipe',
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
proc.stdin.write(text)
|
|
21
|
+
proc.stdin.end()
|
|
22
|
+
const code = await proc.exited
|
|
23
|
+
return code === 0
|
|
24
|
+
} catch {
|
|
25
|
+
return false
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { IS_WINDOWS } from './platform'
|
|
5
|
+
import type { TinyClawConfig } from './types'
|
|
6
|
+
|
|
7
|
+
const HOME = homedir()
|
|
8
|
+
|
|
9
|
+
// Platform-aware directories
|
|
10
|
+
const CONFIG_DIR = IS_WINDOWS
|
|
11
|
+
? join(process.env.APPDATA || join(HOME, 'AppData', 'Roaming'), 'smolerclaw')
|
|
12
|
+
: join(HOME, '.config', 'smolerclaw')
|
|
13
|
+
|
|
14
|
+
const DATA_DIR = IS_WINDOWS
|
|
15
|
+
? join(process.env.LOCALAPPDATA || join(HOME, 'AppData', 'Local'), 'smolerclaw')
|
|
16
|
+
: join(HOME, '.local', 'share', 'smolerclaw')
|
|
17
|
+
|
|
18
|
+
const CONFIG_FILE = join(CONFIG_DIR, 'config.json')
|
|
19
|
+
|
|
20
|
+
const DEFAULTS: TinyClawConfig = {
|
|
21
|
+
apiKey: '',
|
|
22
|
+
authMode: 'auto',
|
|
23
|
+
model: 'claude-haiku-4-5-20251001',
|
|
24
|
+
maxTokens: 4096,
|
|
25
|
+
maxHistory: 50,
|
|
26
|
+
systemPrompt: '',
|
|
27
|
+
skillsDir: './skills',
|
|
28
|
+
dataDir: DATA_DIR,
|
|
29
|
+
toolApproval: 'auto',
|
|
30
|
+
language: 'auto',
|
|
31
|
+
maxSessionCost: 0,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function ensureDir(dir: string): void {
|
|
35
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function loadConfig(): TinyClawConfig {
|
|
39
|
+
ensureDir(CONFIG_DIR)
|
|
40
|
+
ensureDir(DATA_DIR)
|
|
41
|
+
ensureDir(join(DATA_DIR, 'sessions'))
|
|
42
|
+
|
|
43
|
+
// Migrate from old Linux-style paths on Windows if they exist
|
|
44
|
+
if (IS_WINDOWS) {
|
|
45
|
+
migrateOldPaths()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!existsSync(CONFIG_FILE)) {
|
|
49
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(DEFAULTS, null, 2))
|
|
50
|
+
return { ...DEFAULTS }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let raw: Record<string, unknown>
|
|
54
|
+
try {
|
|
55
|
+
raw = JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'))
|
|
56
|
+
} catch {
|
|
57
|
+
// Config file corrupted — reset to defaults
|
|
58
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(DEFAULTS, null, 2))
|
|
59
|
+
return { ...DEFAULTS }
|
|
60
|
+
}
|
|
61
|
+
return { ...DEFAULTS, ...raw }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function saveConfig(config: TinyClawConfig): void {
|
|
65
|
+
ensureDir(CONFIG_DIR)
|
|
66
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function getConfigPath(): string {
|
|
70
|
+
return CONFIG_FILE
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function getDataDir(): string {
|
|
74
|
+
return DATA_DIR
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** One-time migration from old ~/.config/smolerclaw paths on Windows */
|
|
78
|
+
function migrateOldPaths(): void {
|
|
79
|
+
const oldConfig = join(HOME, '.config', 'smolerclaw', 'config.json')
|
|
80
|
+
if (existsSync(oldConfig) && !existsSync(CONFIG_FILE)) {
|
|
81
|
+
try {
|
|
82
|
+
const data = readFileSync(oldConfig, 'utf-8')
|
|
83
|
+
ensureDir(CONFIG_DIR)
|
|
84
|
+
writeFileSync(CONFIG_FILE, data)
|
|
85
|
+
} catch { /* best effort */ }
|
|
86
|
+
}
|
|
87
|
+
}
|