@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,246 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { SUPPORTED_AGENTS } from './agents.js';
|
|
5
|
+
import { computeContentHash } from './skill-hash.js';
|
|
6
|
+
import type { SkillMetadata, SkillInstallation, AgentType } from '../types/index.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Parse YAML-like frontmatter from SKILL.md content.
|
|
10
|
+
* Frontmatter is delimited by --- at start and end.
|
|
11
|
+
* Handles nested metadata field as key-value pairs.
|
|
12
|
+
*/
|
|
13
|
+
function parseFrontmatter(content: string): SkillMetadata | null {
|
|
14
|
+
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
15
|
+
if (!frontmatterMatch) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const lines = frontmatterMatch[1].split('\n');
|
|
20
|
+
let name = '';
|
|
21
|
+
let description = '';
|
|
22
|
+
let license: string | undefined;
|
|
23
|
+
let compatibility: string | undefined;
|
|
24
|
+
let allowedTools: string | undefined;
|
|
25
|
+
const metadata: Record<string, string> = {};
|
|
26
|
+
|
|
27
|
+
let inMetadata = false;
|
|
28
|
+
|
|
29
|
+
for (const line of lines) {
|
|
30
|
+
// Check if we're entering metadata block (indented or explicit)
|
|
31
|
+
if (line.match(/^metadata:\s*$/)) {
|
|
32
|
+
inMetadata = true;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// If line starts with non-whitespace, we're out of metadata block
|
|
37
|
+
if (inMetadata && line.match(/^\S/)) {
|
|
38
|
+
inMetadata = false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const colonIndex = line.indexOf(':');
|
|
42
|
+
if (colonIndex > 0) {
|
|
43
|
+
const key = line.slice(0, colonIndex).trim();
|
|
44
|
+
let value = line.slice(colonIndex + 1).trim();
|
|
45
|
+
|
|
46
|
+
// Remove surrounding quotes if present
|
|
47
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
48
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
49
|
+
value = value.slice(1, -1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (inMetadata) {
|
|
53
|
+
// Nested under metadata
|
|
54
|
+
metadata[key] = value;
|
|
55
|
+
} else {
|
|
56
|
+
// Top-level fields
|
|
57
|
+
switch (key) {
|
|
58
|
+
case 'name':
|
|
59
|
+
name = value;
|
|
60
|
+
break;
|
|
61
|
+
case 'description':
|
|
62
|
+
description = value;
|
|
63
|
+
break;
|
|
64
|
+
case 'license':
|
|
65
|
+
license = value;
|
|
66
|
+
break;
|
|
67
|
+
case 'compatibility':
|
|
68
|
+
compatibility = value;
|
|
69
|
+
break;
|
|
70
|
+
case 'allowed-tools':
|
|
71
|
+
allowedTools = value;
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// name and description are required
|
|
79
|
+
if (!name || !description) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
name,
|
|
85
|
+
description,
|
|
86
|
+
license,
|
|
87
|
+
compatibility,
|
|
88
|
+
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
|
|
89
|
+
allowedTools
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Check if a directory is a valid skill.
|
|
95
|
+
* A valid skill must contain a SKILL.md with required frontmatter (name, description).
|
|
96
|
+
*/
|
|
97
|
+
export async function isValidSkillDirectory(dirPath: string): Promise<boolean> {
|
|
98
|
+
const skillMdPath = path.join(dirPath, 'SKILL.md');
|
|
99
|
+
|
|
100
|
+
if (!await fs.pathExists(skillMdPath)) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const content = await fs.readFile(skillMdPath, 'utf-8');
|
|
106
|
+
const metadata = parseFrontmatter(content);
|
|
107
|
+
return metadata !== null;
|
|
108
|
+
} catch {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Find skill directories within a given path.
|
|
115
|
+
* Only checks the path itself and its direct children.
|
|
116
|
+
* A skill is any directory containing a valid SKILL.md file (with name + description).
|
|
117
|
+
*/
|
|
118
|
+
export async function findSkillDirectories(searchPath: string): Promise<string[]> {
|
|
119
|
+
const skills: string[] = [];
|
|
120
|
+
|
|
121
|
+
// Check if the path itself is a skill
|
|
122
|
+
if (await isValidSkillDirectory(searchPath)) {
|
|
123
|
+
skills.push(searchPath);
|
|
124
|
+
return skills;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Check direct children only
|
|
128
|
+
try {
|
|
129
|
+
const entries = await fs.readdir(searchPath, { withFileTypes: true });
|
|
130
|
+
for (const entry of entries) {
|
|
131
|
+
if (entry.isDirectory()) {
|
|
132
|
+
const childPath = path.join(searchPath, entry.name);
|
|
133
|
+
if (await isValidSkillDirectory(childPath)) {
|
|
134
|
+
skills.push(childPath);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
} catch {
|
|
139
|
+
// Permission denied or other error, skip
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return skills;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Read skill metadata from SKILL.md frontmatter.
|
|
147
|
+
* The canonical skill name comes from the frontmatter, not the folder name.
|
|
148
|
+
*/
|
|
149
|
+
export async function readSkillMetadata(skillPath: string): Promise<SkillMetadata | null> {
|
|
150
|
+
const skillMdPath = path.join(skillPath, 'SKILL.md');
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
const content = await fs.readFile(skillMdPath, 'utf-8');
|
|
154
|
+
return parseFrontmatter(content);
|
|
155
|
+
} catch {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Get the canonical skill name from SKILL.md frontmatter.
|
|
162
|
+
* Returns null if not a valid skill.
|
|
163
|
+
*/
|
|
164
|
+
export async function getSkillName(skillPath: string): Promise<string | null> {
|
|
165
|
+
const metadata = await readSkillMetadata(skillPath);
|
|
166
|
+
return metadata?.name || null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Find all installations of a skill by name across all agents.
|
|
171
|
+
*/
|
|
172
|
+
export async function findSkillInstallations(skillName: string): Promise<SkillInstallation[]> {
|
|
173
|
+
const installations: SkillInstallation[] = [];
|
|
174
|
+
|
|
175
|
+
// Check both local and global for each agent
|
|
176
|
+
const locations = [
|
|
177
|
+
{ base: process.cwd(), isGlobal: false },
|
|
178
|
+
{ base: os.homedir(), isGlobal: true }
|
|
179
|
+
];
|
|
180
|
+
|
|
181
|
+
for (const { base, isGlobal } of locations) {
|
|
182
|
+
for (const agent of SUPPORTED_AGENTS) {
|
|
183
|
+
const skillPath = path.join(base, agent.folderName, 'skills', skillName);
|
|
184
|
+
|
|
185
|
+
if (await fs.pathExists(skillPath)) {
|
|
186
|
+
const metadata = await readSkillMetadata(skillPath);
|
|
187
|
+
if (metadata) {
|
|
188
|
+
const contentHash = await computeContentHash(skillPath);
|
|
189
|
+
|
|
190
|
+
installations.push({
|
|
191
|
+
agent: agent.flag as AgentType,
|
|
192
|
+
path: skillPath,
|
|
193
|
+
isGlobal,
|
|
194
|
+
metadata,
|
|
195
|
+
contentHash
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return installations;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* List all installed skills for a specific agent.
|
|
207
|
+
*/
|
|
208
|
+
export async function listSkillsForAgent(
|
|
209
|
+
agentFolderName: string,
|
|
210
|
+
isGlobal: boolean
|
|
211
|
+
): Promise<string[]> {
|
|
212
|
+
const base = isGlobal ? os.homedir() : process.cwd();
|
|
213
|
+
const skillsDir = path.join(base, agentFolderName, 'skills');
|
|
214
|
+
|
|
215
|
+
if (!await fs.pathExists(skillsDir)) {
|
|
216
|
+
return [];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const entries = await fs.readdir(skillsDir, { withFileTypes: true });
|
|
220
|
+
const skills: string[] = [];
|
|
221
|
+
|
|
222
|
+
for (const entry of entries) {
|
|
223
|
+
if (entry.isDirectory()) {
|
|
224
|
+
const skillPath = path.join(skillsDir, entry.name);
|
|
225
|
+
const skillName = await getSkillName(skillPath);
|
|
226
|
+
if (skillName) {
|
|
227
|
+
skills.push(skillName);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return skills;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Check if a skill exists in an agent's folder.
|
|
237
|
+
*/
|
|
238
|
+
export async function skillExists(
|
|
239
|
+
skillName: string,
|
|
240
|
+
agentFolderName: string,
|
|
241
|
+
isGlobal: boolean
|
|
242
|
+
): Promise<boolean> {
|
|
243
|
+
const base = isGlobal ? os.homedir() : process.cwd();
|
|
244
|
+
const skillPath = path.join(base, agentFolderName, 'skills', skillName);
|
|
245
|
+
return (await fs.pathExists(skillPath)) && (await isValidSkillDirectory(skillPath));
|
|
246
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { execSync } from 'child_process';
|
|
5
|
+
import { getAgentByFlag } from './agents.js';
|
|
6
|
+
import { resolveSkillSource } from './skill-source.js';
|
|
7
|
+
import { findSkillDirectories, readSkillMetadata } from './skill-info.js';
|
|
8
|
+
import type { AgentType, SkillSource } from '../types/index.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Get the destination path for installing a skill.
|
|
12
|
+
* Skill name comes from SKILL.md frontmatter.
|
|
13
|
+
*/
|
|
14
|
+
function getSkillDestination(skillName: string, agentFlag: AgentType, isGlobal: boolean): string {
|
|
15
|
+
const agent = getAgentByFlag(agentFlag);
|
|
16
|
+
if (!agent) {
|
|
17
|
+
throw new Error(`Unknown agent: ${agentFlag}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const base = isGlobal ? os.homedir() : process.cwd();
|
|
21
|
+
return path.join(base, agent.folderName, 'skills', skillName);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Install a single skill directory to an agent.
|
|
26
|
+
* The skill name is taken from SKILL.md frontmatter (not the folder name).
|
|
27
|
+
* The destination folder will be named after the frontmatter name.
|
|
28
|
+
*/
|
|
29
|
+
async function installSkillDirectory(
|
|
30
|
+
skillDir: string,
|
|
31
|
+
agentFlag: AgentType,
|
|
32
|
+
isGlobal: boolean
|
|
33
|
+
): Promise<string> {
|
|
34
|
+
// Get metadata from SKILL.md frontmatter (name and description are required)
|
|
35
|
+
const metadata = await readSkillMetadata(skillDir);
|
|
36
|
+
if (!metadata) {
|
|
37
|
+
throw new Error(`Invalid skill at "${skillDir}": SKILL.md must have name and description in frontmatter`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const dest = getSkillDestination(metadata.name, agentFlag, isGlobal);
|
|
41
|
+
|
|
42
|
+
// Ensure parent directory exists
|
|
43
|
+
await fs.ensureDir(path.dirname(dest));
|
|
44
|
+
|
|
45
|
+
// Copy the skill folder (folder will be renamed to match frontmatter name)
|
|
46
|
+
await fs.copy(skillDir, dest, { overwrite: true });
|
|
47
|
+
|
|
48
|
+
return metadata.name;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Install skill(s) from a local path.
|
|
53
|
+
* Checks if path is a skill, otherwise checks direct children for SKILL.md.
|
|
54
|
+
*/
|
|
55
|
+
export async function installFromLocal(
|
|
56
|
+
source: SkillSource,
|
|
57
|
+
agentFlag: AgentType,
|
|
58
|
+
isGlobal: boolean
|
|
59
|
+
): Promise<string[]> {
|
|
60
|
+
const skillDirs = await findSkillDirectories(source.location);
|
|
61
|
+
|
|
62
|
+
if (skillDirs.length === 0) {
|
|
63
|
+
throw new Error(`No skills found in "${source.location}". A skill must contain a SKILL.md file.`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const installedSkills: string[] = [];
|
|
67
|
+
for (const skillDir of skillDirs) {
|
|
68
|
+
const skillName = await installSkillDirectory(skillDir, agentFlag, isGlobal);
|
|
69
|
+
installedSkills.push(skillName);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return installedSkills;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Install skill(s) from GitHub using degit.
|
|
77
|
+
* After downloading, checks if it's a skill or searches direct children for SKILL.md.
|
|
78
|
+
*/
|
|
79
|
+
export async function installFromGithub(
|
|
80
|
+
source: SkillSource,
|
|
81
|
+
agentFlag: AgentType,
|
|
82
|
+
isGlobal: boolean
|
|
83
|
+
): Promise<string[]> {
|
|
84
|
+
// Create a temp directory to download to
|
|
85
|
+
const tempDir = path.join(os.tmpdir(), `sun-install-${Date.now()}`);
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
// Download using degit
|
|
89
|
+
await fs.ensureDir(tempDir);
|
|
90
|
+
try {
|
|
91
|
+
execSync(`npx degit ${source.location} "${tempDir}"`, {
|
|
92
|
+
stdio: 'pipe'
|
|
93
|
+
});
|
|
94
|
+
} catch (error) {
|
|
95
|
+
const err = error as Error & { stderr?: Buffer };
|
|
96
|
+
const stderr = err.stderr?.toString() || err.message;
|
|
97
|
+
throw new Error(`Failed to download from GitHub: ${stderr}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Find skills in downloaded content (checks itself and direct children)
|
|
101
|
+
const skillDirs = await findSkillDirectories(tempDir);
|
|
102
|
+
|
|
103
|
+
if (skillDirs.length === 0) {
|
|
104
|
+
throw new Error(`No skills found in "${source.originalInput}". A skill must contain a SKILL.md file.`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Install each skill found (name comes from SKILL.md frontmatter)
|
|
108
|
+
const installedSkills: string[] = [];
|
|
109
|
+
for (const skillDir of skillDirs) {
|
|
110
|
+
const skillName = await installSkillDirectory(skillDir, agentFlag, isGlobal);
|
|
111
|
+
installedSkills.push(skillName);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return installedSkills;
|
|
115
|
+
} finally {
|
|
116
|
+
// Clean up temp directory
|
|
117
|
+
await fs.remove(tempDir).catch(() => {});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Install skill(s) from any source to an agent.
|
|
123
|
+
* Returns list of installed skill names.
|
|
124
|
+
*/
|
|
125
|
+
export async function installSkill(
|
|
126
|
+
skillInput: string,
|
|
127
|
+
agentFlag: AgentType,
|
|
128
|
+
isGlobal: boolean
|
|
129
|
+
): Promise<{ skillNames: string[]; source: SkillSource }> {
|
|
130
|
+
const source = resolveSkillSource(skillInput);
|
|
131
|
+
|
|
132
|
+
let skillNames: string[];
|
|
133
|
+
|
|
134
|
+
switch (source.type) {
|
|
135
|
+
case 'local':
|
|
136
|
+
skillNames = await installFromLocal(source, agentFlag, isGlobal);
|
|
137
|
+
break;
|
|
138
|
+
case 'github':
|
|
139
|
+
case 'shortcut':
|
|
140
|
+
skillNames = await installFromGithub(source, agentFlag, isGlobal);
|
|
141
|
+
break;
|
|
142
|
+
default:
|
|
143
|
+
throw new Error(`Unknown source type: ${(source as SkillSource).type}`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { skillNames, source };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Remove a skill from an agent.
|
|
151
|
+
*/
|
|
152
|
+
export async function removeSkill(
|
|
153
|
+
skillName: string,
|
|
154
|
+
agentFlag: AgentType,
|
|
155
|
+
isGlobal: boolean
|
|
156
|
+
): Promise<boolean> {
|
|
157
|
+
const dest = getSkillDestination(skillName, agentFlag, isGlobal);
|
|
158
|
+
|
|
159
|
+
if (await fs.pathExists(dest)) {
|
|
160
|
+
await fs.remove(dest);
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { isShortcut, getShortcutUrl } from '../utils/registry.js';
|
|
4
|
+
import type { SkillSource } from '../types/index.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Check if input looks like a GitHub URL or reference.
|
|
8
|
+
* Matches: github.com/user/repo, https://github.com/..., etc.
|
|
9
|
+
*/
|
|
10
|
+
export function isGithubUrl(input: string): boolean {
|
|
11
|
+
return input.includes('github.com');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Check if input looks like a local file path.
|
|
16
|
+
* Matches: ./path, ../path, ~/path, /absolute/path
|
|
17
|
+
*/
|
|
18
|
+
export function isLocalPath(input: string): boolean {
|
|
19
|
+
// Check if it starts with path indicators
|
|
20
|
+
if (input.startsWith('./') ||
|
|
21
|
+
input.startsWith('../') ||
|
|
22
|
+
input.startsWith('~/') ||
|
|
23
|
+
input.startsWith('/')) {
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Check if it's an existing path on disk
|
|
28
|
+
const resolved = path.resolve(input);
|
|
29
|
+
return fs.pathExistsSync(resolved);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve a skill input to its source type and location.
|
|
34
|
+
*
|
|
35
|
+
* Resolution order:
|
|
36
|
+
* 1. Check if it's a registered shortcut (e.g., "tinker")
|
|
37
|
+
* 2. Check if it contains "github.com" (treat as GitHub URL)
|
|
38
|
+
* 3. Check if it's a valid local path
|
|
39
|
+
* 4. Otherwise, throw error
|
|
40
|
+
*/
|
|
41
|
+
export function resolveSkillSource(input: string): SkillSource {
|
|
42
|
+
// 1. Check shortcuts first
|
|
43
|
+
if (isShortcut(input)) {
|
|
44
|
+
const url = getShortcutUrl(input)!;
|
|
45
|
+
return {
|
|
46
|
+
type: 'shortcut',
|
|
47
|
+
location: url,
|
|
48
|
+
originalInput: input
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 2. Check if it's a GitHub URL
|
|
53
|
+
if (isGithubUrl(input)) {
|
|
54
|
+
// Normalize the URL for degit
|
|
55
|
+
let location = input;
|
|
56
|
+
|
|
57
|
+
// Remove https:// or http:// prefix if present
|
|
58
|
+
location = location.replace(/^https?:\/\//, '');
|
|
59
|
+
|
|
60
|
+
// Handle github.com/user/repo/tree/branch/path format
|
|
61
|
+
// Convert to degit format: user/repo/path#branch
|
|
62
|
+
const treeMatch = location.match(/^github\.com\/([^/]+)\/([^/]+)\/tree\/([^/]+)\/(.+)$/);
|
|
63
|
+
if (treeMatch) {
|
|
64
|
+
const [, user, repo, branch, subpath] = treeMatch;
|
|
65
|
+
location = `${user}/${repo}/${subpath}#${branch}`;
|
|
66
|
+
} else {
|
|
67
|
+
// Simple format: github.com/user/repo -> user/repo
|
|
68
|
+
location = location.replace(/^github\.com\//, '');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
type: 'github',
|
|
73
|
+
location,
|
|
74
|
+
originalInput: input
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 3. Check if it's a local path
|
|
79
|
+
if (isLocalPath(input)) {
|
|
80
|
+
// Resolve to absolute path, handling ~
|
|
81
|
+
let location = input;
|
|
82
|
+
if (location.startsWith('~/')) {
|
|
83
|
+
location = path.join(process.env.HOME || '', location.slice(2));
|
|
84
|
+
}
|
|
85
|
+
location = path.resolve(location);
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
type: 'local',
|
|
89
|
+
location,
|
|
90
|
+
originalInput: input
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 4. Not found
|
|
95
|
+
throw new Error(`Skill not found: "${input}". Expected a shortcut name, GitHub URL, or local path.`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Extract skill name from a source.
|
|
100
|
+
* For GitHub: last path segment
|
|
101
|
+
* For local: folder name
|
|
102
|
+
* For shortcut: the shortcut name itself
|
|
103
|
+
*/
|
|
104
|
+
export function getSkillNameFromSource(source: SkillSource): string {
|
|
105
|
+
if (source.type === 'shortcut') {
|
|
106
|
+
return source.originalInput;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (source.type === 'local') {
|
|
110
|
+
return path.basename(source.location);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// GitHub: extract from path (e.g., "user/repo/skills/tinker#main" -> "tinker")
|
|
114
|
+
const parts = source.location.split('/');
|
|
115
|
+
const lastPart = parts[parts.length - 1];
|
|
116
|
+
// Remove branch suffix if present (e.g., "tinker#main" -> "tinker")
|
|
117
|
+
return lastPart.split('#')[0];
|
|
118
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { SUPPORTED_AGENTS } from './core/agents.js';
|
|
6
|
+
import { addCommand } from './commands/add.js';
|
|
7
|
+
import { removeCommand } from './commands/remove.js';
|
|
8
|
+
import { listCommand } from './commands/list.js';
|
|
9
|
+
import { showCommand } from './commands/show.js';
|
|
10
|
+
import { configCommand } from './commands/config.js';
|
|
11
|
+
import { suggestCommand, getValidCommands } from './utils/fuzzy-match.js';
|
|
12
|
+
import type { CommandFlags } from './types/index.js';
|
|
13
|
+
|
|
14
|
+
const program = new Command();
|
|
15
|
+
|
|
16
|
+
program
|
|
17
|
+
.name('sun')
|
|
18
|
+
.description('Sundial CLI - Manage skills for your AI agents')
|
|
19
|
+
.version('0.1.0');
|
|
20
|
+
|
|
21
|
+
// Add command
|
|
22
|
+
const add = program
|
|
23
|
+
.command('add <skills...>')
|
|
24
|
+
.description('Add skill(s) to agent configuration(s)')
|
|
25
|
+
.option('--global', 'Install to global agent config (~/.claude/, ~/.codex/, etc.)');
|
|
26
|
+
|
|
27
|
+
// Add agent flags dynamically
|
|
28
|
+
for (const agent of SUPPORTED_AGENTS) {
|
|
29
|
+
add.option(`--${agent.flag}`, `Install to ${agent.name}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
add.action(async (skills: string[], options: CommandFlags) => {
|
|
33
|
+
try {
|
|
34
|
+
await addCommand(skills, options);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
console.error(chalk.red(error instanceof Error ? error.message : String(error)));
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Remove command
|
|
42
|
+
const remove = program
|
|
43
|
+
.command('remove <skills...>')
|
|
44
|
+
.description('Remove skill(s) from agent configuration(s)')
|
|
45
|
+
.option('--global', 'Remove from global config');
|
|
46
|
+
|
|
47
|
+
for (const agent of SUPPORTED_AGENTS) {
|
|
48
|
+
remove.option(`--${agent.flag}`, `Remove from ${agent.name}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
remove.action(async (skills: string[], options: CommandFlags) => {
|
|
52
|
+
try {
|
|
53
|
+
await removeCommand(skills, options);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
console.error(chalk.red(error instanceof Error ? error.message : String(error)));
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// List command
|
|
61
|
+
program
|
|
62
|
+
.command('list')
|
|
63
|
+
.description('List all installed skills for each agent')
|
|
64
|
+
.action(async () => {
|
|
65
|
+
try {
|
|
66
|
+
await listCommand();
|
|
67
|
+
} catch (error) {
|
|
68
|
+
console.error(chalk.red(error instanceof Error ? error.message : String(error)));
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Show command
|
|
74
|
+
program
|
|
75
|
+
.command('show <skill>')
|
|
76
|
+
.description('Show skill details and installation locations')
|
|
77
|
+
.action(async (skill: string) => {
|
|
78
|
+
try {
|
|
79
|
+
await showCommand(skill);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
console.error(chalk.red(error instanceof Error ? error.message : String(error)));
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Config command
|
|
87
|
+
program
|
|
88
|
+
.command('config')
|
|
89
|
+
.description('Configure default agents')
|
|
90
|
+
.action(async () => {
|
|
91
|
+
try {
|
|
92
|
+
await configCommand();
|
|
93
|
+
} catch (error) {
|
|
94
|
+
console.error(chalk.red(error instanceof Error ? error.message : String(error)));
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Handle unknown commands with fuzzy matching
|
|
100
|
+
program.on('command:*', (operands) => {
|
|
101
|
+
const unknownCommand = operands[0];
|
|
102
|
+
const suggestion = suggestCommand(unknownCommand);
|
|
103
|
+
|
|
104
|
+
console.error(chalk.red(`Error: Unknown command '${unknownCommand}'`));
|
|
105
|
+
|
|
106
|
+
if (suggestion) {
|
|
107
|
+
console.error(chalk.yellow(`Did you mean '${suggestion}'?`));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
console.error();
|
|
111
|
+
console.error(`Valid commands: ${getValidCommands().join(', ')}`);
|
|
112
|
+
process.exit(1);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// Parse and execute
|
|
116
|
+
program.parse();
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export type AgentType = 'claude' | 'codex' | 'gemini';
|
|
2
|
+
|
|
3
|
+
export interface AgentConfig {
|
|
4
|
+
name: string;
|
|
5
|
+
flag: string;
|
|
6
|
+
folderName: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface SunConfig {
|
|
10
|
+
defaultAgents: AgentType[];
|
|
11
|
+
firstRunComplete: boolean;
|
|
12
|
+
skillRegistryUrl?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Skill metadata parsed from SKILL.md frontmatter.
|
|
17
|
+
* Per spec: https://agentskills.io/specification#skill-md-format
|
|
18
|
+
*/
|
|
19
|
+
export interface SkillMetadata {
|
|
20
|
+
/** Required: Max 64 chars, lowercase letters, numbers, hyphens only */
|
|
21
|
+
name: string;
|
|
22
|
+
/** Required: Max 1024 chars, describes what the skill does */
|
|
23
|
+
description: string;
|
|
24
|
+
/** Optional: License name or reference to bundled license file */
|
|
25
|
+
license?: string;
|
|
26
|
+
/** Optional: Max 500 chars, environment requirements */
|
|
27
|
+
compatibility?: string;
|
|
28
|
+
/** Optional: Arbitrary key-value mapping (includes author, version, etc.) */
|
|
29
|
+
metadata?: Record<string, string>;
|
|
30
|
+
/** Optional: Space-delimited list of pre-approved tools (experimental) */
|
|
31
|
+
allowedTools?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type SkillSourceType = 'shortcut' | 'github' | 'local';
|
|
35
|
+
|
|
36
|
+
/** Used at install time to determine how to fetch a skill */
|
|
37
|
+
export interface SkillSource {
|
|
38
|
+
type: SkillSourceType;
|
|
39
|
+
/** The resolved location (URL for github/shortcut, path for local) */
|
|
40
|
+
location: string;
|
|
41
|
+
/** Original input string from user (e.g., "tinker" or "github.com/user/skill") */
|
|
42
|
+
originalInput: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface SkillInstallation {
|
|
46
|
+
agent: AgentType;
|
|
47
|
+
path: string;
|
|
48
|
+
isGlobal: boolean;
|
|
49
|
+
metadata: SkillMetadata;
|
|
50
|
+
contentHash: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface CommandFlags {
|
|
54
|
+
global?: boolean;
|
|
55
|
+
claude?: boolean;
|
|
56
|
+
codex?: boolean;
|
|
57
|
+
gemini?: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface DetectedAgent {
|
|
61
|
+
agent: AgentConfig;
|
|
62
|
+
path: string;
|
|
63
|
+
isGlobal: boolean;
|
|
64
|
+
}
|