@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,31 @@
|
|
|
1
|
+
import { useMemo, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
interface FilterOptions<T> {
|
|
4
|
+
keys: (keyof T & string)[]
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function useFilter<T>(items: T[], options: FilterOptions<T>) {
|
|
8
|
+
const [query, setQuery] = useState('')
|
|
9
|
+
|
|
10
|
+
const filtered = useMemo(() => {
|
|
11
|
+
if (!query.trim()) return items
|
|
12
|
+
|
|
13
|
+
const tokens = query
|
|
14
|
+
.toLowerCase()
|
|
15
|
+
.split(/\s+/)
|
|
16
|
+
.filter((t) => t.length > 0)
|
|
17
|
+
|
|
18
|
+
return items.filter((item) => {
|
|
19
|
+
const searchable = options.keys
|
|
20
|
+
.map((key) => {
|
|
21
|
+
const value = item[key]
|
|
22
|
+
return typeof value === 'string' ? value.toLowerCase() : ''
|
|
23
|
+
})
|
|
24
|
+
.join(' ')
|
|
25
|
+
|
|
26
|
+
return tokens.every((token) => searchable.includes(token))
|
|
27
|
+
})
|
|
28
|
+
}, [query, items, options.keys])
|
|
29
|
+
|
|
30
|
+
return { query, setQuery, filtered, hasFilter: query.trim().length > 0 }
|
|
31
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
|
|
3
|
+
import { installSkills } from '../services/installer'
|
|
4
|
+
import { getSkillWithPath } from '../services/skills-provider'
|
|
5
|
+
import type { InstallOptions, InstallResult, SkillInfo } from '../types'
|
|
6
|
+
|
|
7
|
+
export function useInstaller() {
|
|
8
|
+
const [progress, setProgress] = useState({ current: 0, total: 0, skill: '' })
|
|
9
|
+
const [results, setResults] = useState<InstallResult[]>([])
|
|
10
|
+
const [installing, setInstalling] = useState(false)
|
|
11
|
+
const [error, setError] = useState<string | null>(null)
|
|
12
|
+
|
|
13
|
+
const install = async (skills: SkillInfo[], options: InstallOptions) => {
|
|
14
|
+
setInstalling(true)
|
|
15
|
+
setError(null)
|
|
16
|
+
setProgress({ current: 0, total: skills.length * options.agents.length, skill: 'Downloading...' })
|
|
17
|
+
|
|
18
|
+
const resolvedSkills: SkillInfo[] = []
|
|
19
|
+
for (const skill of skills) {
|
|
20
|
+
const resolved = skill.path ? skill : await getSkillWithPath(skill.name)
|
|
21
|
+
if (resolved) resolvedSkills.push(resolved)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
setProgress({ current: 0, total: resolvedSkills.length * options.agents.length, skill: 'Installing...' })
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const res = await installSkills(resolvedSkills, options)
|
|
28
|
+
setResults(res)
|
|
29
|
+
return res
|
|
30
|
+
} catch (err: unknown) {
|
|
31
|
+
setError(err instanceof Error ? err.message : String(err))
|
|
32
|
+
return []
|
|
33
|
+
} finally {
|
|
34
|
+
setInstalling(false)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return { install, progress, results, installing, error }
|
|
39
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useInput } from 'ink'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
|
|
4
|
+
export interface KeyNavOptions {
|
|
5
|
+
cols?: number
|
|
6
|
+
isGrid?: boolean
|
|
7
|
+
loop?: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function useKeyboardNav(itemCount: number, options: KeyNavOptions = {}) {
|
|
11
|
+
const [selectedIndex, setSelectedIndex] = useState(0)
|
|
12
|
+
const { cols = 1, isGrid = false, loop = true } = options
|
|
13
|
+
|
|
14
|
+
useInput((_input, key) => {
|
|
15
|
+
const keyHandlers: Record<string, () => void> = {
|
|
16
|
+
upArrow: () =>
|
|
17
|
+
setSelectedIndex((prev) => {
|
|
18
|
+
if (prev - cols < 0) return loop ? itemCount - 1 : prev
|
|
19
|
+
return prev - cols
|
|
20
|
+
}),
|
|
21
|
+
downArrow: () =>
|
|
22
|
+
setSelectedIndex((prev) => {
|
|
23
|
+
if (prev + cols >= itemCount) return loop ? 0 : prev
|
|
24
|
+
return prev + cols
|
|
25
|
+
}),
|
|
26
|
+
leftArrow: () => isGrid && setSelectedIndex((prev) => (prev > 0 ? prev - 1 : loop ? itemCount - 1 : prev)),
|
|
27
|
+
rightArrow: () => isGrid && setSelectedIndex((prev) => (prev < itemCount - 1 ? prev + 1 : loop ? 0 : prev)),
|
|
28
|
+
pageUp: () => setSelectedIndex((prev) => Math.max(0, prev - 10)),
|
|
29
|
+
pageDown: () => setSelectedIndex((prev) => Math.min(itemCount - 1, prev + 10)),
|
|
30
|
+
home: () => setSelectedIndex(0),
|
|
31
|
+
end: () => setSelectedIndex(itemCount - 1),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const pressedKey = Object.keys(key).find((k) => key[k as keyof typeof key])
|
|
35
|
+
if (pressedKey && keyHandlers[pressedKey]) keyHandlers[pressedKey]()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
return { selectedIndex, setSelectedIndex }
|
|
39
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { useInput } from 'ink'
|
|
2
|
+
import { useCallback, useRef, useState } from 'react'
|
|
3
|
+
|
|
4
|
+
const KONAMI_SEQUENCE = ['up', 'up', 'down', 'down', 'left', 'right', 'left', 'right', 'b', 'a'] as const
|
|
5
|
+
|
|
6
|
+
type KonamiKey = (typeof KONAMI_SEQUENCE)[number]
|
|
7
|
+
|
|
8
|
+
export function useKonamiCode() {
|
|
9
|
+
const [activated, setActivated] = useState(false)
|
|
10
|
+
const bufferRef = useRef<KonamiKey[]>([])
|
|
11
|
+
|
|
12
|
+
useInput((input, key) => {
|
|
13
|
+
if (activated) return
|
|
14
|
+
let mapped: KonamiKey | null = null
|
|
15
|
+
if (key.upArrow) mapped = 'up'
|
|
16
|
+
else if (key.downArrow) mapped = 'down'
|
|
17
|
+
else if (key.leftArrow) mapped = 'left'
|
|
18
|
+
else if (key.rightArrow) mapped = 'right'
|
|
19
|
+
else if (input.toLowerCase() === 'b') mapped = 'b'
|
|
20
|
+
else if (input.toLowerCase() === 'a') mapped = 'a'
|
|
21
|
+
|
|
22
|
+
if (!mapped) {
|
|
23
|
+
bufferRef.current = []
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
bufferRef.current.push(mapped)
|
|
28
|
+
|
|
29
|
+
if (bufferRef.current.length > KONAMI_SEQUENCE.length) {
|
|
30
|
+
bufferRef.current = bufferRef.current.slice(-KONAMI_SEQUENCE.length)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (
|
|
34
|
+
bufferRef.current.length === KONAMI_SEQUENCE.length &&
|
|
35
|
+
bufferRef.current.every((k, i) => k === KONAMI_SEQUENCE[i])
|
|
36
|
+
) {
|
|
37
|
+
setActivated(true)
|
|
38
|
+
bufferRef.current = []
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const reset = useCallback(() => {
|
|
43
|
+
setActivated(false)
|
|
44
|
+
bufferRef.current = []
|
|
45
|
+
}, [])
|
|
46
|
+
|
|
47
|
+
return { activated, reset }
|
|
48
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
|
|
3
|
+
import { removeSkill } from '../services/installer'
|
|
4
|
+
import type { AgentType } from '../types'
|
|
5
|
+
|
|
6
|
+
export interface RemoveResult {
|
|
7
|
+
skill: string
|
|
8
|
+
agent: string
|
|
9
|
+
success: boolean
|
|
10
|
+
error?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function useRemover() {
|
|
14
|
+
const [progress, setProgress] = useState({ current: 0, total: 0, skill: '' })
|
|
15
|
+
const [results, setResults] = useState<RemoveResult[]>([])
|
|
16
|
+
const [removing, setRemoving] = useState(false)
|
|
17
|
+
const [error, setError] = useState<string | null>(null)
|
|
18
|
+
|
|
19
|
+
const remove = async (skillName: string, agents: AgentType[], global = false) => {
|
|
20
|
+
setRemoving(true)
|
|
21
|
+
setProgress({ current: 0, total: agents.length, skill: skillName })
|
|
22
|
+
setError(null)
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const res = await removeSkill(skillName, agents, { global })
|
|
26
|
+
setResults((prev) => [...prev, ...res])
|
|
27
|
+
return res
|
|
28
|
+
} catch (err: unknown) {
|
|
29
|
+
setError(err instanceof Error ? err.message : String(err))
|
|
30
|
+
return []
|
|
31
|
+
} finally {
|
|
32
|
+
setRemoving(false)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const removeMultiple = async (skillsToRemove: { name: string; agents: AgentType[] }[]) => {
|
|
37
|
+
setRemoving(true)
|
|
38
|
+
const totalOps = skillsToRemove.reduce((acc, item) => acc + item.agents.length, 0)
|
|
39
|
+
setProgress({ current: 0, total: totalOps, skill: 'Initializing...' })
|
|
40
|
+
setResults([])
|
|
41
|
+
setError(null)
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
let completedOps = 0
|
|
45
|
+
for (const item of skillsToRemove) {
|
|
46
|
+
setProgress({ current: completedOps, total: totalOps, skill: item.name })
|
|
47
|
+
const res = await removeSkill(item.name, item.agents, {})
|
|
48
|
+
setResults((prev) => [...prev, ...res])
|
|
49
|
+
completedOps += item.agents.length
|
|
50
|
+
}
|
|
51
|
+
} catch (err: unknown) {
|
|
52
|
+
setError(err instanceof Error ? err.message : String(err))
|
|
53
|
+
} finally {
|
|
54
|
+
setRemoving(false)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { remove, removeMultiple, progress, results, removing, error }
|
|
59
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
import { ensureSkillDownloaded, getSkillCachePath, getSkillMetadata, type SkillMetadata } from '../services/registry'
|
|
6
|
+
|
|
7
|
+
export interface SkillContent {
|
|
8
|
+
metadata: SkillMetadata | null
|
|
9
|
+
content: string | null
|
|
10
|
+
loading: boolean
|
|
11
|
+
error: string | null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function useSkillContent(skillName: string | null): SkillContent {
|
|
15
|
+
const [metadata, setMetadata] = useState<SkillMetadata | null>(null)
|
|
16
|
+
const [content, setContent] = useState<string | null>(null)
|
|
17
|
+
const [loading, setLoading] = useState(false)
|
|
18
|
+
const [error, setError] = useState<string | null>(null)
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (!skillName) {
|
|
22
|
+
setMetadata(null)
|
|
23
|
+
setContent(null)
|
|
24
|
+
setLoading(false)
|
|
25
|
+
setError(null)
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let mounted = true
|
|
30
|
+
setLoading(true)
|
|
31
|
+
setError(null)
|
|
32
|
+
|
|
33
|
+
const load = async () => {
|
|
34
|
+
try {
|
|
35
|
+
const [meta, cachePath] = await Promise.all([
|
|
36
|
+
getSkillMetadata(skillName).catch(() => null),
|
|
37
|
+
ensureSkillDownloaded(skillName).catch(() => null),
|
|
38
|
+
])
|
|
39
|
+
|
|
40
|
+
if (!mounted) return
|
|
41
|
+
if (meta) setMetadata(meta)
|
|
42
|
+
|
|
43
|
+
const resolvedPath = cachePath ?? getSkillCachePath(skillName)
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const skillMd = readFileSync(join(resolvedPath, 'SKILL.md'), 'utf-8')
|
|
47
|
+
setContent(skillMd)
|
|
48
|
+
} catch {
|
|
49
|
+
setError('Failed to load skill content')
|
|
50
|
+
}
|
|
51
|
+
} catch (err: unknown) {
|
|
52
|
+
if (mounted) {
|
|
53
|
+
setError(err instanceof Error ? err.message : String(err))
|
|
54
|
+
}
|
|
55
|
+
} finally {
|
|
56
|
+
if (mounted) setLoading(false)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
load()
|
|
61
|
+
return () => {
|
|
62
|
+
mounted = false
|
|
63
|
+
}
|
|
64
|
+
}, [skillName])
|
|
65
|
+
|
|
66
|
+
return { metadata, content, loading, error }
|
|
67
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
import { groupSkillsByCategory } from '../services/categories'
|
|
4
|
+
import { discoverSkillsAsync } from '../services/skills-provider'
|
|
5
|
+
import type { GroupedSkills, SkillInfo } from '../types'
|
|
6
|
+
|
|
7
|
+
export function useSkills() {
|
|
8
|
+
const [skills, setSkills] = useState<SkillInfo[]>([])
|
|
9
|
+
const [loading, setLoading] = useState(true)
|
|
10
|
+
const [error, setError] = useState<string | null>(null)
|
|
11
|
+
const [groupedSkills, setGroupedSkills] = useState<GroupedSkills>(new Map())
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
let mounted = true
|
|
15
|
+
|
|
16
|
+
const load = async () => {
|
|
17
|
+
try {
|
|
18
|
+
const data = await discoverSkillsAsync()
|
|
19
|
+
|
|
20
|
+
if (mounted) {
|
|
21
|
+
setSkills(data)
|
|
22
|
+
setGroupedSkills(groupSkillsByCategory(data))
|
|
23
|
+
}
|
|
24
|
+
} catch (err: unknown) {
|
|
25
|
+
if (mounted) setError(err instanceof Error ? err.message : String(err))
|
|
26
|
+
} finally {
|
|
27
|
+
if (mounted) setLoading(false)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
load()
|
|
32
|
+
return () => {
|
|
33
|
+
mounted = false
|
|
34
|
+
}
|
|
35
|
+
}, [])
|
|
36
|
+
|
|
37
|
+
return { skills, loading, error, groupedSkills }
|
|
38
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
|
|
3
|
+
export function useWizardStep(totalSteps: number) {
|
|
4
|
+
const [step, setStep] = useState(1)
|
|
5
|
+
|
|
6
|
+
const next = () => setStep((s) => Math.min(s + 1, totalSteps))
|
|
7
|
+
const back = () => setStep((s) => Math.max(s - 1, 1))
|
|
8
|
+
const goTo = (s: number) => setStep(s)
|
|
9
|
+
|
|
10
|
+
return {
|
|
11
|
+
step,
|
|
12
|
+
next,
|
|
13
|
+
back,
|
|
14
|
+
goTo,
|
|
15
|
+
isFirst: step === 1,
|
|
16
|
+
isLast: step === totalSteps,
|
|
17
|
+
progress: step / totalSteps,
|
|
18
|
+
}
|
|
19
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { Command } from 'commander'
|
|
2
|
+
import { render } from 'ink'
|
|
3
|
+
import React from 'react'
|
|
4
|
+
|
|
5
|
+
import { App } from './app'
|
|
6
|
+
import { PACKAGE_VERSION } from './services/package-info'
|
|
7
|
+
|
|
8
|
+
const program = new Command()
|
|
9
|
+
|
|
10
|
+
// Root command — default action (no subcommand) → interactive install
|
|
11
|
+
program
|
|
12
|
+
.name('ai-tools')
|
|
13
|
+
.description('CLI to install and manage AI tools (Skills, MCPs, Agents) for AI coding agents')
|
|
14
|
+
.version(PACKAGE_VERSION)
|
|
15
|
+
.action(() => {
|
|
16
|
+
render(React.createElement(App, { command: 'install' }))
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
// Install command
|
|
20
|
+
program
|
|
21
|
+
.command('install')
|
|
22
|
+
.description('Install skills (interactive by default)')
|
|
23
|
+
.option('-g, --global', 'Install globally to user home', false)
|
|
24
|
+
.option('-s, --skill <names...>', 'Install one or more skills')
|
|
25
|
+
.option('-a, --agent <agents...>', 'Target specific agents')
|
|
26
|
+
.option('--symlink', 'Use symlink instead of copy', false)
|
|
27
|
+
.option('-f, --force', 'Force re-download skills (bypass cache)', false)
|
|
28
|
+
.action(async (options) => {
|
|
29
|
+
if (shouldUseInteractiveMode(options)) {
|
|
30
|
+
render(React.createElement(App, { command: 'install' }))
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// CLI mode - dynamic import
|
|
35
|
+
const { runCliInstall } = await import('./cli/install')
|
|
36
|
+
await runCliInstall(options)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// List command
|
|
40
|
+
program
|
|
41
|
+
.command('list')
|
|
42
|
+
.alias('ls')
|
|
43
|
+
.description('List available/installed agent skills')
|
|
44
|
+
.option('-s, --simple', 'List skills in non-interactive mode (plain output)', false)
|
|
45
|
+
.action(async (options) => {
|
|
46
|
+
if (!options.simple) {
|
|
47
|
+
render(React.createElement(App, { command: 'list' }))
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// CLI mode - dynamic import
|
|
52
|
+
const { runCliList } = await import('./cli/list')
|
|
53
|
+
await runCliList()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
// Remove command
|
|
57
|
+
program
|
|
58
|
+
.command('remove')
|
|
59
|
+
.alias('rm')
|
|
60
|
+
.description('Remove installed skills')
|
|
61
|
+
.option('-g, --global', 'Remove from global installation', false)
|
|
62
|
+
.option('-s, --skill <names...>', 'Remove one or more skills')
|
|
63
|
+
.option('-a, --agent <agents...>', 'Target specific agents')
|
|
64
|
+
.option('-f, --force', 'Force removal even if not in lockfile', false)
|
|
65
|
+
.action(async (options) => {
|
|
66
|
+
if (shouldUseInteractiveMode(options)) {
|
|
67
|
+
render(React.createElement(App, { command: 'remove' }))
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// CLI mode - dynamic import
|
|
72
|
+
const { runCliRemove } = await import('./cli/remove')
|
|
73
|
+
await runCliRemove(options)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
// Update command
|
|
77
|
+
program
|
|
78
|
+
.command('update')
|
|
79
|
+
.description('Update installed skills to the latest version')
|
|
80
|
+
.option('-s, --skill <name>', 'Update a specific skill')
|
|
81
|
+
.action(async (options) => {
|
|
82
|
+
if (shouldUseInteractiveMode(options)) {
|
|
83
|
+
render(React.createElement(App, { command: 'update' }))
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// CLI mode - dynamic import
|
|
88
|
+
const { runCliUpdate } = await import('./cli/update')
|
|
89
|
+
await runCliUpdate(options)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
// Cache command
|
|
93
|
+
program
|
|
94
|
+
.command('cache')
|
|
95
|
+
.description('Manage the skills cache')
|
|
96
|
+
.option('--clear', 'Clear all cached skills and registry')
|
|
97
|
+
.option('--clear-registry', 'Clear only the registry cache')
|
|
98
|
+
.option('--path', 'Show cache directory path')
|
|
99
|
+
.action(async (options) => {
|
|
100
|
+
// CLI mode - dynamic import
|
|
101
|
+
const { runCliCache } = await import('./cli/cache')
|
|
102
|
+
runCliCache(options)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
// Credits command
|
|
106
|
+
program
|
|
107
|
+
.command('credits')
|
|
108
|
+
.description('Show project contributors and credits')
|
|
109
|
+
.action(() => {
|
|
110
|
+
render(React.createElement(App, { command: 'credits' }))
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
// Audit log command
|
|
114
|
+
program
|
|
115
|
+
.command('audit')
|
|
116
|
+
.description('View audit log of skill operations')
|
|
117
|
+
.option('-n, --limit <number>', 'Number of entries to show', '10')
|
|
118
|
+
.option('--path', 'Show audit log file path')
|
|
119
|
+
.action(async (options) => {
|
|
120
|
+
const { runCliAudit } = await import('./cli/audit')
|
|
121
|
+
await runCliAudit(options)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
program.parse(process.argv)
|
|
125
|
+
|
|
126
|
+
function shouldUseInteractiveMode(options: Record<string, unknown>): boolean {
|
|
127
|
+
const optionKeys = Object.keys(options).filter((key) => key !== 'parent')
|
|
128
|
+
return optionKeys.length === 0
|
|
129
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { mkdir, rm, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { AUDIT_LOG_FILE, GLOBAL_CONFIG_DIR } from '../../utils/constants'
|
|
6
|
+
import { getAuditLogPath, logAudit, readAuditLog } from '../audit-log'
|
|
7
|
+
|
|
8
|
+
describe('Audit Log', () => {
|
|
9
|
+
const testHome = join(tmpdir(), `.test-ai-tools-${Date.now()}`)
|
|
10
|
+
const testConfigDir = join(testHome, GLOBAL_CONFIG_DIR)
|
|
11
|
+
const testLogPath = join(testConfigDir, AUDIT_LOG_FILE)
|
|
12
|
+
|
|
13
|
+
beforeAll(async () => {
|
|
14
|
+
await mkdir(testConfigDir, { recursive: true })
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
afterAll(async () => {
|
|
18
|
+
await rm(testHome, { recursive: true, force: true })
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
beforeEach(async () => {
|
|
22
|
+
await rm(testLogPath, { force: true })
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('getAuditLogPath', () => {
|
|
26
|
+
it('should return path in home directory', () => {
|
|
27
|
+
const path = getAuditLogPath(testHome)
|
|
28
|
+
expect(path).toContain(testHome)
|
|
29
|
+
expect(path).toContain(GLOBAL_CONFIG_DIR)
|
|
30
|
+
expect(path).toContain(AUDIT_LOG_FILE)
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe('logAudit', () => {
|
|
35
|
+
it('should create audit log file and write entry', async () => {
|
|
36
|
+
await logAudit(
|
|
37
|
+
{
|
|
38
|
+
action: 'install',
|
|
39
|
+
skillName: 'test-skill',
|
|
40
|
+
agents: ['Cursor'],
|
|
41
|
+
success: 1,
|
|
42
|
+
failed: 0,
|
|
43
|
+
},
|
|
44
|
+
testHome,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
const entries = await readAuditLog(undefined, testHome)
|
|
48
|
+
expect(entries).toHaveLength(1)
|
|
49
|
+
expect(entries[0].skillName).toBe('test-skill')
|
|
50
|
+
expect(entries[0].action).toBe('install')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('should append to existing audit log', async () => {
|
|
54
|
+
await logAudit(
|
|
55
|
+
{
|
|
56
|
+
action: 'install',
|
|
57
|
+
skillName: 'skill-1',
|
|
58
|
+
agents: ['Cursor'],
|
|
59
|
+
success: 1,
|
|
60
|
+
failed: 0,
|
|
61
|
+
},
|
|
62
|
+
testHome,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
await logAudit(
|
|
66
|
+
{
|
|
67
|
+
action: 'remove',
|
|
68
|
+
skillName: 'skill-2',
|
|
69
|
+
agents: ['Claude Code'],
|
|
70
|
+
success: 1,
|
|
71
|
+
failed: 0,
|
|
72
|
+
},
|
|
73
|
+
testHome,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
const entries = await readAuditLog(undefined, testHome)
|
|
77
|
+
expect(entries).toHaveLength(2)
|
|
78
|
+
expect(entries[0].skillName).toBe('skill-2') // Most recent first
|
|
79
|
+
expect(entries[1].skillName).toBe('skill-1')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('should include timestamp in log entries', async () => {
|
|
83
|
+
const beforeTime = Date.now()
|
|
84
|
+
|
|
85
|
+
await logAudit(
|
|
86
|
+
{
|
|
87
|
+
action: 'install',
|
|
88
|
+
skillName: 'test-skill',
|
|
89
|
+
agents: ['Cursor'],
|
|
90
|
+
success: 1,
|
|
91
|
+
failed: 0,
|
|
92
|
+
},
|
|
93
|
+
testHome,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
const entries = await readAuditLog(undefined, testHome)
|
|
97
|
+
// Timestamp string exists now
|
|
98
|
+
const timestamp = entries[0].timestamp
|
|
99
|
+
expect(timestamp).toBeDefined()
|
|
100
|
+
const entryTime = new Date(timestamp!).getTime()
|
|
101
|
+
|
|
102
|
+
expect(entryTime).toBeGreaterThanOrEqual(beforeTime)
|
|
103
|
+
expect(entryTime).toBeLessThanOrEqual(Date.now())
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe('readAuditLog', () => {
|
|
108
|
+
it('should return empty array if log file does not exist', async () => {
|
|
109
|
+
const entries = await readAuditLog(undefined, testHome)
|
|
110
|
+
expect(entries).toEqual([])
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('should parse valid log entries', async () => {
|
|
114
|
+
await mkdir(testConfigDir, { recursive: true })
|
|
115
|
+
const logContent = [
|
|
116
|
+
JSON.stringify({
|
|
117
|
+
action: 'install',
|
|
118
|
+
skillName: 'skill-1',
|
|
119
|
+
agents: ['Cursor'],
|
|
120
|
+
success: 1,
|
|
121
|
+
failed: 0,
|
|
122
|
+
timestamp: '2026-02-18T10:00:00.000Z',
|
|
123
|
+
}),
|
|
124
|
+
JSON.stringify({
|
|
125
|
+
action: 'remove',
|
|
126
|
+
skillName: 'skill-2',
|
|
127
|
+
agents: ['Claude Code'],
|
|
128
|
+
success: 1,
|
|
129
|
+
failed: 0,
|
|
130
|
+
timestamp: '2026-02-18T11:00:00.000Z',
|
|
131
|
+
}),
|
|
132
|
+
].join('\n')
|
|
133
|
+
|
|
134
|
+
await writeFile(testLogPath, logContent, 'utf-8')
|
|
135
|
+
|
|
136
|
+
const entries = await readAuditLog(undefined, testHome)
|
|
137
|
+
expect(entries).toHaveLength(2)
|
|
138
|
+
expect(entries[0].skillName).toBe('skill-2') // Most recent first
|
|
139
|
+
expect(entries[1].skillName).toBe('skill-1')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('should skip invalid JSON lines', async () => {
|
|
143
|
+
await mkdir(testConfigDir, { recursive: true })
|
|
144
|
+
const logContent = [
|
|
145
|
+
JSON.stringify({
|
|
146
|
+
action: 'install',
|
|
147
|
+
skillName: 'skill-1',
|
|
148
|
+
agents: ['Cursor'],
|
|
149
|
+
success: 1,
|
|
150
|
+
failed: 0,
|
|
151
|
+
timestamp: '2026-02-18T10:00:00.000Z',
|
|
152
|
+
}),
|
|
153
|
+
'invalid json line',
|
|
154
|
+
JSON.stringify({
|
|
155
|
+
action: 'remove',
|
|
156
|
+
skillName: 'skill-2',
|
|
157
|
+
agents: ['Claude Code'],
|
|
158
|
+
success: 1,
|
|
159
|
+
failed: 0,
|
|
160
|
+
timestamp: '2026-02-18T11:00:00.000Z',
|
|
161
|
+
}),
|
|
162
|
+
].join('\n')
|
|
163
|
+
|
|
164
|
+
await writeFile(testLogPath, logContent, 'utf-8')
|
|
165
|
+
|
|
166
|
+
const entries = await readAuditLog(undefined, testHome)
|
|
167
|
+
expect(entries).toHaveLength(2)
|
|
168
|
+
expect(entries[0].skillName).toBe('skill-2')
|
|
169
|
+
expect(entries[1].skillName).toBe('skill-1')
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('should respect limit parameter', async () => {
|
|
173
|
+
await mkdir(testConfigDir, { recursive: true })
|
|
174
|
+
const logContent = Array.from({ length: 20 }, (_, i) =>
|
|
175
|
+
JSON.stringify({
|
|
176
|
+
action: 'install',
|
|
177
|
+
skillName: `skill-${i}`,
|
|
178
|
+
agents: ['Cursor'],
|
|
179
|
+
success: 1,
|
|
180
|
+
failed: 0,
|
|
181
|
+
timestamp: new Date(Date.now() + i * 1000).toISOString(),
|
|
182
|
+
}),
|
|
183
|
+
).join('\n')
|
|
184
|
+
|
|
185
|
+
await writeFile(testLogPath, logContent, 'utf-8')
|
|
186
|
+
|
|
187
|
+
const entries = await readAuditLog(5, testHome)
|
|
188
|
+
expect(entries).toHaveLength(5)
|
|
189
|
+
expect(entries[0].skillName).toBe('skill-19') // Most recent
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('should handle empty lines', async () => {
|
|
193
|
+
await mkdir(testConfigDir, { recursive: true })
|
|
194
|
+
const logContent = [
|
|
195
|
+
JSON.stringify({
|
|
196
|
+
action: 'install',
|
|
197
|
+
skillName: 'skill-1',
|
|
198
|
+
agents: ['Cursor'],
|
|
199
|
+
success: 1,
|
|
200
|
+
failed: 0,
|
|
201
|
+
timestamp: '2026-02-18T10:00:00.000Z',
|
|
202
|
+
}),
|
|
203
|
+
'',
|
|
204
|
+
'',
|
|
205
|
+
JSON.stringify({
|
|
206
|
+
action: 'remove',
|
|
207
|
+
skillName: 'skill-2',
|
|
208
|
+
agents: ['Claude Code'],
|
|
209
|
+
success: 1,
|
|
210
|
+
failed: 0,
|
|
211
|
+
timestamp: '2026-02-18T11:00:00.000Z',
|
|
212
|
+
}),
|
|
213
|
+
].join('\n')
|
|
214
|
+
|
|
215
|
+
await writeFile(testLogPath, logContent, 'utf-8')
|
|
216
|
+
const entries = await readAuditLog(undefined, testHome)
|
|
217
|
+
expect(entries).toHaveLength(2)
|
|
218
|
+
})
|
|
219
|
+
})
|
|
220
|
+
})
|