@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.
Files changed (106) hide show
  1. package/CHANGELOG.md +0 -0
  2. package/jest.config.ts +26 -0
  3. package/package.json +1 -1
  4. package/project.json +74 -0
  5. package/src/app.tsx +56 -0
  6. package/src/atoms/deprecatedSkills.ts +11 -0
  7. package/src/atoms/environmentCheck.ts +51 -0
  8. package/src/atoms/installedSkills.ts +36 -0
  9. package/src/atoms/wizard.ts +6 -0
  10. package/src/cli/audit.ts +21 -0
  11. package/src/cli/cache.ts +28 -0
  12. package/src/cli/install.ts +93 -0
  13. package/src/cli/list.ts +41 -0
  14. package/src/cli/remove.ts +70 -0
  15. package/src/cli/update.ts +107 -0
  16. package/src/components/AnimatedTransition.tsx +42 -0
  17. package/src/components/AuditLogViewer.tsx +85 -0
  18. package/src/components/CategoryHeader.tsx +39 -0
  19. package/src/components/ConfirmPrompt.tsx +34 -0
  20. package/src/components/FooterBar.tsx +36 -0
  21. package/src/components/Header.tsx +97 -0
  22. package/src/components/InstallResults.tsx +110 -0
  23. package/src/components/KeyboardShortcutsOverlay.tsx +112 -0
  24. package/src/components/MultiSelectPrompt.tsx +219 -0
  25. package/src/components/SearchInput.tsx +36 -0
  26. package/src/components/SelectPrompt.tsx +108 -0
  27. package/src/components/SkillCard.tsx +74 -0
  28. package/src/components/SkillDetailPanel.tsx +233 -0
  29. package/src/components/StatusBadge.tsx +45 -0
  30. package/src/components/__tests__/AnimatedTransition.pbt.test.tsx +51 -0
  31. package/src/components/__tests__/CategoryHeader.pbt.test.tsx +107 -0
  32. package/src/components/__tests__/CategoryHeader.test.tsx +105 -0
  33. package/src/components/__tests__/KeyboardShortcutsOverlay.pbt.test.tsx +155 -0
  34. package/src/components/__tests__/KeyboardShortcutsOverlay.test.tsx +136 -0
  35. package/src/components/__tests__/SkillDetailPanel.test.tsx +273 -0
  36. package/src/components/index.ts +12 -0
  37. package/src/hooks/__tests__/useConfig.test.ts +242 -0
  38. package/src/hooks/index.ts +9 -0
  39. package/src/hooks/useAgents.ts +28 -0
  40. package/src/hooks/useConfig.ts +114 -0
  41. package/src/hooks/useFilter.ts +31 -0
  42. package/src/hooks/useInstaller.ts +39 -0
  43. package/src/hooks/useKeyboardNav.ts +39 -0
  44. package/src/hooks/useKonamiCode.ts +48 -0
  45. package/src/hooks/useRemover.ts +59 -0
  46. package/src/hooks/useSkillContent.ts +67 -0
  47. package/src/hooks/useSkills.ts +38 -0
  48. package/src/hooks/useWizardStep.ts +19 -0
  49. package/src/index.ts +129 -0
  50. package/src/services/__tests__/audit-log.spec.ts +220 -0
  51. package/src/services/__tests__/badge-format.test.ts +102 -0
  52. package/src/services/__tests__/category-colors.test.ts +253 -0
  53. package/src/services/__tests__/config.test.ts +184 -0
  54. package/src/services/__tests__/installer.security.spec.ts +151 -0
  55. package/src/services/__tests__/lockfile.security.spec.ts +132 -0
  56. package/src/services/__tests__/markdown-parser.spec.ts +185 -0
  57. package/src/services/__tests__/terminal-dimensions.pbt.test.ts +246 -0
  58. package/src/services/__tests__/terminal-dimensions.test.ts +109 -0
  59. package/src/services/__tests__/update-cache.pbt.test.ts +214 -0
  60. package/src/services/__tests__/update-cache.test.ts +215 -0
  61. package/src/services/agents.ts +42 -0
  62. package/src/services/audio-player.ts +55 -0
  63. package/src/services/audit-log.ts +69 -0
  64. package/src/services/badge-format.ts +4 -0
  65. package/src/services/categories.ts +176 -0
  66. package/src/services/category-colors.ts +19 -0
  67. package/src/services/config.ts +84 -0
  68. package/src/services/github-contributors.ts +56 -0
  69. package/src/services/global-path.ts +20 -0
  70. package/src/services/index.ts +21 -0
  71. package/src/services/installer.ts +371 -0
  72. package/src/services/lockfile.ts +177 -0
  73. package/src/services/markdown-parser.ts +108 -0
  74. package/src/services/package-info.ts +19 -0
  75. package/src/services/project-root.ts +18 -0
  76. package/src/services/registry.ts +382 -0
  77. package/src/services/skills-provider.ts +169 -0
  78. package/src/services/terminal-dimensions.ts +18 -0
  79. package/src/services/update-cache.ts +65 -0
  80. package/src/services/update-check.ts +26 -0
  81. package/src/theme/colors.ts +24 -0
  82. package/src/theme/index.ts +2 -0
  83. package/src/theme/symbols.ts +22 -0
  84. package/src/types.ts +38 -0
  85. package/src/utils/constants.ts +49 -0
  86. package/src/utils/paths.ts +52 -0
  87. package/src/views/ActionSelector.tsx +45 -0
  88. package/src/views/AgentSelector.tsx +105 -0
  89. package/src/views/CreditsView.tsx +332 -0
  90. package/src/views/InstallConfig.tsx +162 -0
  91. package/src/views/InstallWizard.tsx +181 -0
  92. package/src/views/ListView.tsx +41 -0
  93. package/src/views/RemoveWizard.tsx +237 -0
  94. package/src/views/SkillBrowser.tsx +504 -0
  95. package/src/views/UpdateView.tsx +272 -0
  96. package/src/views/arcade/ArcadeMenu.tsx +89 -0
  97. package/src/views/arcade/VibeInvaders.tsx +339 -0
  98. package/src/views/arcade/index.ts +2 -0
  99. package/src/views/index.ts +11 -0
  100. package/tsconfig.json +19 -0
  101. package/tsconfig.spec.json +25 -0
  102. package/LICENSE +0 -26
  103. package/README.md +0 -257
  104. package/index.js +0 -12
  105. package/index.js.map +0 -7
  106. /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
+ }