@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,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
+ })