@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,272 @@
|
|
|
1
|
+
import { Box, Text, useInput } from 'ink'
|
|
2
|
+
import Spinner from 'ink-spinner'
|
|
3
|
+
import { useAtomValue } from 'jotai'
|
|
4
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
5
|
+
|
|
6
|
+
import { deprecatedSkillsAtom } from '../atoms/deprecatedSkills'
|
|
7
|
+
import { installedSkillsAtom } from '../atoms/installedSkills'
|
|
8
|
+
import { Header } from '../components/Header'
|
|
9
|
+
import { InstallResults } from '../components/InstallResults'
|
|
10
|
+
import { MultiSelectPrompt } from '../components/MultiSelectPrompt'
|
|
11
|
+
import { useInstaller } from '../hooks/useInstaller'
|
|
12
|
+
import { useSkills } from '../hooks/useSkills'
|
|
13
|
+
import { getUpdatableSkills } from '../services/registry'
|
|
14
|
+
import { colors, symbols } from '../theme'
|
|
15
|
+
import type { AgentType, DeprecatedEntry, SkillInfo } from '../types'
|
|
16
|
+
import { AgentSelector } from './AgentSelector'
|
|
17
|
+
|
|
18
|
+
export function UpdateView({ selectedAgents, onExit }: { selectedAgents?: AgentType[]; onExit: () => void }) {
|
|
19
|
+
const [checkingUpdates, setCheckingUpdates] = useState(false)
|
|
20
|
+
const [updateCheckComplete, setUpdateCheckComplete] = useState(false)
|
|
21
|
+
const [installComplete, setInstallComplete] = useState(false)
|
|
22
|
+
const [internalAgents, setInternalAgents] = useState<AgentType[]>(selectedAgents || [])
|
|
23
|
+
const [showAgentSelect, setShowAgentSelect] = useState(!selectedAgents)
|
|
24
|
+
const [updatableSkills, setUpdatableSkills] = useState<SkillInfo[]>([])
|
|
25
|
+
const { install, progress, results, installing } = useInstaller()
|
|
26
|
+
const installedSkills = useAtomValue(installedSkillsAtom)
|
|
27
|
+
const deprecatedMap = useAtomValue(deprecatedSkillsAtom)
|
|
28
|
+
const { skills, loading: loadingSkills } = useSkills()
|
|
29
|
+
|
|
30
|
+
const activeAgents = selectedAgents || internalAgents
|
|
31
|
+
|
|
32
|
+
const installedList = useMemo(() => {
|
|
33
|
+
if (loadingSkills) return []
|
|
34
|
+
const installedNames = new Set(Object.keys(installedSkills))
|
|
35
|
+
|
|
36
|
+
return skills.filter((s) => {
|
|
37
|
+
if (!installedNames.has(s.name)) return false
|
|
38
|
+
const agents = installedSkills[s.name] || []
|
|
39
|
+
return agents.some((a: AgentType) => activeAgents.includes(a))
|
|
40
|
+
})
|
|
41
|
+
}, [installedSkills, skills, loadingSkills, activeAgents])
|
|
42
|
+
|
|
43
|
+
const deprecatedInstalled = useMemo(() => {
|
|
44
|
+
if (loadingSkills || !(deprecatedMap instanceof Map) || deprecatedMap.size === 0) return []
|
|
45
|
+
const installedNames = new Set(Object.keys(installedSkills))
|
|
46
|
+
const registryNames = new Set(skills.map((s) => s.name))
|
|
47
|
+
|
|
48
|
+
const result: { name: string; entry?: DeprecatedEntry }[] = []
|
|
49
|
+
|
|
50
|
+
for (const name of installedNames) {
|
|
51
|
+
if (deprecatedMap.has(name)) result.push({ name, entry: deprecatedMap.get(name) })
|
|
52
|
+
else if (!registryNames.has(name)) result.push({ name })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return result
|
|
56
|
+
}, [installedSkills, skills, loadingSkills, deprecatedMap])
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (installedList.length === 0) {
|
|
60
|
+
setCheckingUpdates(false)
|
|
61
|
+
setUpdateCheckComplete(true)
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
setCheckingUpdates(true)
|
|
66
|
+
const checkUpdates = async () => {
|
|
67
|
+
const installedNames = installedList.map((s) => s.name)
|
|
68
|
+
const { toUpdate } = await getUpdatableSkills(installedNames)
|
|
69
|
+
const skillsToUpdate = installedList.filter((s) => toUpdate.includes(s.name))
|
|
70
|
+
setUpdatableSkills(skillsToUpdate)
|
|
71
|
+
setCheckingUpdates(false)
|
|
72
|
+
setUpdateCheckComplete(true)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
checkUpdates()
|
|
76
|
+
}, [installedList])
|
|
77
|
+
|
|
78
|
+
useInput((_, key) => {
|
|
79
|
+
if (
|
|
80
|
+
key.escape &&
|
|
81
|
+
!installing &&
|
|
82
|
+
!installComplete &&
|
|
83
|
+
!checkingUpdates &&
|
|
84
|
+
updateCheckComplete &&
|
|
85
|
+
updatableSkills.length === 0
|
|
86
|
+
) {
|
|
87
|
+
onExit()
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
if (showAgentSelect) {
|
|
92
|
+
return (
|
|
93
|
+
<AgentSelector
|
|
94
|
+
onSelect={(agents) => {
|
|
95
|
+
setInternalAgents(agents)
|
|
96
|
+
setShowAgentSelect(false)
|
|
97
|
+
}}
|
|
98
|
+
onBack={onExit}
|
|
99
|
+
/>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const handleUpdate = async (selectedSkills: SkillInfo[]) => {
|
|
104
|
+
if (selectedSkills.length === 0) return
|
|
105
|
+
|
|
106
|
+
const involvedAgents = new Set<AgentType>()
|
|
107
|
+
selectedSkills.forEach((s) => {
|
|
108
|
+
const agents = installedSkills[s.name] || []
|
|
109
|
+
agents.forEach((a: AgentType) => {
|
|
110
|
+
if (activeAgents.includes(a)) involvedAgents.add(a)
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
await install(selectedSkills, {
|
|
115
|
+
agents: Array.from(involvedAgents),
|
|
116
|
+
method: 'copy',
|
|
117
|
+
global: false,
|
|
118
|
+
skills: selectedSkills.map((s) => s.name),
|
|
119
|
+
isUpdate: true,
|
|
120
|
+
})
|
|
121
|
+
setInstallComplete(true)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (installComplete) {
|
|
125
|
+
return (
|
|
126
|
+
<InstallResults results={results} onExit={onExit} title="Skills Updated Successfully" successLabel="updated" />
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (installing) {
|
|
131
|
+
return (
|
|
132
|
+
<Box flexDirection="column" paddingX={1}>
|
|
133
|
+
<Header />
|
|
134
|
+
<Box marginTop={1}>
|
|
135
|
+
<Text color={colors.accent}>
|
|
136
|
+
<Spinner type="dots" />{' '}
|
|
137
|
+
</Text>
|
|
138
|
+
<Text>Updating skills...</Text>
|
|
139
|
+
</Box>
|
|
140
|
+
<Box marginTop={1} paddingX={2}>
|
|
141
|
+
<Text color={colors.textDim}>
|
|
142
|
+
{symbols.arrow} {progress.skill} ({progress.current}/{progress.total})
|
|
143
|
+
</Text>
|
|
144
|
+
</Box>
|
|
145
|
+
</Box>
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (loadingSkills || checkingUpdates) {
|
|
150
|
+
return (
|
|
151
|
+
<Box flexDirection="column" paddingX={1}>
|
|
152
|
+
<Header />
|
|
153
|
+
<Box marginTop={1}>
|
|
154
|
+
<Text color={colors.accent}>
|
|
155
|
+
<Spinner type="dots" /> {checkingUpdates ? 'Checking for updates...' : 'Loading...'}
|
|
156
|
+
</Text>
|
|
157
|
+
</Box>
|
|
158
|
+
</Box>
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (updateCheckComplete && updatableSkills.length === 0) {
|
|
163
|
+
return (
|
|
164
|
+
<Box flexDirection="column" paddingX={1}>
|
|
165
|
+
<Header />
|
|
166
|
+
<Box borderStyle="round" borderColor={colors.success} paddingX={2} paddingY={1}>
|
|
167
|
+
<Text color={colors.success}>{symbols.check} All installed skills are up to date!</Text>
|
|
168
|
+
</Box>
|
|
169
|
+
|
|
170
|
+
{deprecatedInstalled.length > 0 && (
|
|
171
|
+
<Box
|
|
172
|
+
flexDirection="column"
|
|
173
|
+
marginTop={1}
|
|
174
|
+
borderStyle="round"
|
|
175
|
+
borderColor={colors.warning}
|
|
176
|
+
paddingX={2}
|
|
177
|
+
paddingY={1}
|
|
178
|
+
>
|
|
179
|
+
<Box marginBottom={1}>
|
|
180
|
+
<Text color={colors.warning} bold>
|
|
181
|
+
{symbols.warning} {deprecatedInstalled.length} deprecated skill
|
|
182
|
+
{deprecatedInstalled.length > 1 ? 's' : ''} detected:
|
|
183
|
+
</Text>
|
|
184
|
+
</Box>
|
|
185
|
+
{deprecatedInstalled.map((d) => (
|
|
186
|
+
<Box key={d.name} flexDirection="column" paddingX={1} marginBottom={1}>
|
|
187
|
+
<Text color={colors.warning}>
|
|
188
|
+
{symbols.arrow} {d.name}
|
|
189
|
+
</Text>
|
|
190
|
+
{d.entry?.message && <Text color={colors.textDim}> {d.entry.message}</Text>}
|
|
191
|
+
{!d.entry && <Text color={colors.textDim}> No longer available in the registry</Text>}
|
|
192
|
+
{d.entry?.alternatives && d.entry.alternatives.length > 0 && (
|
|
193
|
+
<Text color={colors.textDim}>
|
|
194
|
+
{' '}Try: ai-tools install --skill {d.entry.alternatives.join(', ')}
|
|
195
|
+
</Text>
|
|
196
|
+
)}
|
|
197
|
+
</Box>
|
|
198
|
+
))}
|
|
199
|
+
<Text color={colors.textMuted}>Run: ai-tools remove --skill {'<name>'} to clean up</Text>
|
|
200
|
+
</Box>
|
|
201
|
+
)}
|
|
202
|
+
|
|
203
|
+
<Box marginTop={1} borderStyle="round" borderColor={colors.border} paddingX={1}>
|
|
204
|
+
<Text>
|
|
205
|
+
<Text color={colors.warning} bold>
|
|
206
|
+
esc
|
|
207
|
+
</Text>
|
|
208
|
+
<Text color={colors.textDim}> exit</Text>
|
|
209
|
+
</Text>
|
|
210
|
+
</Box>
|
|
211
|
+
</Box>
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
<UpdateSelector
|
|
217
|
+
skills={updatableSkills}
|
|
218
|
+
installedSkills={installedSkills}
|
|
219
|
+
selectedAgents={activeAgents}
|
|
220
|
+
onUpdate={handleUpdate}
|
|
221
|
+
onExit={onExit}
|
|
222
|
+
/>
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function UpdateSelector({
|
|
227
|
+
skills,
|
|
228
|
+
installedSkills,
|
|
229
|
+
selectedAgents,
|
|
230
|
+
onUpdate,
|
|
231
|
+
onExit,
|
|
232
|
+
}: {
|
|
233
|
+
skills: SkillInfo[]
|
|
234
|
+
installedSkills: Record<string, AgentType[]>
|
|
235
|
+
selectedAgents: AgentType[]
|
|
236
|
+
onUpdate: (skills: SkillInfo[]) => void
|
|
237
|
+
onExit: () => void
|
|
238
|
+
}) {
|
|
239
|
+
const items = skills.map((s) => {
|
|
240
|
+
const allAgents = installedSkills[s.name] || []
|
|
241
|
+
const filteredAgents = allAgents.filter((a) => selectedAgents.includes(a))
|
|
242
|
+
return {
|
|
243
|
+
label: s.name,
|
|
244
|
+
value: s.name,
|
|
245
|
+
hint: `${filteredAgents.length} agent${filteredAgents.length > 1 ? 's' : ''}: ${filteredAgents.join(', ')}`,
|
|
246
|
+
}
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
const allValues = skills.map((s) => s.name)
|
|
250
|
+
|
|
251
|
+
const handleSubmit = (selectedNames: string[]) => {
|
|
252
|
+
const selectedSkills = skills.filter((s) => selectedNames.includes(s.name))
|
|
253
|
+
onUpdate(selectedSkills)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return (
|
|
257
|
+
<Box flexDirection="column" paddingX={1}>
|
|
258
|
+
<Header />
|
|
259
|
+
<Box marginBottom={1}>
|
|
260
|
+
<Text bold color={colors.primary}>
|
|
261
|
+
{symbols.diamond} Select skills to update:
|
|
262
|
+
</Text>
|
|
263
|
+
</Box>
|
|
264
|
+
<Box marginBottom={1}>
|
|
265
|
+
<Text color={colors.textDim}>
|
|
266
|
+
{skills.length} installed skill{skills.length > 1 ? 's' : ''} found {symbols.dot} all pre-selected
|
|
267
|
+
</Text>
|
|
268
|
+
</Box>
|
|
269
|
+
<MultiSelectPrompt items={items} initialSelected={allValues} onSubmit={handleSubmit} onCancel={onExit} />
|
|
270
|
+
</Box>
|
|
271
|
+
)
|
|
272
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { Box, Text } from 'ink'
|
|
2
|
+
import BigText from 'ink-big-text'
|
|
3
|
+
import Gradient from 'ink-gradient'
|
|
4
|
+
import { useEffect, useState } from 'react'
|
|
5
|
+
|
|
6
|
+
import { FooterBar } from '../../components/FooterBar'
|
|
7
|
+
import { SelectPrompt } from '../../components/SelectPrompt'
|
|
8
|
+
import { colors, symbols } from '../../theme'
|
|
9
|
+
import { VibeInvaders } from './VibeInvaders'
|
|
10
|
+
|
|
11
|
+
type ArcadeScreen = 'menu' | 'invaders'
|
|
12
|
+
|
|
13
|
+
interface ArcadeMenuProps {
|
|
14
|
+
onExit: () => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const menuItems = [
|
|
18
|
+
{ label: 'Vibe Invaders', value: 'invaders' as const, hint: 'Fight vibe-coding!' },
|
|
19
|
+
{ label: 'Back', value: 'back' as const, hint: 'Return to CLI' },
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
const SCANLINE = '\u2591'.repeat(56)
|
|
23
|
+
|
|
24
|
+
export function ArcadeMenu({ onExit }: ArcadeMenuProps) {
|
|
25
|
+
const [screen, setScreen] = useState<ArcadeScreen>('menu')
|
|
26
|
+
const [blinkVisible, setBlinkVisible] = useState(true)
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
const interval = setInterval(() => setBlinkVisible((v) => !v), 600)
|
|
30
|
+
return () => clearInterval(interval)
|
|
31
|
+
}, [])
|
|
32
|
+
|
|
33
|
+
const handleSelect = (value: 'invaders' | 'back') => {
|
|
34
|
+
if (value === 'back') {
|
|
35
|
+
onExit()
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
setScreen(value)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (screen === 'invaders') return <VibeInvaders onExit={() => setScreen('menu')} />
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<Box flexDirection="column" alignItems="center">
|
|
46
|
+
<Box marginBottom={0}>
|
|
47
|
+
<Gradient name="cristal">
|
|
48
|
+
<Text>{SCANLINE}</Text>
|
|
49
|
+
</Gradient>
|
|
50
|
+
</Box>
|
|
51
|
+
|
|
52
|
+
<Box marginBottom={0}>
|
|
53
|
+
<Gradient name="pastel">
|
|
54
|
+
<BigText text="ARCADE" font="chrome" />
|
|
55
|
+
</Gradient>
|
|
56
|
+
</Box>
|
|
57
|
+
|
|
58
|
+
<Box marginBottom={0}>
|
|
59
|
+
<Gradient name="cristal">
|
|
60
|
+
<Text>{SCANLINE}</Text>
|
|
61
|
+
</Gradient>
|
|
62
|
+
</Box>
|
|
63
|
+
|
|
64
|
+
<Box marginBottom={1} marginTop={1}>
|
|
65
|
+
<Text color={blinkVisible ? colors.warning : colors.bg} bold>
|
|
66
|
+
{symbols.sparkle} SECRET UNLOCKED {symbols.sparkle}
|
|
67
|
+
</Text>
|
|
68
|
+
</Box>
|
|
69
|
+
|
|
70
|
+
<Box marginBottom={1}>
|
|
71
|
+
<Text color={colors.textDim}>
|
|
72
|
+
{symbols.diamond} Choose your adventure {symbols.diamond}
|
|
73
|
+
</Text>
|
|
74
|
+
</Box>
|
|
75
|
+
|
|
76
|
+
<Box width={50}>
|
|
77
|
+
<SelectPrompt items={menuItems} onSelect={handleSelect} onCancel={onExit} hideFooter />
|
|
78
|
+
</Box>
|
|
79
|
+
|
|
80
|
+
<FooterBar
|
|
81
|
+
hints={[
|
|
82
|
+
{ key: '\u2191\u2193', label: 'navigate' },
|
|
83
|
+
{ key: '\u23CE', label: 'select' },
|
|
84
|
+
{ key: 'esc', label: 'back', color: colors.warning },
|
|
85
|
+
]}
|
|
86
|
+
/>
|
|
87
|
+
</Box>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { Box, Text, useInput, useStdout } from 'ink'
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
3
|
+
|
|
4
|
+
import { colors } from '../../theme'
|
|
5
|
+
|
|
6
|
+
const MAX_WIDTH = 100
|
|
7
|
+
const GAME_HEIGHT = 16
|
|
8
|
+
const TICK_MS = 50
|
|
9
|
+
const BASE_MOVE_EVERY = 10
|
|
10
|
+
const RATE_LIMIT_PENALTY = 30
|
|
11
|
+
const SNIPER_MULTIPLIER = 5
|
|
12
|
+
|
|
13
|
+
const LOSE_MESSAGES = [
|
|
14
|
+
'BANKRUPT. Your burn rate was too high.',
|
|
15
|
+
'DOWN ROUND. Valuation dropped to zero.',
|
|
16
|
+
'RUNWAY EXPIRED. Back to living with parents.',
|
|
17
|
+
'SERVER COSTS > REVENUE. You are cooked.',
|
|
18
|
+
'AUDIT FAILED. Too much tech debt.',
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
const WIN_MESSAGES = [
|
|
22
|
+
'ACQUIRED BY BIG TECH. Golden handcuffs on.',
|
|
23
|
+
'SERIES B SECURED. Keep burning cash!',
|
|
24
|
+
'IPO SUCCESSFUL. Time to buy a yacht.',
|
|
25
|
+
'PROFITABLE? No, but the vibes are great.',
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
const INVADER_ROWS: string[][] = [
|
|
29
|
+
['AGI_SOON', '100x_DEV', 'LOVABLE', 'HYPE'],
|
|
30
|
+
['DEEPSEEK', 'GEMINI', 'GPT', 'V0_DEV'],
|
|
31
|
+
['TAB_SPAM', 'NO_READ', 'TRUST_ME', 'YOLO'],
|
|
32
|
+
['SLOP', 'SPAGHETTI', 'ANY_TYPE', 'BUG'],
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
interface Position {
|
|
36
|
+
x: number
|
|
37
|
+
y: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface Invader extends Position {
|
|
41
|
+
label: string
|
|
42
|
+
alive: boolean
|
|
43
|
+
width: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface GameState {
|
|
47
|
+
player: Position
|
|
48
|
+
playerBullets: Position[]
|
|
49
|
+
enemyBullets: Position[]
|
|
50
|
+
invaders: Invader[]
|
|
51
|
+
score: number
|
|
52
|
+
lives: number
|
|
53
|
+
gameOver: boolean
|
|
54
|
+
won: boolean
|
|
55
|
+
invaderDirection: 1 | -1
|
|
56
|
+
tickCount: number
|
|
57
|
+
flash: boolean
|
|
58
|
+
glitch: boolean
|
|
59
|
+
rateLimited: number
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function createInvaders(cols: number): Invader[] {
|
|
63
|
+
const invaders: Invader[] = []
|
|
64
|
+
const longestWord = Math.max(...INVADER_ROWS.flat().map((w) => w.length))
|
|
65
|
+
const colSpacing = longestWord + 4
|
|
66
|
+
const totalWidth = INVADER_ROWS[0].length * colSpacing
|
|
67
|
+
const startX = Math.floor((cols - totalWidth) / 2)
|
|
68
|
+
|
|
69
|
+
for (let row = 0; row < INVADER_ROWS.length; row++) {
|
|
70
|
+
for (let col = 0; col < INVADER_ROWS[row].length; col++) {
|
|
71
|
+
const label = INVADER_ROWS[row][col]
|
|
72
|
+
invaders.push({
|
|
73
|
+
x: Math.max(0, startX + col * colSpacing),
|
|
74
|
+
y: 1 + row * 2,
|
|
75
|
+
label,
|
|
76
|
+
width: label.length,
|
|
77
|
+
alive: true,
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return invaders
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface VibeInvadersProps {
|
|
86
|
+
onExit: () => void
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function VibeInvaders({ onExit }: VibeInvadersProps) {
|
|
90
|
+
const { stdout } = useStdout()
|
|
91
|
+
|
|
92
|
+
const terminalCols = stdout?.columns ?? 80
|
|
93
|
+
const gameWidth = Math.max(60, Math.min(terminalCols - 4, MAX_WIDTH))
|
|
94
|
+
const finalMessageRef = useRef<string>('')
|
|
95
|
+
|
|
96
|
+
const [state, setState] = useState<GameState>(() => ({
|
|
97
|
+
player: { x: Math.floor(gameWidth / 2), y: GAME_HEIGHT - 1 },
|
|
98
|
+
playerBullets: [],
|
|
99
|
+
enemyBullets: [],
|
|
100
|
+
invaders: createInvaders(gameWidth),
|
|
101
|
+
score: 0,
|
|
102
|
+
lives: 5,
|
|
103
|
+
gameOver: false,
|
|
104
|
+
won: false,
|
|
105
|
+
invaderDirection: 1,
|
|
106
|
+
tickCount: 0,
|
|
107
|
+
flash: false,
|
|
108
|
+
glitch: false,
|
|
109
|
+
rateLimited: 0,
|
|
110
|
+
}))
|
|
111
|
+
|
|
112
|
+
if ((state.gameOver || state.won) && !finalMessageRef.current) {
|
|
113
|
+
const pool = state.won ? WIN_MESSAGES : LOSE_MESSAGES
|
|
114
|
+
finalMessageRef.current = pool[Math.floor(Math.random() * pool.length)]
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
useInput((input, key) => {
|
|
118
|
+
if (state.gameOver || state.won) {
|
|
119
|
+
if (key.return || key.escape) onExit()
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (key.escape) {
|
|
124
|
+
onExit()
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
setState((prev) => {
|
|
129
|
+
let newX = prev.player.x
|
|
130
|
+
if (key.leftArrow) newX = Math.max(0, prev.player.x - 2)
|
|
131
|
+
if (key.rightArrow) newX = Math.min(gameWidth - 1, prev.player.x + 2)
|
|
132
|
+
|
|
133
|
+
let newBullets = prev.playerBullets
|
|
134
|
+
let currentRateLimit = prev.rateLimited
|
|
135
|
+
|
|
136
|
+
if (input === ' ' && currentRateLimit === 0) {
|
|
137
|
+
if (prev.playerBullets.length >= 2) {
|
|
138
|
+
currentRateLimit = RATE_LIMIT_PENALTY
|
|
139
|
+
newBullets = []
|
|
140
|
+
} else {
|
|
141
|
+
newBullets = [...prev.playerBullets, { x: newX, y: prev.player.y - 1 }]
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return { ...prev, player: { ...prev.player, x: newX }, playerBullets: newBullets, rateLimited: currentRateLimit }
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
const tick = useCallback(() => {
|
|
150
|
+
setState((prev) => {
|
|
151
|
+
if (prev.gameOver || prev.won) return prev
|
|
152
|
+
|
|
153
|
+
const tick = prev.tickCount + 1
|
|
154
|
+
let score = prev.score
|
|
155
|
+
let lives = prev.lives
|
|
156
|
+
let gameOver: boolean = prev.gameOver
|
|
157
|
+
let flash = false
|
|
158
|
+
let glitch = false
|
|
159
|
+
const rateLimited = prev.rateLimited > 0 ? prev.rateLimited - 1 : 0
|
|
160
|
+
|
|
161
|
+
// Logic
|
|
162
|
+
const aliveInvaders = prev.invaders.filter((i) => i.alive)
|
|
163
|
+
const totalInvaders = INVADER_ROWS.flat().length
|
|
164
|
+
const survivalRatio = aliveInvaders.length / totalInvaders
|
|
165
|
+
const moveEvery = Math.max(2, Math.floor(BASE_MOVE_EVERY * survivalRatio) + 1)
|
|
166
|
+
const shootChance = 0.02 + 0.08 * (1 - survivalRatio)
|
|
167
|
+
|
|
168
|
+
// Bullets
|
|
169
|
+
let pBullets = prev.playerBullets.map((b) => ({ ...b, y: b.y - 1 })).filter((b) => b.y >= 0)
|
|
170
|
+
let eBullets = prev.enemyBullets.map((b) => ({ ...b, y: b.y + 1 })).filter((b) => b.y < GAME_HEIGHT)
|
|
171
|
+
|
|
172
|
+
const invaders = prev.invaders.map((i) => ({ ...i }))
|
|
173
|
+
|
|
174
|
+
// Collisions: Player -> Invader
|
|
175
|
+
for (const b of pBullets) {
|
|
176
|
+
if (b.y === -1) continue
|
|
177
|
+
for (const inv of invaders) {
|
|
178
|
+
if (!inv.alive) continue
|
|
179
|
+
if (b.y === inv.y && b.x >= inv.x && b.x < inv.x + inv.width) {
|
|
180
|
+
inv.alive = false
|
|
181
|
+
b.y = -1
|
|
182
|
+
score += 100
|
|
183
|
+
flash = true
|
|
184
|
+
break
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
pBullets = pBullets.filter((b) => b.y !== -1)
|
|
190
|
+
|
|
191
|
+
// Collisions: Enemy -> Player
|
|
192
|
+
if (eBullets.some((b) => b.x === prev.player.x && b.y === prev.player.y)) {
|
|
193
|
+
lives -= 1
|
|
194
|
+
flash = true
|
|
195
|
+
glitch = true
|
|
196
|
+
eBullets = []
|
|
197
|
+
if (lives <= 0) gameOver = true
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Win?
|
|
201
|
+
if (invaders.every((i) => !i.alive)) return { ...prev, won: true, score: score + lives * 1000, invaders }
|
|
202
|
+
|
|
203
|
+
// Shoot
|
|
204
|
+
if (aliveInvaders.length > 0) {
|
|
205
|
+
const shooters = aliveInvaders.filter((inv) => {
|
|
206
|
+
const inSight = prev.player.x >= inv.x - 1 && prev.player.x <= inv.x + inv.width + 1
|
|
207
|
+
return Math.random() < (inSight ? shootChance * SNIPER_MULTIPLIER : shootChance)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
if (shooters.length > 0) {
|
|
211
|
+
const s = shooters[Math.floor(Math.random() * shooters.length)]
|
|
212
|
+
eBullets.push({ x: s.x + Math.floor(s.width / 2), y: s.y + 1 })
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Move
|
|
217
|
+
let dir = prev.invaderDirection
|
|
218
|
+
|
|
219
|
+
if (tick % moveEvery === 0) {
|
|
220
|
+
const xs = invaders.filter((i) => i.alive).map((i) => i.x)
|
|
221
|
+
const minX = Math.min(...xs)
|
|
222
|
+
const maxX = Math.max(...invaders.filter((i) => i.alive).map((i) => i.x + i.width))
|
|
223
|
+
if (maxX >= gameWidth - 2 && dir === 1) dir = -1
|
|
224
|
+
if (minX <= 0 && dir === -1) dir = 1
|
|
225
|
+
invaders.forEach((i) => i.alive && (i.x += dir))
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Drop
|
|
229
|
+
if (tick % 45 === 0) {
|
|
230
|
+
invaders.forEach((i) => i.alive && (i.y += 1))
|
|
231
|
+
if (invaders.some((i) => i.alive && i.y >= GAME_HEIGHT - 1)) gameOver = true
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
...prev,
|
|
236
|
+
playerBullets: pBullets,
|
|
237
|
+
enemyBullets: eBullets,
|
|
238
|
+
invaders,
|
|
239
|
+
score,
|
|
240
|
+
lives,
|
|
241
|
+
gameOver,
|
|
242
|
+
invaderDirection: dir,
|
|
243
|
+
tickCount: tick,
|
|
244
|
+
flash,
|
|
245
|
+
glitch,
|
|
246
|
+
rateLimited,
|
|
247
|
+
}
|
|
248
|
+
})
|
|
249
|
+
}, [gameWidth])
|
|
250
|
+
|
|
251
|
+
useEffect(() => {
|
|
252
|
+
const t = setInterval(tick, TICK_MS)
|
|
253
|
+
return () => clearInterval(t)
|
|
254
|
+
}, [tick])
|
|
255
|
+
|
|
256
|
+
const renderGrid = () => {
|
|
257
|
+
const rows = Array.from({ length: GAME_HEIGHT }, () => Array(gameWidth).fill(' '))
|
|
258
|
+
|
|
259
|
+
state.invaders.forEach((inv) => {
|
|
260
|
+
if (inv.alive) {
|
|
261
|
+
for (let i = 0; i < inv.width; i++) {
|
|
262
|
+
if (inv.x + i < gameWidth && inv.y < GAME_HEIGHT) rows[inv.y][inv.x + i] = state.glitch ? '?' : inv.label[i]
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
state.playerBullets.forEach((b) => {
|
|
268
|
+
if (b.x < gameWidth && b.y < GAME_HEIGHT) rows[b.y][b.x] = '$'
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
state.enemyBullets.forEach((b) => {
|
|
272
|
+
if (b.x < gameWidth && b.y < GAME_HEIGHT) rows[b.y][b.x] = '*'
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
const { x, y } = state.player
|
|
276
|
+
if (x < gameWidth && y < GAME_HEIGHT) rows[y][x] = state.rateLimited > 0 ? 'X' : '^'
|
|
277
|
+
return rows.map((r) => r.join('')).join('\n')
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const statusColor = state.rateLimited > 0 ? colors.error : colors.accent
|
|
281
|
+
const gridColor = state.glitch ? colors.warning : state.flash ? colors.error : colors.success
|
|
282
|
+
|
|
283
|
+
return (
|
|
284
|
+
<Box width="100%" alignItems="center" flexDirection="column">
|
|
285
|
+
<Box width={gameWidth + 4} flexDirection="column" alignItems="center">
|
|
286
|
+
<Box width={gameWidth} flexDirection="row" paddingX={1}>
|
|
287
|
+
<Box width="35%">
|
|
288
|
+
<Text color={statusColor} bold>
|
|
289
|
+
{state.rateLimited > 0 ? `LIMIT (${state.rateLimited})` : `$${state.score}k`}
|
|
290
|
+
</Text>
|
|
291
|
+
</Box>
|
|
292
|
+
<Box width="30%" justifyContent="center">
|
|
293
|
+
<Text color={colors.warning} bold>
|
|
294
|
+
VIBE INVADERS
|
|
295
|
+
</Text>
|
|
296
|
+
</Box>
|
|
297
|
+
<Box width="35%" justifyContent="flex-end">
|
|
298
|
+
<Text color={colors.success} bold>
|
|
299
|
+
RUNWAY: {'$'.repeat(state.lives)}
|
|
300
|
+
</Text>
|
|
301
|
+
</Box>
|
|
302
|
+
</Box>
|
|
303
|
+
|
|
304
|
+
<Box
|
|
305
|
+
borderStyle="round"
|
|
306
|
+
borderColor={state.rateLimited > 0 ? colors.error : colors.border}
|
|
307
|
+
flexDirection="column"
|
|
308
|
+
width={gameWidth + 2}
|
|
309
|
+
height={GAME_HEIGHT + 2}
|
|
310
|
+
>
|
|
311
|
+
<Text color={state.gameOver ? colors.error : gridColor}>{renderGrid()}</Text>
|
|
312
|
+
</Box>
|
|
313
|
+
|
|
314
|
+
<Box
|
|
315
|
+
marginTop={0}
|
|
316
|
+
width={gameWidth + 2}
|
|
317
|
+
justifyContent="center"
|
|
318
|
+
borderStyle="round"
|
|
319
|
+
borderColor={colors.accent}
|
|
320
|
+
>
|
|
321
|
+
{state.gameOver || state.won ? (
|
|
322
|
+
<Text color={state.won ? colors.success : colors.error} bold>
|
|
323
|
+
{finalMessageRef.current} Val: ${state.score}k
|
|
324
|
+
</Text>
|
|
325
|
+
) : (
|
|
326
|
+
<Box gap={1}>
|
|
327
|
+
<Text color={colors.accent}>←→</Text>
|
|
328
|
+
<Text> move </Text>
|
|
329
|
+
<Text color={colors.accent}>spc</Text>
|
|
330
|
+
<Text> shoot </Text>
|
|
331
|
+
<Text color={colors.accent}>esc</Text>
|
|
332
|
+
<Text> quit</Text>
|
|
333
|
+
</Box>
|
|
334
|
+
)}
|
|
335
|
+
</Box>
|
|
336
|
+
</Box>
|
|
337
|
+
</Box>
|
|
338
|
+
)
|
|
339
|
+
}
|