@gustavobrunodev/ai-tools 1.0.1 → 1.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/CHANGELOG.md +0 -0
- package/jest.config.ts +26 -0
- package/package.json +1 -1
- package/project.json +74 -0
- package/src/app.tsx +56 -0
- package/src/atoms/deprecatedSkills.ts +11 -0
- package/src/atoms/environmentCheck.ts +51 -0
- package/src/atoms/installedSkills.ts +36 -0
- package/src/atoms/wizard.ts +6 -0
- package/src/cli/audit.ts +21 -0
- package/src/cli/cache.ts +28 -0
- package/src/cli/install.ts +93 -0
- package/src/cli/list.ts +41 -0
- package/src/cli/remove.ts +70 -0
- package/src/cli/update.ts +107 -0
- package/src/components/AnimatedTransition.tsx +42 -0
- package/src/components/AuditLogViewer.tsx +85 -0
- package/src/components/CategoryHeader.tsx +39 -0
- package/src/components/ConfirmPrompt.tsx +34 -0
- package/src/components/FooterBar.tsx +36 -0
- package/src/components/Header.tsx +97 -0
- package/src/components/InstallResults.tsx +110 -0
- package/src/components/KeyboardShortcutsOverlay.tsx +112 -0
- package/src/components/MultiSelectPrompt.tsx +219 -0
- package/src/components/SearchInput.tsx +36 -0
- package/src/components/SelectPrompt.tsx +108 -0
- package/src/components/SkillCard.tsx +74 -0
- package/src/components/SkillDetailPanel.tsx +233 -0
- package/src/components/StatusBadge.tsx +45 -0
- package/src/components/__tests__/AnimatedTransition.pbt.test.tsx +51 -0
- package/src/components/__tests__/CategoryHeader.pbt.test.tsx +107 -0
- package/src/components/__tests__/CategoryHeader.test.tsx +105 -0
- package/src/components/__tests__/KeyboardShortcutsOverlay.pbt.test.tsx +155 -0
- package/src/components/__tests__/KeyboardShortcutsOverlay.test.tsx +136 -0
- package/src/components/__tests__/SkillDetailPanel.test.tsx +273 -0
- package/src/components/index.ts +12 -0
- package/src/hooks/__tests__/useConfig.test.ts +242 -0
- package/src/hooks/index.ts +9 -0
- package/src/hooks/useAgents.ts +28 -0
- package/src/hooks/useConfig.ts +114 -0
- package/src/hooks/useFilter.ts +31 -0
- package/src/hooks/useInstaller.ts +39 -0
- package/src/hooks/useKeyboardNav.ts +39 -0
- package/src/hooks/useKonamiCode.ts +48 -0
- package/src/hooks/useRemover.ts +59 -0
- package/src/hooks/useSkillContent.ts +67 -0
- package/src/hooks/useSkills.ts +38 -0
- package/src/hooks/useWizardStep.ts +19 -0
- package/src/index.ts +129 -0
- package/src/services/__tests__/audit-log.spec.ts +220 -0
- package/src/services/__tests__/badge-format.test.ts +102 -0
- package/src/services/__tests__/category-colors.test.ts +253 -0
- package/src/services/__tests__/config.test.ts +184 -0
- package/src/services/__tests__/installer.security.spec.ts +151 -0
- package/src/services/__tests__/lockfile.security.spec.ts +132 -0
- package/src/services/__tests__/markdown-parser.spec.ts +185 -0
- package/src/services/__tests__/terminal-dimensions.pbt.test.ts +246 -0
- package/src/services/__tests__/terminal-dimensions.test.ts +109 -0
- package/src/services/__tests__/update-cache.pbt.test.ts +214 -0
- package/src/services/__tests__/update-cache.test.ts +215 -0
- package/src/services/agents.ts +42 -0
- package/src/services/audio-player.ts +55 -0
- package/src/services/audit-log.ts +69 -0
- package/src/services/badge-format.ts +4 -0
- package/src/services/categories.ts +176 -0
- package/src/services/category-colors.ts +19 -0
- package/src/services/config.ts +84 -0
- package/src/services/github-contributors.ts +56 -0
- package/src/services/global-path.ts +20 -0
- package/src/services/index.ts +21 -0
- package/src/services/installer.ts +371 -0
- package/src/services/lockfile.ts +177 -0
- package/src/services/markdown-parser.ts +108 -0
- package/src/services/package-info.ts +19 -0
- package/src/services/project-root.ts +18 -0
- package/src/services/registry.ts +382 -0
- package/src/services/skills-provider.ts +169 -0
- package/src/services/terminal-dimensions.ts +18 -0
- package/src/services/update-cache.ts +65 -0
- package/src/services/update-check.ts +26 -0
- package/src/theme/colors.ts +24 -0
- package/src/theme/index.ts +2 -0
- package/src/theme/symbols.ts +22 -0
- package/src/types.ts +38 -0
- package/src/utils/constants.ts +49 -0
- package/src/utils/paths.ts +52 -0
- package/src/views/ActionSelector.tsx +45 -0
- package/src/views/AgentSelector.tsx +105 -0
- package/src/views/CreditsView.tsx +332 -0
- package/src/views/InstallConfig.tsx +162 -0
- package/src/views/InstallWizard.tsx +181 -0
- package/src/views/ListView.tsx +41 -0
- package/src/views/RemoveWizard.tsx +237 -0
- package/src/views/SkillBrowser.tsx +504 -0
- package/src/views/UpdateView.tsx +272 -0
- package/src/views/arcade/ArcadeMenu.tsx +89 -0
- package/src/views/arcade/VibeInvaders.tsx +339 -0
- package/src/views/arcade/index.ts +2 -0
- package/src/views/index.ts +11 -0
- package/tsconfig.json +19 -0
- package/tsconfig.spec.json +25 -0
- package/LICENSE +0 -26
- package/README.md +0 -257
- package/index.js +0 -12
- package/index.js.map +0 -7
- /package/{assets → src/assets}/chiptune.mp3 +0 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
import { render } from '@testing-library/react'
|
|
3
|
+
import React from 'react'
|
|
4
|
+
|
|
5
|
+
jest.mock('../../services/category-colors', () => ({
|
|
6
|
+
getColorForCategory: (id: string) => (id === 'web' ? '#3b82f6' : '#64748b'),
|
|
7
|
+
}))
|
|
8
|
+
|
|
9
|
+
jest.mock('../../services/markdown-parser', () => ({
|
|
10
|
+
parseMarkdown: (raw: string) => {
|
|
11
|
+
const lines = raw
|
|
12
|
+
.replace(/^---[\s\S]*?---\n*/m, '')
|
|
13
|
+
.split('\n')
|
|
14
|
+
.filter(Boolean)
|
|
15
|
+
return lines.map((line: string) => {
|
|
16
|
+
const headingMatch = line.match(/^(#{1,3})\s+(.+)$/)
|
|
17
|
+
if (headingMatch) return { type: 'heading', level: headingMatch[1].length, text: headingMatch[2] }
|
|
18
|
+
const listMatch = line.match(/^[-*]\s+(.+)$/)
|
|
19
|
+
if (listMatch) return { type: 'list-item', text: listMatch[1], indent: 0 }
|
|
20
|
+
return { type: 'paragraph', text: line }
|
|
21
|
+
})
|
|
22
|
+
},
|
|
23
|
+
}))
|
|
24
|
+
|
|
25
|
+
jest.mock('chalk', () => {
|
|
26
|
+
const passthrough = (text: string) => text
|
|
27
|
+
const chainableFn = Object.assign(passthrough, {
|
|
28
|
+
bold: passthrough,
|
|
29
|
+
dim: passthrough,
|
|
30
|
+
underline: passthrough,
|
|
31
|
+
italic: passthrough,
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
__esModule: true,
|
|
36
|
+
default: {
|
|
37
|
+
hex: () => chainableFn,
|
|
38
|
+
bold: passthrough,
|
|
39
|
+
dim: passthrough,
|
|
40
|
+
underline: passthrough,
|
|
41
|
+
italic: passthrough,
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
jest.mock('../../theme', () => ({
|
|
47
|
+
colors: {
|
|
48
|
+
primary: '#3b82f6',
|
|
49
|
+
primaryLight: '#60a5fa',
|
|
50
|
+
accent: '#06b6d4',
|
|
51
|
+
text: '#f8fafc',
|
|
52
|
+
textDim: '#94a3b8',
|
|
53
|
+
textMuted: '#64748b',
|
|
54
|
+
border: '#334155',
|
|
55
|
+
bg: '#0f172a',
|
|
56
|
+
bgLight: '#1e293b',
|
|
57
|
+
success: '#22c55e',
|
|
58
|
+
error: '#ef4444',
|
|
59
|
+
},
|
|
60
|
+
symbols: {
|
|
61
|
+
sparkle: '✦',
|
|
62
|
+
diamond: '◆',
|
|
63
|
+
arrow: '›',
|
|
64
|
+
dot: '·',
|
|
65
|
+
bullet: '▸',
|
|
66
|
+
cross: '✗',
|
|
67
|
+
info: 'ℹ',
|
|
68
|
+
arrowDown: '↓',
|
|
69
|
+
arrowUp: '↑',
|
|
70
|
+
bar: '│',
|
|
71
|
+
},
|
|
72
|
+
}))
|
|
73
|
+
|
|
74
|
+
jest.mock('ink', () => ({
|
|
75
|
+
Box: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => {
|
|
76
|
+
const {
|
|
77
|
+
flexDirection,
|
|
78
|
+
width,
|
|
79
|
+
height,
|
|
80
|
+
marginTop,
|
|
81
|
+
marginBottom,
|
|
82
|
+
paddingX,
|
|
83
|
+
paddingY,
|
|
84
|
+
paddingLeft,
|
|
85
|
+
borderStyle,
|
|
86
|
+
borderColor,
|
|
87
|
+
flexGrow,
|
|
88
|
+
flexShrink,
|
|
89
|
+
justifyContent,
|
|
90
|
+
alignItems,
|
|
91
|
+
gap,
|
|
92
|
+
overflow,
|
|
93
|
+
overflowX,
|
|
94
|
+
overflowY,
|
|
95
|
+
minHeight,
|
|
96
|
+
...validProps
|
|
97
|
+
} = props
|
|
98
|
+
void flexDirection
|
|
99
|
+
void width
|
|
100
|
+
void height
|
|
101
|
+
void marginTop
|
|
102
|
+
void marginBottom
|
|
103
|
+
void paddingX
|
|
104
|
+
void paddingY
|
|
105
|
+
void paddingLeft
|
|
106
|
+
void borderStyle
|
|
107
|
+
void borderColor
|
|
108
|
+
void flexGrow
|
|
109
|
+
void flexShrink
|
|
110
|
+
void justifyContent
|
|
111
|
+
void alignItems
|
|
112
|
+
void gap
|
|
113
|
+
void overflow
|
|
114
|
+
void overflowX
|
|
115
|
+
void overflowY
|
|
116
|
+
void minHeight
|
|
117
|
+
return (
|
|
118
|
+
<div data-testid="box" {...validProps}>
|
|
119
|
+
{children}
|
|
120
|
+
</div>
|
|
121
|
+
)
|
|
122
|
+
},
|
|
123
|
+
Text: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => {
|
|
124
|
+
const { bold, underline, dimColor, wrap, backgroundColor, ...validProps } = props
|
|
125
|
+
void bold
|
|
126
|
+
void underline
|
|
127
|
+
void dimColor
|
|
128
|
+
void wrap
|
|
129
|
+
void backgroundColor
|
|
130
|
+
return (
|
|
131
|
+
<span data-testid="text" {...validProps}>
|
|
132
|
+
{children}
|
|
133
|
+
</span>
|
|
134
|
+
)
|
|
135
|
+
},
|
|
136
|
+
useStdout: () => ({ stdout: { rows: 40, columns: 140 } }),
|
|
137
|
+
useInput: jest.fn(),
|
|
138
|
+
}))
|
|
139
|
+
|
|
140
|
+
jest.mock('ink-spinner', () => ({
|
|
141
|
+
__esModule: true,
|
|
142
|
+
default: () => <span data-testid="spinner">⠋</span>,
|
|
143
|
+
}))
|
|
144
|
+
|
|
145
|
+
const mockUseSkillContent = jest.fn()
|
|
146
|
+
jest.mock('../../hooks/useSkillContent', () => ({
|
|
147
|
+
useSkillContent: (...args: unknown[]) => mockUseSkillContent(...args),
|
|
148
|
+
}))
|
|
149
|
+
|
|
150
|
+
import type { SkillInfo } from '../../types'
|
|
151
|
+
import { SkillDetailPanel } from '../SkillDetailPanel'
|
|
152
|
+
|
|
153
|
+
const mockSkill: SkillInfo = {
|
|
154
|
+
name: 'api-designer',
|
|
155
|
+
description: 'Design RESTful APIs with best practices',
|
|
156
|
+
category: 'web',
|
|
157
|
+
path: '/path/to/skill',
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
describe('SkillDetailPanel', () => {
|
|
161
|
+
beforeEach(() => {
|
|
162
|
+
jest.clearAllMocks()
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('renders nothing when skill is null', () => {
|
|
166
|
+
mockUseSkillContent.mockReturnValue({ metadata: null, content: null, loading: false, error: null })
|
|
167
|
+
const { container } = render(<SkillDetailPanel skill={null} onClose={jest.fn()} />)
|
|
168
|
+
expect(container.innerHTML).toBe('')
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('shows loading state', () => {
|
|
172
|
+
mockUseSkillContent.mockReturnValue({ metadata: null, content: null, loading: true, error: null })
|
|
173
|
+
const { container } = render(<SkillDetailPanel skill={mockSkill} onClose={jest.fn()} />)
|
|
174
|
+
expect(container.textContent).toContain('Loading')
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('shows error state', () => {
|
|
178
|
+
mockUseSkillContent.mockReturnValue({ metadata: null, content: null, loading: false, error: 'Network error' })
|
|
179
|
+
const { container } = render(<SkillDetailPanel skill={mockSkill} onClose={jest.fn()} />)
|
|
180
|
+
expect(container.textContent).toContain('Network error')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('displays skill metadata header', () => {
|
|
184
|
+
mockUseSkillContent.mockReturnValue({
|
|
185
|
+
metadata: { name: 'api-designer', author: 'techleads', files: ['SKILL.md', 'README.md'] },
|
|
186
|
+
content: '# API Designer\n\nDesign APIs.',
|
|
187
|
+
loading: false,
|
|
188
|
+
error: null,
|
|
189
|
+
})
|
|
190
|
+
const { container } = render(<SkillDetailPanel skill={mockSkill} onClose={jest.fn()} />)
|
|
191
|
+
expect(container.textContent).toContain('api-designer')
|
|
192
|
+
expect(container.textContent).toContain('web')
|
|
193
|
+
expect(container.textContent).toContain('@techleads')
|
|
194
|
+
expect(container.textContent).toContain('2 files')
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('displays singular file count for 1 file', () => {
|
|
198
|
+
mockUseSkillContent.mockReturnValue({
|
|
199
|
+
metadata: { name: 'api-designer', files: ['SKILL.md'] },
|
|
200
|
+
content: '# Title',
|
|
201
|
+
loading: false,
|
|
202
|
+
error: null,
|
|
203
|
+
})
|
|
204
|
+
const { container } = render(<SkillDetailPanel skill={mockSkill} onClose={jest.fn()} />)
|
|
205
|
+
expect(container.textContent).toContain('1 file')
|
|
206
|
+
expect(container.textContent).not.toContain('1 files')
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('renders parsed markdown content as formatted text', () => {
|
|
210
|
+
mockUseSkillContent.mockReturnValue({
|
|
211
|
+
metadata: { name: 'api-designer', files: ['SKILL.md'] },
|
|
212
|
+
content: '# My Skill\n\nA paragraph here.\n\n- Item one\n- Item two',
|
|
213
|
+
loading: false,
|
|
214
|
+
error: null,
|
|
215
|
+
})
|
|
216
|
+
const { container } = render(<SkillDetailPanel skill={mockSkill} onClose={jest.fn()} />)
|
|
217
|
+
// Content is pre-formatted with chalk into a single string
|
|
218
|
+
expect(container.textContent).toContain('My Skill')
|
|
219
|
+
expect(container.textContent).toContain('A paragraph here.')
|
|
220
|
+
expect(container.textContent).toContain('Item one')
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('shows panel title bar with expand and close hints', () => {
|
|
224
|
+
mockUseSkillContent.mockReturnValue({ metadata: null, content: null, loading: true, error: null })
|
|
225
|
+
const { container } = render(<SkillDetailPanel skill={mockSkill} onClose={jest.fn()} />)
|
|
226
|
+
expect(container.textContent).toContain('Skill Details')
|
|
227
|
+
expect(container.textContent).toContain('f')
|
|
228
|
+
expect(container.textContent).toContain('Esc')
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('hides author when not available', () => {
|
|
232
|
+
mockUseSkillContent.mockReturnValue({
|
|
233
|
+
metadata: { name: 'api-designer', files: ['SKILL.md'] },
|
|
234
|
+
content: '# Title',
|
|
235
|
+
loading: false,
|
|
236
|
+
error: null,
|
|
237
|
+
})
|
|
238
|
+
const { container } = render(<SkillDetailPanel skill={mockSkill} onClose={jest.fn()} />)
|
|
239
|
+
expect(container.textContent).not.toContain('@')
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('displays skill description from props', () => {
|
|
243
|
+
mockUseSkillContent.mockReturnValue({
|
|
244
|
+
metadata: { name: 'api-designer', files: [] },
|
|
245
|
+
content: '# Title',
|
|
246
|
+
loading: false,
|
|
247
|
+
error: null,
|
|
248
|
+
})
|
|
249
|
+
const { container } = render(<SkillDetailPanel skill={mockSkill} onClose={jest.fn()} />)
|
|
250
|
+
expect(container.textContent).toContain('Design RESTful APIs with best practices')
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('always shows name, category, and description when metadata loads', () => {
|
|
254
|
+
const skills: SkillInfo[] = [
|
|
255
|
+
{ name: 'skill-a', description: 'Desc A', category: 'web', path: '' },
|
|
256
|
+
{ name: 'skill-b', description: 'Desc B', category: 'devops', path: '' },
|
|
257
|
+
{ name: 'skill-c', description: 'Desc C', category: 'ai', path: '' },
|
|
258
|
+
]
|
|
259
|
+
for (const skill of skills) {
|
|
260
|
+
mockUseSkillContent.mockReturnValue({
|
|
261
|
+
metadata: { name: skill.name, files: ['SKILL.md'], author: 'author' },
|
|
262
|
+
content: '# Content',
|
|
263
|
+
loading: false,
|
|
264
|
+
error: null,
|
|
265
|
+
})
|
|
266
|
+
const { container, unmount } = render(<SkillDetailPanel skill={skill} onClose={jest.fn()} />)
|
|
267
|
+
expect(container.textContent).toContain(skill.name)
|
|
268
|
+
expect(container.textContent).toContain(skill.category)
|
|
269
|
+
expect(container.textContent).toContain(skill.description)
|
|
270
|
+
unmount()
|
|
271
|
+
}
|
|
272
|
+
})
|
|
273
|
+
})
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export * from './AnimatedTransition'
|
|
2
|
+
export * from './CategoryHeader'
|
|
3
|
+
export * from './ConfirmPrompt'
|
|
4
|
+
export * from './FooterBar'
|
|
5
|
+
export * from './Header'
|
|
6
|
+
export * from './KeyboardShortcutsOverlay'
|
|
7
|
+
export * from './MultiSelectPrompt'
|
|
8
|
+
export * from './SearchInput'
|
|
9
|
+
export * from './SelectPrompt'
|
|
10
|
+
export * from './SkillCard'
|
|
11
|
+
export * from './SkillDetailPanel'
|
|
12
|
+
export * from './StatusBadge'
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { jest } from '@jest/globals'
|
|
5
|
+
import { act, renderHook, waitFor } from '@testing-library/react'
|
|
6
|
+
|
|
7
|
+
const mockMkdir = jest.fn<() => Promise<void>>()
|
|
8
|
+
const mockReadFile = jest.fn<(path: string, encoding: string) => Promise<string>>()
|
|
9
|
+
const mockWriteFile = jest.fn<(path: string, data: string, encoding: string) => Promise<void>>()
|
|
10
|
+
const mockHomedir = jest.fn<() => string>()
|
|
11
|
+
const mockRename = jest.fn<(oldPath: string, newPath: string) => Promise<void>>()
|
|
12
|
+
const mockRm = jest.fn<(path: string, options?: { force?: boolean }) => Promise<void>>()
|
|
13
|
+
|
|
14
|
+
jest.unstable_mockModule('node:fs/promises', () => ({
|
|
15
|
+
mkdir: mockMkdir,
|
|
16
|
+
readFile: mockReadFile,
|
|
17
|
+
writeFile: mockWriteFile,
|
|
18
|
+
rename: mockRename,
|
|
19
|
+
rm: mockRm,
|
|
20
|
+
}))
|
|
21
|
+
|
|
22
|
+
jest.unstable_mockModule('node:os', () => ({ homedir: mockHomedir }))
|
|
23
|
+
|
|
24
|
+
const { useConfig } = await import('../useConfig')
|
|
25
|
+
|
|
26
|
+
describe('useConfig hook', () => {
|
|
27
|
+
const mockHome = '/home/testuser'
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
jest.clearAllMocks()
|
|
31
|
+
mockHomedir.mockReturnValue(mockHome)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe('initial load', () => {
|
|
35
|
+
it('should load config on mount', async () => {
|
|
36
|
+
const config = { firstLaunchComplete: true, shortcutsOverlayDismissed: false, version: '1.0.0' }
|
|
37
|
+
mockReadFile.mockResolvedValue(JSON.stringify(config))
|
|
38
|
+
const { result } = renderHook(() => useConfig())
|
|
39
|
+
expect(result.current.loading).toBe(true)
|
|
40
|
+
await waitFor(() => {
|
|
41
|
+
expect(result.current.loading).toBe(false)
|
|
42
|
+
})
|
|
43
|
+
expect(result.current.config).toEqual(config)
|
|
44
|
+
expect(result.current.error).toBeNull()
|
|
45
|
+
expect(result.current.isFirstLaunch).toBe(false)
|
|
46
|
+
expect(result.current.hasShortcutsBeenDismissed).toBe(false)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('should handle first launch state', async () => {
|
|
50
|
+
const config = { firstLaunchComplete: false, shortcutsOverlayDismissed: false, version: '1.0.0' }
|
|
51
|
+
mockReadFile.mockResolvedValue(JSON.stringify(config))
|
|
52
|
+
const { result } = renderHook(() => useConfig())
|
|
53
|
+
await waitFor(() => {
|
|
54
|
+
expect(result.current.loading).toBe(false)
|
|
55
|
+
})
|
|
56
|
+
expect(result.current.isFirstLaunch).toBe(true)
|
|
57
|
+
expect(result.current.hasShortcutsBeenDismissed).toBe(false)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('should handle shortcuts dismissed state', async () => {
|
|
61
|
+
const config = { firstLaunchComplete: true, shortcutsOverlayDismissed: true, version: '1.0.0' }
|
|
62
|
+
mockReadFile.mockResolvedValue(JSON.stringify(config))
|
|
63
|
+
const { result } = renderHook(() => useConfig())
|
|
64
|
+
await waitFor(() => {
|
|
65
|
+
expect(result.current.loading).toBe(false)
|
|
66
|
+
})
|
|
67
|
+
expect(result.current.isFirstLaunch).toBe(false)
|
|
68
|
+
expect(result.current.hasShortcutsBeenDismissed).toBe(true)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('should handle load errors', async () => {
|
|
72
|
+
mockReadFile.mockRejectedValue(new Error('Permission denied'))
|
|
73
|
+
const { result } = renderHook(() => useConfig())
|
|
74
|
+
await waitFor(() => {
|
|
75
|
+
expect(result.current.loading).toBe(false)
|
|
76
|
+
})
|
|
77
|
+
expect(result.current.config).toEqual({
|
|
78
|
+
firstLaunchComplete: false,
|
|
79
|
+
shortcutsOverlayDismissed: false,
|
|
80
|
+
version: '1.0.0',
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('should use default config when file does not exist', async () => {
|
|
85
|
+
mockReadFile.mockRejectedValue(new Error('ENOENT: no such file'))
|
|
86
|
+
const { result } = renderHook(() => useConfig())
|
|
87
|
+
await waitFor(() => {
|
|
88
|
+
expect(result.current.loading).toBe(false)
|
|
89
|
+
})
|
|
90
|
+
expect(result.current.config).toEqual({
|
|
91
|
+
firstLaunchComplete: false,
|
|
92
|
+
shortcutsOverlayDismissed: false,
|
|
93
|
+
version: '1.0.0',
|
|
94
|
+
})
|
|
95
|
+
expect(result.current.isFirstLaunch).toBe(true)
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe('markFirstLaunchComplete', () => {
|
|
100
|
+
it('should update first launch state', async () => {
|
|
101
|
+
const initialConfig = { firstLaunchComplete: false, shortcutsOverlayDismissed: false, version: '1.0.0' }
|
|
102
|
+
const updatedConfig = { ...initialConfig, firstLaunchComplete: true }
|
|
103
|
+
mockReadFile
|
|
104
|
+
.mockResolvedValueOnce(JSON.stringify(initialConfig))
|
|
105
|
+
.mockResolvedValueOnce(JSON.stringify(initialConfig))
|
|
106
|
+
.mockResolvedValueOnce(JSON.stringify(initialConfig))
|
|
107
|
+
.mockResolvedValueOnce(JSON.stringify(initialConfig))
|
|
108
|
+
.mockResolvedValueOnce(JSON.stringify(updatedConfig))
|
|
109
|
+
mockMkdir.mockResolvedValue(undefined)
|
|
110
|
+
mockWriteFile.mockResolvedValue(undefined)
|
|
111
|
+
const { result } = renderHook(() => useConfig())
|
|
112
|
+
await waitFor(() => {
|
|
113
|
+
expect(result.current.loading).toBe(false)
|
|
114
|
+
})
|
|
115
|
+
expect(result.current.isFirstLaunch).toBe(true)
|
|
116
|
+
await act(async () => {
|
|
117
|
+
await result.current.markFirstLaunchComplete()
|
|
118
|
+
})
|
|
119
|
+
await waitFor(() => {
|
|
120
|
+
expect(result.current.isFirstLaunch).toBe(false)
|
|
121
|
+
})
|
|
122
|
+
expect(result.current.config?.firstLaunchComplete).toBe(true)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('should handle errors when marking first launch complete', async () => {
|
|
126
|
+
const config = { firstLaunchComplete: false, shortcutsOverlayDismissed: false, version: '1.0.0' }
|
|
127
|
+
mockReadFile.mockResolvedValue(JSON.stringify(config))
|
|
128
|
+
mockMkdir.mockResolvedValue(undefined)
|
|
129
|
+
mockWriteFile.mockRejectedValue(new Error('Write failed'))
|
|
130
|
+
const { result } = renderHook(() => useConfig())
|
|
131
|
+
await waitFor(() => {
|
|
132
|
+
expect(result.current.loading).toBe(false)
|
|
133
|
+
})
|
|
134
|
+
await act(async () => {
|
|
135
|
+
await expect(result.current.markFirstLaunchComplete()).rejects.toThrow('Write failed')
|
|
136
|
+
})
|
|
137
|
+
expect(result.current.error).toBe('Write failed')
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
describe('markShortcutsDismissed', () => {
|
|
142
|
+
it('should update shortcuts dismissed state', async () => {
|
|
143
|
+
const initialConfig = { firstLaunchComplete: false, shortcutsOverlayDismissed: false, version: '1.0.0' }
|
|
144
|
+
const updatedConfig = { ...initialConfig, shortcutsOverlayDismissed: true }
|
|
145
|
+
mockReadFile
|
|
146
|
+
.mockResolvedValueOnce(JSON.stringify(initialConfig))
|
|
147
|
+
.mockResolvedValueOnce(JSON.stringify(initialConfig))
|
|
148
|
+
.mockResolvedValueOnce(JSON.stringify(initialConfig))
|
|
149
|
+
.mockResolvedValueOnce(JSON.stringify(initialConfig))
|
|
150
|
+
.mockResolvedValueOnce(JSON.stringify(updatedConfig))
|
|
151
|
+
mockMkdir.mockResolvedValue(undefined)
|
|
152
|
+
mockWriteFile.mockResolvedValue(undefined)
|
|
153
|
+
const { result } = renderHook(() => useConfig())
|
|
154
|
+
await waitFor(() => {
|
|
155
|
+
expect(result.current.loading).toBe(false)
|
|
156
|
+
})
|
|
157
|
+
expect(result.current.hasShortcutsBeenDismissed).toBe(false)
|
|
158
|
+
await act(async () => {
|
|
159
|
+
await result.current.markShortcutsDismissed()
|
|
160
|
+
})
|
|
161
|
+
await waitFor(() => {
|
|
162
|
+
expect(result.current.hasShortcutsBeenDismissed).toBe(true)
|
|
163
|
+
})
|
|
164
|
+
expect(result.current.config?.shortcutsOverlayDismissed).toBe(true)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('should handle errors when marking shortcuts dismissed', async () => {
|
|
168
|
+
const config = { firstLaunchComplete: false, shortcutsOverlayDismissed: false, version: '1.0.0' }
|
|
169
|
+
mockReadFile.mockResolvedValue(JSON.stringify(config))
|
|
170
|
+
mockMkdir.mockResolvedValue(undefined)
|
|
171
|
+
mockWriteFile.mockRejectedValue(new Error('Write failed'))
|
|
172
|
+
const { result } = renderHook(() => useConfig())
|
|
173
|
+
await waitFor(() => {
|
|
174
|
+
expect(result.current.loading).toBe(false)
|
|
175
|
+
})
|
|
176
|
+
await act(async () => {
|
|
177
|
+
await expect(result.current.markShortcutsDismissed()).rejects.toThrow('Write failed')
|
|
178
|
+
})
|
|
179
|
+
expect(result.current.error).toBe('Write failed')
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
describe('updateConfig', () => {
|
|
184
|
+
it('should update config with partial updates', async () => {
|
|
185
|
+
const initialConfig = { firstLaunchComplete: false, shortcutsOverlayDismissed: false, version: '1.0.0' }
|
|
186
|
+
const updatedConfig = { ...initialConfig, firstLaunchComplete: true, shortcutsOverlayDismissed: true }
|
|
187
|
+
mockReadFile
|
|
188
|
+
.mockResolvedValueOnce(JSON.stringify(initialConfig))
|
|
189
|
+
.mockResolvedValueOnce(JSON.stringify(initialConfig))
|
|
190
|
+
.mockResolvedValueOnce(JSON.stringify(initialConfig))
|
|
191
|
+
.mockResolvedValueOnce(JSON.stringify(initialConfig))
|
|
192
|
+
.mockResolvedValueOnce(JSON.stringify(updatedConfig))
|
|
193
|
+
mockMkdir.mockResolvedValue(undefined)
|
|
194
|
+
mockWriteFile.mockResolvedValue(undefined)
|
|
195
|
+
const { result } = renderHook(() => useConfig())
|
|
196
|
+
await waitFor(() => {
|
|
197
|
+
expect(result.current.loading).toBe(false)
|
|
198
|
+
})
|
|
199
|
+
await act(async () => {
|
|
200
|
+
await result.current.updateConfig({ firstLaunchComplete: true, shortcutsOverlayDismissed: true })
|
|
201
|
+
})
|
|
202
|
+
await waitFor(() => {
|
|
203
|
+
expect(result.current.config).toEqual(updatedConfig)
|
|
204
|
+
})
|
|
205
|
+
expect(result.current.isFirstLaunch).toBe(false)
|
|
206
|
+
expect(result.current.hasShortcutsBeenDismissed).toBe(true)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('should handle errors when updating config', async () => {
|
|
210
|
+
const config = { firstLaunchComplete: false, shortcutsOverlayDismissed: false, version: '1.0.0' }
|
|
211
|
+
mockReadFile.mockResolvedValue(JSON.stringify(config))
|
|
212
|
+
mockMkdir.mockResolvedValue(undefined)
|
|
213
|
+
mockWriteFile.mockRejectedValue(new Error('Write failed'))
|
|
214
|
+
const { result } = renderHook(() => useConfig())
|
|
215
|
+
await waitFor(() => {
|
|
216
|
+
expect(result.current.loading).toBe(false)
|
|
217
|
+
})
|
|
218
|
+
await act(async () => {
|
|
219
|
+
await expect(result.current.updateConfig({ firstLaunchComplete: true })).rejects.toThrow('Write failed')
|
|
220
|
+
})
|
|
221
|
+
expect(result.current.error).toBe('Write failed')
|
|
222
|
+
})
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
describe('cleanup', () => {
|
|
226
|
+
it('should not update state after unmount', async () => {
|
|
227
|
+
const config = { firstLaunchComplete: false, shortcutsOverlayDismissed: false, version: '1.0.0' }
|
|
228
|
+
mockReadFile.mockImplementation(
|
|
229
|
+
() =>
|
|
230
|
+
new Promise((resolve) => {
|
|
231
|
+
setTimeout(() => resolve(JSON.stringify(config)), 100)
|
|
232
|
+
}),
|
|
233
|
+
)
|
|
234
|
+
const { result, unmount } = renderHook(() => useConfig())
|
|
235
|
+
expect(result.current.loading).toBe(true)
|
|
236
|
+
unmount()
|
|
237
|
+
await new Promise((resolve) => setTimeout(resolve, 150))
|
|
238
|
+
expect(result.current.loading).toBe(true)
|
|
239
|
+
expect(result.current.config).toBeNull()
|
|
240
|
+
})
|
|
241
|
+
})
|
|
242
|
+
})
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from './useAgents'
|
|
2
|
+
export * from './useConfig'
|
|
3
|
+
export * from './useFilter'
|
|
4
|
+
export * from './useInstaller'
|
|
5
|
+
export * from './useKonamiCode'
|
|
6
|
+
export * from './useRemover'
|
|
7
|
+
export * from './useSkillContent'
|
|
8
|
+
export * from './useSkills'
|
|
9
|
+
export * from './useWizardStep'
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
import { detectInstalledAgents, getAllAgentTypes } from '../services/agents'
|
|
4
|
+
import type { AgentType } from '../types'
|
|
5
|
+
|
|
6
|
+
export function useAgents() {
|
|
7
|
+
const [selectedAgents, setSelectedAgents] = useState<AgentType[]>([])
|
|
8
|
+
const [installedAgents, setInstalledAgents] = useState<AgentType[]>([])
|
|
9
|
+
const [loading, setLoading] = useState(true)
|
|
10
|
+
const allAgents = useMemo(() => getAllAgentTypes(), [])
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
const timer = setTimeout(() => {
|
|
14
|
+
const detected = detectInstalledAgents()
|
|
15
|
+
setInstalledAgents(detected)
|
|
16
|
+
setSelectedAgents(detected)
|
|
17
|
+
setLoading(false)
|
|
18
|
+
}, 800)
|
|
19
|
+
|
|
20
|
+
return () => clearTimeout(timer)
|
|
21
|
+
}, [])
|
|
22
|
+
|
|
23
|
+
const toggleAgent = (agent: AgentType) => {
|
|
24
|
+
setSelectedAgents((prev) => (prev.includes(agent) ? prev.filter((a) => a !== agent) : [...prev, agent]))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return { allAgents, installedAgents, selectedAgents, setSelectedAgents, toggleAgent, loading }
|
|
28
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { UserConfig } from '../services/config'
|
|
4
|
+
import {
|
|
5
|
+
hasShortcutsBeenDismissed,
|
|
6
|
+
isFirstLaunch,
|
|
7
|
+
loadConfig,
|
|
8
|
+
markFirstLaunchComplete,
|
|
9
|
+
markShortcutsDismissed,
|
|
10
|
+
saveConfig,
|
|
11
|
+
} from '../services/config'
|
|
12
|
+
|
|
13
|
+
interface UseConfigReturn {
|
|
14
|
+
config: UserConfig | null
|
|
15
|
+
loading: boolean
|
|
16
|
+
error: string | null
|
|
17
|
+
isFirstLaunch: boolean
|
|
18
|
+
hasShortcutsBeenDismissed: boolean
|
|
19
|
+
markFirstLaunchComplete: () => Promise<void>
|
|
20
|
+
markShortcutsDismissed: () => Promise<void>
|
|
21
|
+
updateConfig: (updates: Partial<UserConfig>) => Promise<void>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function useConfig(): UseConfigReturn {
|
|
25
|
+
const [config, setConfig] = useState<UserConfig | null>(null)
|
|
26
|
+
const [loading, setLoading] = useState(true)
|
|
27
|
+
const [error, setError] = useState<string | null>(null)
|
|
28
|
+
const [isFirstLaunchState, setIsFirstLaunchState] = useState(false)
|
|
29
|
+
const [hasShortcutsDismissedState, setHasShortcutsDismissedState] = useState(false)
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
let mounted = true
|
|
33
|
+
|
|
34
|
+
const load = async () => {
|
|
35
|
+
try {
|
|
36
|
+
const [configData, firstLaunch, shortcutsDismissed] = await Promise.all([
|
|
37
|
+
loadConfig(),
|
|
38
|
+
isFirstLaunch(),
|
|
39
|
+
hasShortcutsBeenDismissed(),
|
|
40
|
+
])
|
|
41
|
+
|
|
42
|
+
if (mounted) {
|
|
43
|
+
setConfig(configData)
|
|
44
|
+
setIsFirstLaunchState(firstLaunch)
|
|
45
|
+
setHasShortcutsDismissedState(shortcutsDismissed)
|
|
46
|
+
}
|
|
47
|
+
} catch (err: unknown) {
|
|
48
|
+
if (mounted) setError(err instanceof Error ? err.message : String(err))
|
|
49
|
+
} finally {
|
|
50
|
+
if (mounted) setLoading(false)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
load()
|
|
55
|
+
|
|
56
|
+
return () => {
|
|
57
|
+
mounted = false
|
|
58
|
+
}
|
|
59
|
+
}, [])
|
|
60
|
+
|
|
61
|
+
const handleMarkFirstLaunchComplete = async () => {
|
|
62
|
+
try {
|
|
63
|
+
await markFirstLaunchComplete()
|
|
64
|
+
setIsFirstLaunchState(false)
|
|
65
|
+
const updatedConfig = await loadConfig()
|
|
66
|
+
setConfig(updatedConfig)
|
|
67
|
+
} catch (err: unknown) {
|
|
68
|
+
setError(err instanceof Error ? err.message : String(err))
|
|
69
|
+
throw err
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const handleMarkShortcutsDismissed = async () => {
|
|
74
|
+
try {
|
|
75
|
+
await markShortcutsDismissed()
|
|
76
|
+
setHasShortcutsDismissedState(true)
|
|
77
|
+
const updatedConfig = await loadConfig()
|
|
78
|
+
setConfig(updatedConfig)
|
|
79
|
+
} catch (err: unknown) {
|
|
80
|
+
setError(err instanceof Error ? err.message : String(err))
|
|
81
|
+
throw err
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const handleUpdateConfig = async (updates: Partial<UserConfig>) => {
|
|
86
|
+
try {
|
|
87
|
+
await saveConfig(updates)
|
|
88
|
+
const updatedConfig = await loadConfig()
|
|
89
|
+
setConfig(updatedConfig)
|
|
90
|
+
|
|
91
|
+
if ('firstLaunchComplete' in updates) {
|
|
92
|
+
setIsFirstLaunchState(!updates.firstLaunchComplete)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if ('shortcutsOverlayDismissed' in updates) {
|
|
96
|
+
setHasShortcutsDismissedState(Boolean(updates.shortcutsOverlayDismissed))
|
|
97
|
+
}
|
|
98
|
+
} catch (err: unknown) {
|
|
99
|
+
setError(err instanceof Error ? err.message : String(err))
|
|
100
|
+
throw err
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
config,
|
|
106
|
+
loading,
|
|
107
|
+
error,
|
|
108
|
+
isFirstLaunch: isFirstLaunchState,
|
|
109
|
+
hasShortcutsBeenDismissed: hasShortcutsDismissedState,
|
|
110
|
+
markFirstLaunchComplete: handleMarkFirstLaunchComplete,
|
|
111
|
+
markShortcutsDismissed: handleMarkShortcutsDismissed,
|
|
112
|
+
updateConfig: handleUpdateConfig,
|
|
113
|
+
}
|
|
114
|
+
}
|