@sundial-ai/cli 0.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/PLAN.md +497 -0
- package/README.md +30 -0
- package/dist/commands/add.d.ts +13 -0
- package/dist/commands/add.d.ts.map +1 -0
- package/dist/commands/add.js +112 -0
- package/dist/commands/add.js.map +1 -0
- package/dist/commands/config.d.ts +5 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/config.js +42 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/list.d.ts +5 -0
- package/dist/commands/list.d.ts.map +1 -0
- package/dist/commands/list.js +53 -0
- package/dist/commands/list.js.map +1 -0
- package/dist/commands/remove.d.ts +13 -0
- package/dist/commands/remove.d.ts.map +1 -0
- package/dist/commands/remove.js +127 -0
- package/dist/commands/remove.js.map +1 -0
- package/dist/commands/show.d.ts +5 -0
- package/dist/commands/show.d.ts.map +1 -0
- package/dist/commands/show.js +81 -0
- package/dist/commands/show.js.map +1 -0
- package/dist/core/agent-detect.d.ts +9 -0
- package/dist/core/agent-detect.d.ts.map +1 -0
- package/dist/core/agent-detect.js +44 -0
- package/dist/core/agent-detect.js.map +1 -0
- package/dist/core/agents.d.ts +8 -0
- package/dist/core/agents.d.ts.map +1 -0
- package/dist/core/agents.js +34 -0
- package/dist/core/agents.js.map +1 -0
- package/dist/core/config-manager.d.ts +9 -0
- package/dist/core/config-manager.d.ts.map +1 -0
- package/dist/core/config-manager.js +47 -0
- package/dist/core/config-manager.js.map +1 -0
- package/dist/core/skill-hash.d.ts +12 -0
- package/dist/core/skill-hash.d.ts.map +1 -0
- package/dist/core/skill-hash.js +53 -0
- package/dist/core/skill-hash.js.map +1 -0
- package/dist/core/skill-info.d.ts +35 -0
- package/dist/core/skill-info.d.ts.map +1 -0
- package/dist/core/skill-info.js +211 -0
- package/dist/core/skill-info.js.map +1 -0
- package/dist/core/skill-install.d.ts +24 -0
- package/dist/core/skill-install.d.ts.map +1 -0
- package/dist/core/skill-install.js +123 -0
- package/dist/core/skill-install.js.map +1 -0
- package/dist/core/skill-source.d.ts +29 -0
- package/dist/core/skill-source.d.ts.map +1 -0
- package/dist/core/skill-source.js +105 -0
- package/dist/core/skill-source.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +104 -0
- package/dist/index.js.map +1 -0
- package/dist/types/index.d.ts +57 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +2 -0
- package/dist/types/index.js.map +1 -0
- package/dist/utils/fuzzy-match.d.ts +16 -0
- package/dist/utils/fuzzy-match.d.ts.map +1 -0
- package/dist/utils/fuzzy-match.js +37 -0
- package/dist/utils/fuzzy-match.js.map +1 -0
- package/dist/utils/prompts.d.ts +16 -0
- package/dist/utils/prompts.d.ts.map +1 -0
- package/dist/utils/prompts.js +80 -0
- package/dist/utils/prompts.js.map +1 -0
- package/dist/utils/registry.d.ts +6 -0
- package/dist/utils/registry.d.ts.map +1 -0
- package/dist/utils/registry.js +14 -0
- package/dist/utils/registry.js.map +1 -0
- package/package.json +43 -0
- package/publish.sh +2 -0
- package/src/commands/add.ts +137 -0
- package/src/commands/config.ts +49 -0
- package/src/commands/list.ts +68 -0
- package/src/commands/remove.ts +152 -0
- package/src/commands/show.ts +93 -0
- package/src/core/agent-detect.ts +53 -0
- package/src/core/agents.ts +40 -0
- package/src/core/config-manager.ts +55 -0
- package/src/core/skill-hash.ts +61 -0
- package/src/core/skill-info.ts +246 -0
- package/src/core/skill-install.ts +165 -0
- package/src/core/skill-source.ts +118 -0
- package/src/index.ts +116 -0
- package/src/types/index.ts +64 -0
- package/src/utils/fuzzy-match.ts +48 -0
- package/src/utils/prompts.ts +92 -0
- package/src/utils/registry.ts +16 -0
- package/test/agents.test.ts +86 -0
- package/test/fuzzy-match.test.ts +58 -0
- package/test/registry.test.ts +48 -0
- package/test/skill-hash.test.ts +77 -0
- package/test/skill-info.test.ts +195 -0
- package/test/skill-source.test.ts +89 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import fuzzysort from 'fuzzysort';
|
|
2
|
+
|
|
3
|
+
const VALID_COMMANDS = ['add', 'remove', 'list', 'show', 'config'];
|
|
4
|
+
|
|
5
|
+
export interface FuzzyMatch {
|
|
6
|
+
command: string;
|
|
7
|
+
score: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Find the closest matching command using fuzzy search.
|
|
12
|
+
* fuzzysort scores: 0 = perfect match, negative = worse match
|
|
13
|
+
*/
|
|
14
|
+
export function findClosestCommand(input: string): FuzzyMatch | null {
|
|
15
|
+
const results = fuzzysort.go(input, VALID_COMMANDS, {
|
|
16
|
+
// Allow any match (we'll filter by score later)
|
|
17
|
+
threshold: -Infinity
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
if (results.length === 0) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const best = results[0];
|
|
25
|
+
return {
|
|
26
|
+
command: best.target,
|
|
27
|
+
score: best.score
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Suggest a command if the input is close enough to a valid command.
|
|
33
|
+
* Returns null if no good suggestion (avoids suggesting "add" for "xyz").
|
|
34
|
+
*/
|
|
35
|
+
export function suggestCommand(input: string): string | null {
|
|
36
|
+
const match = findClosestCommand(input);
|
|
37
|
+
|
|
38
|
+
// Only suggest if score is reasonable (0=perfect, -100 is still decent for typos)
|
|
39
|
+
// Examples: "ad" -> "add" (good), "addd" -> "add" (good), "xyz" -> null (no suggestion)
|
|
40
|
+
if (match && match.score > -100) {
|
|
41
|
+
return match.command;
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function getValidCommands(): string[] {
|
|
47
|
+
return [...VALID_COMMANDS];
|
|
48
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import inquirer from 'inquirer';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { SUPPORTED_AGENTS, getSupportedAgentsMessage } from '../core/agents.js';
|
|
4
|
+
import type { AgentType, DetectedAgent } from '../types/index.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Show an interactive checkbox UI for selecting default agents.
|
|
8
|
+
*
|
|
9
|
+
* @param detectedAgents - Agents found in local/global directories
|
|
10
|
+
* @param currentDefaults - Previously saved defaults (empty on first run)
|
|
11
|
+
* @returns Array of selected agent flags (e.g., ['claude', 'codex'])
|
|
12
|
+
*
|
|
13
|
+
* Behavior:
|
|
14
|
+
* - First run (currentDefaults empty): ALL detected agents are pre-selected
|
|
15
|
+
* - Subsequent runs: Only previously saved defaults are pre-selected
|
|
16
|
+
* - If no agents detected: Show all supported agents (none pre-selected)
|
|
17
|
+
*/
|
|
18
|
+
export async function promptAgentSelection(
|
|
19
|
+
detectedAgents: DetectedAgent[],
|
|
20
|
+
currentDefaults: AgentType[] = []
|
|
21
|
+
): Promise<AgentType[]> {
|
|
22
|
+
const isFirstRun = currentDefaults.length === 0;
|
|
23
|
+
|
|
24
|
+
// Show what we detected
|
|
25
|
+
if (detectedAgents.length > 0) {
|
|
26
|
+
console.log(chalk.cyan(`\nWe detected ${detectedAgents.length} agent folders installed on your machine:`));
|
|
27
|
+
for (const detected of detectedAgents) {
|
|
28
|
+
const location = detected.isGlobal ? '~/' : './';
|
|
29
|
+
console.log(chalk.white(` • ${detected.agent.name} ${chalk.gray(`(${location}${detected.agent.folderName})`)}`));
|
|
30
|
+
}
|
|
31
|
+
console.log();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
console.log(chalk.gray(getSupportedAgentsMessage()));
|
|
35
|
+
console.log(chalk.gray('Select which agents you want to add skills to by default:\n'));
|
|
36
|
+
|
|
37
|
+
// Build choices from detected agents
|
|
38
|
+
const choices = detectedAgents.map(detected => {
|
|
39
|
+
const location = detected.isGlobal ? '(global)' : '(local)';
|
|
40
|
+
const agentFlag = detected.agent.flag as AgentType;
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
name: `${detected.agent.name} ${chalk.gray(location)}`,
|
|
44
|
+
value: agentFlag,
|
|
45
|
+
// First run: select ALL detected agents
|
|
46
|
+
// Otherwise: only select if it was in previous defaults
|
|
47
|
+
checked: isFirstRun ? true : currentDefaults.includes(agentFlag)
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// If no agents detected, show all supported agents as options
|
|
52
|
+
if (choices.length === 0) {
|
|
53
|
+
console.log(chalk.yellow('No agent folders detected on your machine.'));
|
|
54
|
+
console.log(chalk.gray('Showing all supported agents - select which ones you plan to use:\n'));
|
|
55
|
+
for (const agent of SUPPORTED_AGENTS) {
|
|
56
|
+
choices.push({
|
|
57
|
+
name: agent.name,
|
|
58
|
+
value: agent.flag as AgentType,
|
|
59
|
+
checked: false // None pre-selected since we don't know which they'll install
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const { selectedAgents } = await inquirer.prompt([
|
|
65
|
+
{
|
|
66
|
+
type: 'checkbox',
|
|
67
|
+
name: 'selectedAgents',
|
|
68
|
+
message: 'Select default agents:',
|
|
69
|
+
choices,
|
|
70
|
+
validate: (answer: AgentType[]) => {
|
|
71
|
+
if (answer.length === 0) {
|
|
72
|
+
return 'You must select at least one agent.';
|
|
73
|
+
}
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
return selectedAgents;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function confirmAction(message: string): Promise<boolean> {
|
|
83
|
+
const { confirmed } = await inquirer.prompt([
|
|
84
|
+
{
|
|
85
|
+
type: 'confirm',
|
|
86
|
+
name: 'confirmed',
|
|
87
|
+
message,
|
|
88
|
+
default: true
|
|
89
|
+
}
|
|
90
|
+
]);
|
|
91
|
+
return confirmed;
|
|
92
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Skill shortcuts registry - maps short names to GitHub URLs */
|
|
2
|
+
export const SKILL_SHORTCUTS: Record<string, string> = {
|
|
3
|
+
tinker: 'https://github.com/sundial-org/skills/tree/main/skills/tinker'
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export function isShortcut(skill: string): boolean {
|
|
7
|
+
return skill in SKILL_SHORTCUTS;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function getShortcutUrl(skill: string): string | undefined {
|
|
11
|
+
return SKILL_SHORTCUTS[skill];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function listShortcuts(): string[] {
|
|
15
|
+
return Object.keys(SKILL_SHORTCUTS);
|
|
16
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
SUPPORTED_AGENTS,
|
|
4
|
+
getAgentByFlag,
|
|
5
|
+
getAgentByFolder,
|
|
6
|
+
getSupportedAgentsMessage,
|
|
7
|
+
isValidAgentType,
|
|
8
|
+
getAgentFlags
|
|
9
|
+
} from '../src/core/agents.js';
|
|
10
|
+
|
|
11
|
+
describe('agents', () => {
|
|
12
|
+
describe('SUPPORTED_AGENTS', () => {
|
|
13
|
+
it('should have claude, codex, and gemini agents', () => {
|
|
14
|
+
const flags = SUPPORTED_AGENTS.map(a => a.flag);
|
|
15
|
+
expect(flags).toContain('claude');
|
|
16
|
+
expect(flags).toContain('codex');
|
|
17
|
+
expect(flags).toContain('gemini');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('each agent should have name, flag, and folderName', () => {
|
|
21
|
+
for (const agent of SUPPORTED_AGENTS) {
|
|
22
|
+
expect(agent.name).toBeTruthy();
|
|
23
|
+
expect(agent.flag).toBeTruthy();
|
|
24
|
+
expect(agent.folderName).toBeTruthy();
|
|
25
|
+
expect(agent.folderName.startsWith('.')).toBe(true);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('getAgentByFlag', () => {
|
|
31
|
+
it('returns correct agent for valid flag', () => {
|
|
32
|
+
const claude = getAgentByFlag('claude');
|
|
33
|
+
expect(claude).toBeDefined();
|
|
34
|
+
expect(claude?.name).toBe('Claude Code');
|
|
35
|
+
expect(claude?.folderName).toBe('.claude');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('returns undefined for invalid flag', () => {
|
|
39
|
+
expect(getAgentByFlag('invalid')).toBeUndefined();
|
|
40
|
+
expect(getAgentByFlag('')).toBeUndefined();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('getAgentByFolder', () => {
|
|
45
|
+
it('returns correct agent for valid folder', () => {
|
|
46
|
+
const codex = getAgentByFolder('.codex');
|
|
47
|
+
expect(codex).toBeDefined();
|
|
48
|
+
expect(codex?.flag).toBe('codex');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('returns undefined for invalid folder', () => {
|
|
52
|
+
expect(getAgentByFolder('codex')).toBeUndefined(); // missing dot
|
|
53
|
+
expect(getAgentByFolder('.invalid')).toBeUndefined();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('getSupportedAgentsMessage', () => {
|
|
58
|
+
it('includes all agent names', () => {
|
|
59
|
+
const message = getSupportedAgentsMessage();
|
|
60
|
+
expect(message).toContain('Claude Code');
|
|
61
|
+
expect(message).toContain('Codex');
|
|
62
|
+
expect(message).toContain('Gemini');
|
|
63
|
+
expect(message).toContain('Currently supported agents:');
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('isValidAgentType', () => {
|
|
68
|
+
it('returns true for valid agent flags', () => {
|
|
69
|
+
expect(isValidAgentType('claude')).toBe(true);
|
|
70
|
+
expect(isValidAgentType('codex')).toBe(true);
|
|
71
|
+
expect(isValidAgentType('gemini')).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('returns false for invalid flags', () => {
|
|
75
|
+
expect(isValidAgentType('invalid')).toBe(false);
|
|
76
|
+
expect(isValidAgentType('')).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('getAgentFlags', () => {
|
|
81
|
+
it('returns array of all agent flags', () => {
|
|
82
|
+
const flags = getAgentFlags();
|
|
83
|
+
expect(flags).toEqual(['claude', 'codex', 'gemini']);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
findClosestCommand,
|
|
4
|
+
suggestCommand,
|
|
5
|
+
getValidCommands
|
|
6
|
+
} from '../src/utils/fuzzy-match.js';
|
|
7
|
+
|
|
8
|
+
describe('fuzzy-match', () => {
|
|
9
|
+
describe('findClosestCommand', () => {
|
|
10
|
+
it('finds exact matches', () => {
|
|
11
|
+
const match = findClosestCommand('add');
|
|
12
|
+
expect(match).not.toBeNull();
|
|
13
|
+
expect(match?.command).toBe('add');
|
|
14
|
+
// fuzzysort returns positive scores for matches
|
|
15
|
+
expect(match?.score).toBeGreaterThanOrEqual(0);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('finds partial matches', () => {
|
|
19
|
+
const match = findClosestCommand('rem');
|
|
20
|
+
expect(match).not.toBeNull();
|
|
21
|
+
expect(match?.command).toBe('remove');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('finds matches for single character typos', () => {
|
|
25
|
+
const match = findClosestCommand('lst');
|
|
26
|
+
expect(match).not.toBeNull();
|
|
27
|
+
expect(match?.command).toBe('list');
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('suggestCommand', () => {
|
|
32
|
+
it('suggests for close matches', () => {
|
|
33
|
+
expect(suggestCommand('ad')).toBe('add');
|
|
34
|
+
expect(suggestCommand('remov')).toBe('remove');
|
|
35
|
+
expect(suggestCommand('lis')).toBe('list');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('returns null for completely different input', () => {
|
|
39
|
+
expect(suggestCommand('xyz')).toBeNull();
|
|
40
|
+
expect(suggestCommand('foobar')).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('returns exact match', () => {
|
|
44
|
+
expect(suggestCommand('config')).toBe('config');
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('getValidCommands', () => {
|
|
49
|
+
it('returns all valid commands', () => {
|
|
50
|
+
const commands = getValidCommands();
|
|
51
|
+
expect(commands).toContain('add');
|
|
52
|
+
expect(commands).toContain('remove');
|
|
53
|
+
expect(commands).toContain('list');
|
|
54
|
+
expect(commands).toContain('show');
|
|
55
|
+
expect(commands).toContain('config');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
SKILL_SHORTCUTS,
|
|
4
|
+
isShortcut,
|
|
5
|
+
getShortcutUrl,
|
|
6
|
+
listShortcuts
|
|
7
|
+
} from '../src/utils/registry.js';
|
|
8
|
+
|
|
9
|
+
describe('registry', () => {
|
|
10
|
+
describe('SKILL_SHORTCUTS', () => {
|
|
11
|
+
it('should have tinker shortcut', () => {
|
|
12
|
+
expect(SKILL_SHORTCUTS.tinker).toBeDefined();
|
|
13
|
+
expect(SKILL_SHORTCUTS.tinker).toContain('github.com');
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('isShortcut', () => {
|
|
18
|
+
it('returns true for registered shortcuts', () => {
|
|
19
|
+
expect(isShortcut('tinker')).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('returns false for non-shortcuts', () => {
|
|
23
|
+
expect(isShortcut('not-a-shortcut')).toBe(false);
|
|
24
|
+
expect(isShortcut('github.com/user/repo')).toBe(false);
|
|
25
|
+
expect(isShortcut('./local-path')).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('getShortcutUrl', () => {
|
|
30
|
+
it('returns URL for valid shortcut', () => {
|
|
31
|
+
const url = getShortcutUrl('tinker');
|
|
32
|
+
expect(url).toBeDefined();
|
|
33
|
+
expect(url).toContain('sundial-org/skills');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('returns undefined for invalid shortcut', () => {
|
|
37
|
+
expect(getShortcutUrl('invalid')).toBeUndefined();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('listShortcuts', () => {
|
|
42
|
+
it('returns list of all shortcuts', () => {
|
|
43
|
+
const shortcuts = listShortcuts();
|
|
44
|
+
expect(shortcuts).toContain('tinker');
|
|
45
|
+
expect(Array.isArray(shortcuts)).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import { computeContentHash } from '../src/core/skill-hash.js';
|
|
6
|
+
|
|
7
|
+
describe('skill-hash', () => {
|
|
8
|
+
let tempDir: string;
|
|
9
|
+
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
tempDir = path.join(os.tmpdir(), `sun-test-hash-${Date.now()}`);
|
|
12
|
+
await fs.ensureDir(tempDir);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(async () => {
|
|
16
|
+
await fs.remove(tempDir);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('computeContentHash', () => {
|
|
20
|
+
it('returns consistent hash for same content', async () => {
|
|
21
|
+
await fs.writeFile(path.join(tempDir, 'file.txt'), 'hello world');
|
|
22
|
+
|
|
23
|
+
const hash1 = await computeContentHash(tempDir);
|
|
24
|
+
const hash2 = await computeContentHash(tempDir);
|
|
25
|
+
|
|
26
|
+
expect(hash1).toBe(hash2);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('returns different hash for different content', async () => {
|
|
30
|
+
const dir1 = path.join(tempDir, 'dir1');
|
|
31
|
+
const dir2 = path.join(tempDir, 'dir2');
|
|
32
|
+
await fs.ensureDir(dir1);
|
|
33
|
+
await fs.ensureDir(dir2);
|
|
34
|
+
|
|
35
|
+
await fs.writeFile(path.join(dir1, 'file.txt'), 'content 1');
|
|
36
|
+
await fs.writeFile(path.join(dir2, 'file.txt'), 'content 2');
|
|
37
|
+
|
|
38
|
+
const hash1 = await computeContentHash(dir1);
|
|
39
|
+
const hash2 = await computeContentHash(dir2);
|
|
40
|
+
|
|
41
|
+
expect(hash1).not.toBe(hash2);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('returns hash for empty folder', async () => {
|
|
45
|
+
const hash = await computeContentHash(tempDir);
|
|
46
|
+
expect(hash).toBeTruthy();
|
|
47
|
+
expect(hash.length).toBe(12); // Truncated to 12 chars
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('includes subdirectory content in hash', async () => {
|
|
51
|
+
const subDir = path.join(tempDir, 'subdir');
|
|
52
|
+
await fs.ensureDir(subDir);
|
|
53
|
+
await fs.writeFile(path.join(subDir, 'nested.txt'), 'nested content');
|
|
54
|
+
|
|
55
|
+
const hashBefore = await computeContentHash(tempDir);
|
|
56
|
+
|
|
57
|
+
await fs.writeFile(path.join(subDir, 'nested.txt'), 'modified content');
|
|
58
|
+
|
|
59
|
+
const hashAfter = await computeContentHash(tempDir);
|
|
60
|
+
|
|
61
|
+
expect(hashBefore).not.toBe(hashAfter);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('hash changes when file is renamed', async () => {
|
|
65
|
+
await fs.writeFile(path.join(tempDir, 'original.txt'), 'content');
|
|
66
|
+
const hash1 = await computeContentHash(tempDir);
|
|
67
|
+
|
|
68
|
+
await fs.rename(
|
|
69
|
+
path.join(tempDir, 'original.txt'),
|
|
70
|
+
path.join(tempDir, 'renamed.txt')
|
|
71
|
+
);
|
|
72
|
+
const hash2 = await computeContentHash(tempDir);
|
|
73
|
+
|
|
74
|
+
expect(hash1).not.toBe(hash2);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import {
|
|
6
|
+
isValidSkillDirectory,
|
|
7
|
+
findSkillDirectories,
|
|
8
|
+
readSkillMetadata,
|
|
9
|
+
getSkillName
|
|
10
|
+
} from '../src/core/skill-info.js';
|
|
11
|
+
|
|
12
|
+
describe('skill-info', () => {
|
|
13
|
+
let tempDir: string;
|
|
14
|
+
|
|
15
|
+
beforeEach(async () => {
|
|
16
|
+
tempDir = path.join(os.tmpdir(), `sun-test-info-${Date.now()}`);
|
|
17
|
+
await fs.ensureDir(tempDir);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
await fs.remove(tempDir);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const createSkillMd = (name: string, description: string, extras = '') => `---
|
|
25
|
+
name: ${name}
|
|
26
|
+
description: ${description}
|
|
27
|
+
${extras}
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
# ${name}
|
|
31
|
+
|
|
32
|
+
This is the skill documentation.
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
describe('isValidSkillDirectory', () => {
|
|
36
|
+
it('returns true for directory with valid SKILL.md', async () => {
|
|
37
|
+
await fs.writeFile(
|
|
38
|
+
path.join(tempDir, 'SKILL.md'),
|
|
39
|
+
createSkillMd('test-skill', 'A test skill')
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
expect(await isValidSkillDirectory(tempDir)).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('returns false for directory without SKILL.md', async () => {
|
|
46
|
+
expect(await isValidSkillDirectory(tempDir)).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('returns false for SKILL.md without name', async () => {
|
|
50
|
+
await fs.writeFile(
|
|
51
|
+
path.join(tempDir, 'SKILL.md'),
|
|
52
|
+
`---
|
|
53
|
+
description: A skill without name
|
|
54
|
+
---
|
|
55
|
+
`
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
expect(await isValidSkillDirectory(tempDir)).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('returns false for SKILL.md without description', async () => {
|
|
62
|
+
await fs.writeFile(
|
|
63
|
+
path.join(tempDir, 'SKILL.md'),
|
|
64
|
+
`---
|
|
65
|
+
name: nameless
|
|
66
|
+
---
|
|
67
|
+
`
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
expect(await isValidSkillDirectory(tempDir)).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('findSkillDirectories', () => {
|
|
75
|
+
it('returns empty array for directory with no skills', async () => {
|
|
76
|
+
const skills = await findSkillDirectories(tempDir);
|
|
77
|
+
expect(skills).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('finds skill if path itself is a skill', async () => {
|
|
81
|
+
await fs.writeFile(
|
|
82
|
+
path.join(tempDir, 'SKILL.md'),
|
|
83
|
+
createSkillMd('test-skill', 'A test skill')
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const skills = await findSkillDirectories(tempDir);
|
|
87
|
+
expect(skills).toHaveLength(1);
|
|
88
|
+
expect(skills[0]).toBe(tempDir);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('finds skills in direct children', async () => {
|
|
92
|
+
const skill1 = path.join(tempDir, 'skill-one');
|
|
93
|
+
const skill2 = path.join(tempDir, 'skill-two');
|
|
94
|
+
await fs.ensureDir(skill1);
|
|
95
|
+
await fs.ensureDir(skill2);
|
|
96
|
+
|
|
97
|
+
await fs.writeFile(
|
|
98
|
+
path.join(skill1, 'SKILL.md'),
|
|
99
|
+
createSkillMd('skill-one', 'First skill')
|
|
100
|
+
);
|
|
101
|
+
await fs.writeFile(
|
|
102
|
+
path.join(skill2, 'SKILL.md'),
|
|
103
|
+
createSkillMd('skill-two', 'Second skill')
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const skills = await findSkillDirectories(tempDir);
|
|
107
|
+
expect(skills).toHaveLength(2);
|
|
108
|
+
expect(skills).toContain(skill1);
|
|
109
|
+
expect(skills).toContain(skill2);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('does not search deeper than direct children', async () => {
|
|
113
|
+
const nested = path.join(tempDir, 'level1', 'level2');
|
|
114
|
+
await fs.ensureDir(nested);
|
|
115
|
+
await fs.writeFile(
|
|
116
|
+
path.join(nested, 'SKILL.md'),
|
|
117
|
+
createSkillMd('nested-skill', 'A nested skill')
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const skills = await findSkillDirectories(tempDir);
|
|
121
|
+
expect(skills).toHaveLength(0);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('readSkillMetadata', () => {
|
|
126
|
+
it('reads name and description from frontmatter', async () => {
|
|
127
|
+
await fs.writeFile(
|
|
128
|
+
path.join(tempDir, 'SKILL.md'),
|
|
129
|
+
createSkillMd('my-skill', 'My skill description')
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const metadata = await readSkillMetadata(tempDir);
|
|
133
|
+
expect(metadata).not.toBeNull();
|
|
134
|
+
expect(metadata?.name).toBe('my-skill');
|
|
135
|
+
expect(metadata?.description).toBe('My skill description');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('reads optional fields', async () => {
|
|
139
|
+
await fs.writeFile(
|
|
140
|
+
path.join(tempDir, 'SKILL.md'),
|
|
141
|
+
createSkillMd('my-skill', 'Description', `license: MIT
|
|
142
|
+
compatibility: Node.js 18+`)
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
const metadata = await readSkillMetadata(tempDir);
|
|
146
|
+
expect(metadata?.license).toBe('MIT');
|
|
147
|
+
expect(metadata?.compatibility).toBe('Node.js 18+');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('reads metadata block', async () => {
|
|
151
|
+
await fs.writeFile(
|
|
152
|
+
path.join(tempDir, 'SKILL.md'),
|
|
153
|
+
`---
|
|
154
|
+
name: my-skill
|
|
155
|
+
description: A skill
|
|
156
|
+
metadata:
|
|
157
|
+
author: Test Author
|
|
158
|
+
version: 1.0.0
|
|
159
|
+
---
|
|
160
|
+
`
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const metadata = await readSkillMetadata(tempDir);
|
|
164
|
+
expect(metadata?.metadata?.author).toBe('Test Author');
|
|
165
|
+
expect(metadata?.metadata?.version).toBe('1.0.0');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('returns null for invalid SKILL.md', async () => {
|
|
169
|
+
await fs.writeFile(
|
|
170
|
+
path.join(tempDir, 'SKILL.md'),
|
|
171
|
+
'No frontmatter here'
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const metadata = await readSkillMetadata(tempDir);
|
|
175
|
+
expect(metadata).toBeNull();
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe('getSkillName', () => {
|
|
180
|
+
it('returns name from frontmatter', async () => {
|
|
181
|
+
await fs.writeFile(
|
|
182
|
+
path.join(tempDir, 'SKILL.md'),
|
|
183
|
+
createSkillMd('canonical-name', 'Description')
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
const name = await getSkillName(tempDir);
|
|
187
|
+
expect(name).toBe('canonical-name');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('returns null for invalid skill', async () => {
|
|
191
|
+
const name = await getSkillName(tempDir);
|
|
192
|
+
expect(name).toBeNull();
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
});
|