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
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test'
|
|
2
|
+
import { needsApproval, formatApprovalPrompt } from '../src/approval'
|
|
3
|
+
|
|
4
|
+
describe('needsApproval', () => {
|
|
5
|
+
test('auto mode never needs approval', () => {
|
|
6
|
+
expect(needsApproval('auto', 'write_file', 'moderate')).toBe(false)
|
|
7
|
+
expect(needsApproval('auto', 'run_command', 'moderate')).toBe(false)
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
test('confirm-writes approves read tools', () => {
|
|
11
|
+
expect(needsApproval('confirm-writes', 'read_file', 'safe')).toBe(false)
|
|
12
|
+
expect(needsApproval('confirm-writes', 'search_files', 'safe')).toBe(false)
|
|
13
|
+
expect(needsApproval('confirm-writes', 'list_directory', 'safe')).toBe(false)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('confirm-writes requires approval for writes', () => {
|
|
17
|
+
expect(needsApproval('confirm-writes', 'write_file', 'moderate')).toBe(true)
|
|
18
|
+
expect(needsApproval('confirm-writes', 'edit_file', 'moderate')).toBe(true)
|
|
19
|
+
expect(needsApproval('confirm-writes', 'run_command', 'moderate')).toBe(true)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('confirm-all requires approval for all non-safe', () => {
|
|
23
|
+
expect(needsApproval('confirm-all', 'write_file', 'moderate')).toBe(true)
|
|
24
|
+
expect(needsApproval('confirm-all', 'fetch_url', 'safe')).toBe(false)
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('formatApprovalPrompt', () => {
|
|
29
|
+
test('formats write_file', () => {
|
|
30
|
+
expect(formatApprovalPrompt('write_file', { path: 'src/foo.ts' })).toContain('src/foo.ts')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('formats run_command', () => {
|
|
34
|
+
expect(formatApprovalPrompt('run_command', { command: 'npm test' })).toContain('npm test')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('truncates long commands', () => {
|
|
38
|
+
const long = 'a'.repeat(100)
|
|
39
|
+
const result = formatApprovalPrompt('run_command', { command: long })
|
|
40
|
+
expect(result.length).toBeLessThan(80)
|
|
41
|
+
expect(result).toContain('...')
|
|
42
|
+
})
|
|
43
|
+
})
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test'
|
|
2
|
+
import { generateBriefing } from '../src/briefing'
|
|
3
|
+
|
|
4
|
+
describe('briefing', () => {
|
|
5
|
+
test('generateBriefing returns structured output', async () => {
|
|
6
|
+
const result = await generateBriefing()
|
|
7
|
+
expect(result).toContain('BRIEFING DIARIO')
|
|
8
|
+
expect(result).toContain('Semana')
|
|
9
|
+
}, 30_000) // network timeout for news
|
|
10
|
+
})
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test'
|
|
2
|
+
import { parseArgs } from '../src/cli'
|
|
3
|
+
|
|
4
|
+
describe('parseArgs', () => {
|
|
5
|
+
test('defaults', () => {
|
|
6
|
+
const args = parseArgs([])
|
|
7
|
+
expect(args.help).toBe(false)
|
|
8
|
+
expect(args.version).toBe(false)
|
|
9
|
+
expect(args.print).toBe(false)
|
|
10
|
+
expect(args.noTools).toBe(false)
|
|
11
|
+
expect(args.model).toBeUndefined()
|
|
12
|
+
expect(args.prompt).toBeUndefined()
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test('--help', () => {
|
|
16
|
+
expect(parseArgs(['-h']).help).toBe(true)
|
|
17
|
+
expect(parseArgs(['--help']).help).toBe(true)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('--version', () => {
|
|
21
|
+
expect(parseArgs(['-v']).version).toBe(true)
|
|
22
|
+
expect(parseArgs(['--version']).version).toBe(true)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('--model', () => {
|
|
26
|
+
expect(parseArgs(['-m', 'sonnet']).model).toBe('sonnet')
|
|
27
|
+
expect(parseArgs(['--model', 'haiku']).model).toBe('haiku')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('--session', () => {
|
|
31
|
+
expect(parseArgs(['-s', 'work']).session).toBe('work')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('--print', () => {
|
|
35
|
+
expect(parseArgs(['-p']).print).toBe(true)
|
|
36
|
+
expect(parseArgs(['--print']).print).toBe(true)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('--no-tools', () => {
|
|
40
|
+
expect(parseArgs(['--no-tools']).noTools).toBe(true)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('positional args become prompt', () => {
|
|
44
|
+
expect(parseArgs(['hello', 'world']).prompt).toBe('hello world')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('mixed flags and positional', () => {
|
|
48
|
+
const args = parseArgs(['-m', 'opus', '-p', 'explain', 'this'])
|
|
49
|
+
expect(args.model).toBe('opus')
|
|
50
|
+
expect(args.print).toBe(true)
|
|
51
|
+
expect(args.prompt).toBe('explain this')
|
|
52
|
+
})
|
|
53
|
+
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test'
|
|
2
|
+
import { estimateTokens, estimateMessageTokens, compressToolResults } from '../src/context-window'
|
|
3
|
+
import type { Message } from '../src/types'
|
|
4
|
+
|
|
5
|
+
describe('estimateTokens', () => {
|
|
6
|
+
test('estimates roughly 1 token per 3.5 chars', () => {
|
|
7
|
+
const tokens = estimateTokens('Hello, world!')
|
|
8
|
+
expect(tokens).toBeGreaterThan(2)
|
|
9
|
+
expect(tokens).toBeLessThan(10)
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
test('empty string is 0 tokens', () => {
|
|
13
|
+
expect(estimateTokens('')).toBe(0)
|
|
14
|
+
})
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
describe('estimateMessageTokens', () => {
|
|
18
|
+
test('counts tokens across messages', () => {
|
|
19
|
+
const msgs: Message[] = [
|
|
20
|
+
{ role: 'user', content: 'Hello', timestamp: 0 },
|
|
21
|
+
{ role: 'assistant', content: 'Hi there, how can I help?', timestamp: 0 },
|
|
22
|
+
]
|
|
23
|
+
const tokens = estimateMessageTokens(msgs)
|
|
24
|
+
expect(tokens).toBeGreaterThan(10)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('includes tool call tokens', () => {
|
|
28
|
+
const withTools: Message[] = [{
|
|
29
|
+
role: 'assistant',
|
|
30
|
+
content: 'Let me check.',
|
|
31
|
+
toolCalls: [{ id: '1', name: 'read_file', input: { path: 'foo.ts' }, result: 'file contents here' }],
|
|
32
|
+
timestamp: 0,
|
|
33
|
+
}]
|
|
34
|
+
const withoutTools: Message[] = [{
|
|
35
|
+
role: 'assistant',
|
|
36
|
+
content: 'Let me check.',
|
|
37
|
+
timestamp: 0,
|
|
38
|
+
}]
|
|
39
|
+
expect(estimateMessageTokens(withTools)).toBeGreaterThan(estimateMessageTokens(withoutTools))
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('compressToolResults', () => {
|
|
44
|
+
test('short results pass through unchanged', () => {
|
|
45
|
+
const msgs: Message[] = [{
|
|
46
|
+
role: 'assistant',
|
|
47
|
+
content: 'done',
|
|
48
|
+
toolCalls: [{ id: '1', name: 'read_file', input: { path: 'x' }, result: 'short' }],
|
|
49
|
+
timestamp: 0,
|
|
50
|
+
}]
|
|
51
|
+
const compressed = compressToolResults(msgs)
|
|
52
|
+
expect(compressed[0].toolCalls![0].result).toBe('short')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('long results are truncated without negative count', () => {
|
|
56
|
+
const longResult = Array.from({ length: 100 }, (_, i) => `line ${i}`).join('\n')
|
|
57
|
+
const msgs: Message[] = [{
|
|
58
|
+
role: 'assistant',
|
|
59
|
+
content: 'done',
|
|
60
|
+
toolCalls: [{ id: '1', name: 'read_file', input: { path: 'x' }, result: longResult }],
|
|
61
|
+
timestamp: 0,
|
|
62
|
+
}]
|
|
63
|
+
const compressed = compressToolResults(msgs, 50)
|
|
64
|
+
const result = compressed[0].toolCalls![0].result
|
|
65
|
+
expect(result).toContain('omitted')
|
|
66
|
+
// Should never contain negative count
|
|
67
|
+
expect(result).not.toMatch(/\(-\d+ lines omitted\)/)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('results with few lines do not produce negative omitted count', () => {
|
|
71
|
+
const fiveLines = 'a\nb\nc\nd\ne'
|
|
72
|
+
const msgs: Message[] = [{
|
|
73
|
+
role: 'assistant',
|
|
74
|
+
content: 'done',
|
|
75
|
+
toolCalls: [{ id: '1', name: 'read_file', input: { path: 'x' }, result: fiveLines }],
|
|
76
|
+
timestamp: 0,
|
|
77
|
+
}]
|
|
78
|
+
// Force compression with very low maxResultLen
|
|
79
|
+
const compressed = compressToolResults(msgs, 2)
|
|
80
|
+
const result = compressed[0].toolCalls![0].result
|
|
81
|
+
expect(result).not.toMatch(/-\d+ lines/)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test'
|
|
2
|
+
import { extractImages } from '../src/images'
|
|
3
|
+
|
|
4
|
+
describe('extractImages', () => {
|
|
5
|
+
test('plain text with no images passes through', () => {
|
|
6
|
+
const { text, images } = extractImages('hello world')
|
|
7
|
+
expect(text).toBe('hello world')
|
|
8
|
+
expect(images).toHaveLength(0)
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
test('non-existent image path is kept as text', () => {
|
|
12
|
+
const { text, images } = extractImages('look at /nonexistent/image.png')
|
|
13
|
+
expect(text).toContain('/nonexistent/image.png')
|
|
14
|
+
expect(images).toHaveLength(0)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('recognizes image extensions', () => {
|
|
18
|
+
// These paths don't exist, so they stay as text — but the logic is tested
|
|
19
|
+
const { text } = extractImages('file.txt file.png file.jpg')
|
|
20
|
+
expect(text).toContain('file.txt')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('handles empty input', () => {
|
|
24
|
+
const { text, images } = extractImages('')
|
|
25
|
+
expect(text).toBe('')
|
|
26
|
+
expect(images).toHaveLength(0)
|
|
27
|
+
})
|
|
28
|
+
})
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach } from 'bun:test'
|
|
2
|
+
import { initMemos, saveMemo, searchMemos, listMemos, updateMemo, deleteMemo, getMemoTags, formatMemoList, formatMemoTags } from '../src/memos'
|
|
3
|
+
import { mkdtempSync } from 'node:fs'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { tmpdir } from 'node:os'
|
|
6
|
+
|
|
7
|
+
describe('memos', () => {
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
const tmpDir = mkdtempSync(join(tmpdir(), 'smolerclaw-memo-'))
|
|
10
|
+
initMemos(tmpDir)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
test('saveMemo creates a memo', () => {
|
|
14
|
+
const memo = saveMemo('Remember to buy milk')
|
|
15
|
+
expect(memo.id).toBeTruthy()
|
|
16
|
+
expect(memo.content).toBe('Remember to buy milk')
|
|
17
|
+
expect(memo.tags).toEqual([])
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('saveMemo extracts hashtags', () => {
|
|
21
|
+
const memo = saveMemo('Deploy steps for #docker #production')
|
|
22
|
+
expect(memo.tags).toContain('docker')
|
|
23
|
+
expect(memo.tags).toContain('production')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('saveMemo merges explicit tags with hashtags', () => {
|
|
27
|
+
const memo = saveMemo('Config for #nginx', ['server', 'infra'])
|
|
28
|
+
expect(memo.tags).toContain('nginx')
|
|
29
|
+
expect(memo.tags).toContain('server')
|
|
30
|
+
expect(memo.tags).toContain('infra')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('saveMemo deduplicates tags', () => {
|
|
34
|
+
const memo = saveMemo('Test #work', ['work'])
|
|
35
|
+
const workCount = memo.tags.filter((t) => t === 'work').length
|
|
36
|
+
expect(workCount).toBe(1)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('listMemos returns recent first', () => {
|
|
40
|
+
saveMemo('first')
|
|
41
|
+
saveMemo('second')
|
|
42
|
+
saveMemo('third')
|
|
43
|
+
const memos = listMemos()
|
|
44
|
+
expect(memos.length).toBe(3)
|
|
45
|
+
expect(memos[0].content).toBe('third')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('listMemos respects limit', () => {
|
|
49
|
+
for (let i = 0; i < 5; i++) saveMemo(`memo ${i}`)
|
|
50
|
+
expect(listMemos(3).length).toBe(3)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('searchMemos by content', () => {
|
|
54
|
+
saveMemo('docker compose setup')
|
|
55
|
+
saveMemo('kubernetes config')
|
|
56
|
+
saveMemo('docker swarm notes')
|
|
57
|
+
const results = searchMemos('docker')
|
|
58
|
+
expect(results.length).toBe(2)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('searchMemos by tag', () => {
|
|
62
|
+
saveMemo('step 1 #deploy')
|
|
63
|
+
saveMemo('step 2 #deploy')
|
|
64
|
+
saveMemo('other #random')
|
|
65
|
+
const results = searchMemos('#deploy')
|
|
66
|
+
expect(results.length).toBe(2)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('searchMemos empty query returns all', () => {
|
|
70
|
+
saveMemo('a')
|
|
71
|
+
saveMemo('b')
|
|
72
|
+
expect(searchMemos('').length).toBe(2)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('updateMemo changes content', () => {
|
|
76
|
+
const memo = saveMemo('original')
|
|
77
|
+
const updated = updateMemo(memo.id, 'modified #new')
|
|
78
|
+
expect(updated?.content).toBe('modified #new')
|
|
79
|
+
expect(updated?.tags).toContain('new')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('deleteMemo removes memo', () => {
|
|
83
|
+
const memo = saveMemo('temp')
|
|
84
|
+
expect(deleteMemo(memo.id)).toBe(true)
|
|
85
|
+
expect(listMemos().length).toBe(0)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('deleteMemo returns false for unknown', () => {
|
|
89
|
+
expect(deleteMemo('nonexistent')).toBe(false)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('getMemoTags returns sorted by count', () => {
|
|
93
|
+
saveMemo('#work task 1')
|
|
94
|
+
saveMemo('#work task 2')
|
|
95
|
+
saveMemo('#personal note')
|
|
96
|
+
const tags = getMemoTags()
|
|
97
|
+
expect(tags[0].tag).toBe('work')
|
|
98
|
+
expect(tags[0].count).toBe(2)
|
|
99
|
+
expect(tags[1].tag).toBe('personal')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('formatMemoList shows memos', () => {
|
|
103
|
+
saveMemo('test memo #tag1')
|
|
104
|
+
const text = formatMemoList(listMemos())
|
|
105
|
+
expect(text).toContain('test memo')
|
|
106
|
+
expect(text).toContain('#tag1')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('formatMemoList empty', () => {
|
|
110
|
+
expect(formatMemoList([])).toContain('Nenhum memo')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('formatMemoTags empty', () => {
|
|
114
|
+
expect(formatMemoTags()).toContain('Nenhuma tag')
|
|
115
|
+
})
|
|
116
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test'
|
|
2
|
+
import { resolveModel, modelDisplayName } from '../src/models'
|
|
3
|
+
|
|
4
|
+
describe('resolveModel', () => {
|
|
5
|
+
test('resolves aliases', () => {
|
|
6
|
+
expect(resolveModel('haiku')).toBe('claude-haiku-4-5-20251001')
|
|
7
|
+
expect(resolveModel('sonnet')).toBe('claude-sonnet-4-20250514')
|
|
8
|
+
expect(resolveModel('opus')).toBe('claude-opus-4-20250514')
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
test('passes through exact model IDs', () => {
|
|
12
|
+
expect(resolveModel('claude-haiku-4-5-20251001')).toBe('claude-haiku-4-5-20251001')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test('passes through unknown models', () => {
|
|
16
|
+
expect(resolveModel('gpt-4o')).toBe('gpt-4o')
|
|
17
|
+
expect(resolveModel('custom-fine-tuned-v1')).toBe('custom-fine-tuned-v1')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('is case-insensitive for aliases', () => {
|
|
21
|
+
expect(resolveModel('Haiku')).toBe('claude-haiku-4-5-20251001')
|
|
22
|
+
expect(resolveModel('SONNET')).toBe('claude-sonnet-4-20250514')
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('modelDisplayName', () => {
|
|
27
|
+
test('returns friendly name for known models', () => {
|
|
28
|
+
expect(modelDisplayName('claude-haiku-4-5-20251001')).toContain('Haiku')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('returns ID for unknown models', () => {
|
|
32
|
+
expect(modelDisplayName('gpt-4o')).toBe('gpt-4o')
|
|
33
|
+
})
|
|
34
|
+
})
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test'
|
|
2
|
+
import { getNewsCategories } from '../src/news'
|
|
3
|
+
|
|
4
|
+
describe('news', () => {
|
|
5
|
+
test('getNewsCategories returns category list', () => {
|
|
6
|
+
const result = getNewsCategories()
|
|
7
|
+
expect(result).toContain('business')
|
|
8
|
+
expect(result).toContain('tech')
|
|
9
|
+
expect(result).toContain('finance')
|
|
10
|
+
expect(result).toContain('brazil')
|
|
11
|
+
expect(result).toContain('world')
|
|
12
|
+
})
|
|
13
|
+
})
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test'
|
|
2
|
+
import { resolve, sep } from 'node:path'
|
|
3
|
+
|
|
4
|
+
// Inline the guardPath logic for testing (can't import private function)
|
|
5
|
+
function guardPath(filePath: string): string | null {
|
|
6
|
+
const resolved = resolve(filePath)
|
|
7
|
+
const cwd = process.cwd()
|
|
8
|
+
if (resolved !== cwd && !resolved.startsWith(cwd + sep)) {
|
|
9
|
+
return `Error: path outside working directory is not permitted: ${resolved}`
|
|
10
|
+
}
|
|
11
|
+
return null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('guardPath', () => {
|
|
15
|
+
test('allows paths within cwd', () => {
|
|
16
|
+
expect(guardPath('src/index.ts')).toBeNull()
|
|
17
|
+
expect(guardPath('./package.json')).toBeNull()
|
|
18
|
+
expect(guardPath('tests/foo.test.ts')).toBeNull()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('allows cwd itself', () => {
|
|
22
|
+
expect(guardPath('.')).toBeNull()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('blocks paths outside cwd', () => {
|
|
26
|
+
expect(guardPath('/etc/passwd')).not.toBeNull()
|
|
27
|
+
expect(guardPath('../../.ssh/id_rsa')).not.toBeNull()
|
|
28
|
+
expect(guardPath('../../../etc/shadow')).not.toBeNull()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('blocks home directory paths', () => {
|
|
32
|
+
const home = process.env.HOME || process.env.USERPROFILE || ''
|
|
33
|
+
if (home) {
|
|
34
|
+
expect(guardPath(home + '/.ssh/id_rsa')).not.toBeNull()
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
})
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
initPeople, addPerson, findPerson, listPeople, updatePerson, removePerson,
|
|
4
|
+
logInteraction, getInteractions, getPendingFollowUps, markFollowUpDone,
|
|
5
|
+
delegateTask, updateDelegation, getDelegations,
|
|
6
|
+
formatPeopleList, formatPersonDetail, formatDelegationList, formatFollowUps,
|
|
7
|
+
generatePeopleDashboard,
|
|
8
|
+
} from '../src/people'
|
|
9
|
+
import { mkdtempSync } from 'node:fs'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
import { tmpdir } from 'node:os'
|
|
12
|
+
|
|
13
|
+
describe('people', () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
const tmpDir = mkdtempSync(join(tmpdir(), 'smolerclaw-people-'))
|
|
16
|
+
initPeople(tmpDir)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
// ── CRUD ──
|
|
20
|
+
|
|
21
|
+
test('addPerson creates a person', () => {
|
|
22
|
+
const p = addPerson('Joao', 'equipe', 'dev frontend')
|
|
23
|
+
expect(p.id).toBeTruthy()
|
|
24
|
+
expect(p.name).toBe('Joao')
|
|
25
|
+
expect(p.group).toBe('equipe')
|
|
26
|
+
expect(p.role).toBe('dev frontend')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('findPerson by name (partial match)', () => {
|
|
30
|
+
addPerson('Maria Silva', 'equipe')
|
|
31
|
+
const found = findPerson('maria')
|
|
32
|
+
expect(found?.name).toBe('Maria Silva')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('findPerson by id', () => {
|
|
36
|
+
const p = addPerson('Carlos', 'familia')
|
|
37
|
+
const found = findPerson(p.id)
|
|
38
|
+
expect(found?.name).toBe('Carlos')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('findPerson returns null for unknown', () => {
|
|
42
|
+
expect(findPerson('nobody')).toBeNull()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('listPeople returns all', () => {
|
|
46
|
+
addPerson('A', 'equipe')
|
|
47
|
+
addPerson('B', 'familia')
|
|
48
|
+
addPerson('C', 'contato')
|
|
49
|
+
expect(listPeople().length).toBe(3)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('listPeople filters by group', () => {
|
|
53
|
+
addPerson('A', 'equipe')
|
|
54
|
+
addPerson('B', 'familia')
|
|
55
|
+
addPerson('C', 'equipe')
|
|
56
|
+
expect(listPeople('equipe').length).toBe(2)
|
|
57
|
+
expect(listPeople('familia').length).toBe(1)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('updatePerson changes fields', () => {
|
|
61
|
+
const p = addPerson('Ana', 'equipe')
|
|
62
|
+
const updated = updatePerson(p.id, { role: 'tech lead' })
|
|
63
|
+
expect(updated?.role).toBe('tech lead')
|
|
64
|
+
expect(updated?.name).toBe('Ana')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('removePerson deletes person and related data', () => {
|
|
68
|
+
const p = addPerson('Temp', 'contato')
|
|
69
|
+
logInteraction(p.id, 'conversa', 'test')
|
|
70
|
+
delegateTask(p.id, 'test task')
|
|
71
|
+
expect(removePerson(p.id)).toBe(true)
|
|
72
|
+
expect(findPerson(p.id)).toBeNull()
|
|
73
|
+
expect(getInteractions(p.id).length).toBe(0)
|
|
74
|
+
expect(getDelegations(p.id).length).toBe(0)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
// ── Interactions ──
|
|
78
|
+
|
|
79
|
+
test('logInteraction creates an interaction', () => {
|
|
80
|
+
const p = addPerson('Bob', 'equipe')
|
|
81
|
+
const i = logInteraction(p.id, 'reuniao', 'Sprint planning')
|
|
82
|
+
expect(i?.type).toBe('reuniao')
|
|
83
|
+
expect(i?.summary).toBe('Sprint planning')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('logInteraction returns null for unknown person', () => {
|
|
87
|
+
expect(logInteraction('nobody', 'conversa', 'hi')).toBeNull()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('getInteractions returns recent first', () => {
|
|
91
|
+
const p = addPerson('Ana', 'equipe')
|
|
92
|
+
logInteraction(p.id, 'email', 'first')
|
|
93
|
+
logInteraction(p.id, 'email', 'second')
|
|
94
|
+
logInteraction(p.id, 'email', 'third')
|
|
95
|
+
const list = getInteractions(p.id)
|
|
96
|
+
expect(list.length).toBe(3)
|
|
97
|
+
expect(list[0].summary).toBe('third')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('logInteraction with follow-up', () => {
|
|
101
|
+
const p = addPerson('Joao', 'equipe')
|
|
102
|
+
const future = new Date(Date.now() - 60_000) // 1 min ago = due
|
|
103
|
+
logInteraction(p.id, 'conversa', 'cobrar relatorio', future)
|
|
104
|
+
const followUps = getPendingFollowUps()
|
|
105
|
+
expect(followUps.length).toBe(1)
|
|
106
|
+
expect(followUps[0].person.name).toBe('Joao')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('markFollowUpDone clears follow-up', () => {
|
|
110
|
+
const p = addPerson('Joao', 'equipe')
|
|
111
|
+
const past = new Date(Date.now() - 60_000)
|
|
112
|
+
const i = logInteraction(p.id, 'conversa', 'test', past)
|
|
113
|
+
expect(getPendingFollowUps().length).toBe(1)
|
|
114
|
+
markFollowUpDone(i!.id)
|
|
115
|
+
expect(getPendingFollowUps().length).toBe(0)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
// ── Delegations ──
|
|
119
|
+
|
|
120
|
+
test('delegateTask creates delegation', () => {
|
|
121
|
+
const p = addPerson('Maria', 'equipe')
|
|
122
|
+
const d = delegateTask(p.id, 'Revisar documento')
|
|
123
|
+
expect(d?.task).toBe('Revisar documento')
|
|
124
|
+
expect(d?.status).toBe('pendente')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('delegateTask returns null for unknown person', () => {
|
|
128
|
+
expect(delegateTask('nobody', 'task')).toBeNull()
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('updateDelegation changes status', () => {
|
|
132
|
+
const p = addPerson('Carlos', 'equipe')
|
|
133
|
+
const d = delegateTask(p.id, 'Fazer deploy')!
|
|
134
|
+
const updated = updateDelegation(d.id, 'concluido', 'deploy feito')
|
|
135
|
+
expect(updated?.status).toBe('concluido')
|
|
136
|
+
expect(updated?.notes).toBe('deploy feito')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('getDelegations shows overdue tasks', () => {
|
|
140
|
+
const p = addPerson('Ana', 'equipe')
|
|
141
|
+
const past = new Date(Date.now() - 86_400_000) // yesterday
|
|
142
|
+
delegateTask(p.id, 'task atrasada', past)
|
|
143
|
+
const list = getDelegations(p.id)
|
|
144
|
+
expect(list.length).toBe(1)
|
|
145
|
+
expect(list[0].status).toBe('atrasado')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('getDelegations filters completed by default', () => {
|
|
149
|
+
const p = addPerson('Bob', 'equipe')
|
|
150
|
+
const d = delegateTask(p.id, 'done task')!
|
|
151
|
+
updateDelegation(d.id, 'concluido')
|
|
152
|
+
expect(getDelegations(p.id, true).length).toBe(0)
|
|
153
|
+
expect(getDelegations(p.id, false).length).toBe(1)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
// ── Formatting ──
|
|
157
|
+
|
|
158
|
+
test('formatPeopleList groups by type', () => {
|
|
159
|
+
addPerson('Alice', 'equipe')
|
|
160
|
+
addPerson('Bob', 'familia')
|
|
161
|
+
const text = formatPeopleList(listPeople())
|
|
162
|
+
expect(text).toContain('Equipe')
|
|
163
|
+
expect(text).toContain('Familia')
|
|
164
|
+
expect(text).toContain('Alice')
|
|
165
|
+
expect(text).toContain('Bob')
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
test('formatPeopleList empty', () => {
|
|
169
|
+
expect(formatPeopleList([])).toContain('Nenhuma pessoa')
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
test('formatPersonDetail includes interactions and delegations', () => {
|
|
173
|
+
const p = addPerson('Ana', 'equipe', 'tech lead')
|
|
174
|
+
logInteraction(p.id, 'reuniao', 'daily standup')
|
|
175
|
+
delegateTask(p.id, 'code review')
|
|
176
|
+
const text = formatPersonDetail(p)
|
|
177
|
+
expect(text).toContain('Ana')
|
|
178
|
+
expect(text).toContain('tech lead')
|
|
179
|
+
expect(text).toContain('daily standup')
|
|
180
|
+
expect(text).toContain('code review')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('formatDelegationList shows items', () => {
|
|
184
|
+
const p = addPerson('X', 'equipe')
|
|
185
|
+
delegateTask(p.id, 'task A')
|
|
186
|
+
const text = formatDelegationList(getDelegations())
|
|
187
|
+
expect(text).toContain('task A')
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test('formatFollowUps empty', () => {
|
|
191
|
+
expect(formatFollowUps([])).toContain('Nenhum follow-up')
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
// ── Dashboard ──
|
|
195
|
+
|
|
196
|
+
test('generatePeopleDashboard returns structured output', () => {
|
|
197
|
+
addPerson('Team1', 'equipe')
|
|
198
|
+
addPerson('Family1', 'familia')
|
|
199
|
+
const text = generatePeopleDashboard()
|
|
200
|
+
expect(text).toContain('PAINEL DE PESSOAS')
|
|
201
|
+
expect(text).toContain('Equipe: 1')
|
|
202
|
+
expect(text).toContain('Familia: 1')
|
|
203
|
+
})
|
|
204
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test'
|
|
2
|
+
import { loadSkills, formatSkillList } from '../src/skills'
|
|
3
|
+
|
|
4
|
+
describe('loadSkills', () => {
|
|
5
|
+
test('returns empty array for non-existent dir', () => {
|
|
6
|
+
const skills = loadSkills('/nonexistent/dir')
|
|
7
|
+
// May return local skills from CWD if .smolerclaw/skills exists
|
|
8
|
+
// But won't crash
|
|
9
|
+
expect(Array.isArray(skills)).toBe(true)
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
test('loads from existing skills dir', () => {
|
|
13
|
+
const skills = loadSkills('./skills')
|
|
14
|
+
expect(skills.length).toBeGreaterThan(0)
|
|
15
|
+
const names = skills.map((s) => s.name)
|
|
16
|
+
expect(names).toContain('default')
|
|
17
|
+
expect(names).toContain('business')
|
|
18
|
+
expect(skills.every((s) => s.source === 'global')).toBe(true)
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('formatSkillList', () => {
|
|
23
|
+
test('shows no skills message when empty', () => {
|
|
24
|
+
expect(formatSkillList([])).toContain('No skills')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('shows source labels', () => {
|
|
28
|
+
const result = formatSkillList([
|
|
29
|
+
{ name: 'test', content: 'x', source: 'global' },
|
|
30
|
+
{ name: 'local-test', content: 'y', source: 'local' },
|
|
31
|
+
])
|
|
32
|
+
expect(result).toContain('[global]')
|
|
33
|
+
expect(result).toContain('[local]')
|
|
34
|
+
})
|
|
35
|
+
})
|