@orderful/droid 0.2.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/.changeset/README.md +30 -0
- package/.changeset/config.json +14 -0
- package/.eslintrc.json +20 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +9 -0
- package/.github/workflows/ci.yml +47 -0
- package/.github/workflows/release.yml +45 -0
- package/CHANGELOG.md +11 -0
- package/README.md +153 -0
- package/bun.lock +571 -0
- package/dist/bin/droid.d.ts +3 -0
- package/dist/bin/droid.d.ts.map +1 -0
- package/dist/bin/droid.js +48 -0
- package/dist/bin/droid.js.map +1 -0
- package/dist/commands/config.d.ts +8 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/config.js +67 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/install.d.ts +2 -0
- package/dist/commands/install.d.ts.map +1 -0
- package/dist/commands/install.js +42 -0
- package/dist/commands/install.js.map +1 -0
- package/dist/commands/setup.d.ts +2 -0
- package/dist/commands/setup.d.ts.map +1 -0
- package/dist/commands/setup.js +132 -0
- package/dist/commands/setup.js.map +1 -0
- package/dist/commands/skills.d.ts +2 -0
- package/dist/commands/skills.d.ts.map +1 -0
- package/dist/commands/skills.js +135 -0
- package/dist/commands/skills.js.map +1 -0
- package/dist/commands/uninstall.d.ts +2 -0
- package/dist/commands/uninstall.d.ts.map +1 -0
- package/dist/commands/uninstall.js +17 -0
- package/dist/commands/uninstall.js.map +1 -0
- package/dist/commands/update.d.ts +7 -0
- package/dist/commands/update.d.ts.map +1 -0
- package/dist/commands/update.js +45 -0
- package/dist/commands/update.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/config.d.ts +46 -0
- package/dist/lib/config.d.ts.map +1 -0
- package/dist/lib/config.js +133 -0
- package/dist/lib/config.js.map +1 -0
- package/dist/lib/skill-config.d.ts +6 -0
- package/dist/lib/skill-config.d.ts.map +1 -0
- package/dist/lib/skill-config.js +80 -0
- package/dist/lib/skill-config.js.map +1 -0
- package/dist/lib/skills.d.ts +56 -0
- package/dist/lib/skills.d.ts.map +1 -0
- package/dist/lib/skills.js +245 -0
- package/dist/lib/skills.js.map +1 -0
- package/dist/lib/types.d.ts +54 -0
- package/dist/lib/types.d.ts.map +1 -0
- package/dist/lib/types.js +31 -0
- package/dist/lib/types.js.map +1 -0
- package/dist/lib/version.d.ts +10 -0
- package/dist/lib/version.d.ts.map +1 -0
- package/dist/lib/version.js +41 -0
- package/dist/lib/version.js.map +1 -0
- package/dist/skills/comments/SKILL.md +65 -0
- package/dist/skills/comments/SKILL.yaml +18 -0
- package/dist/skills/comments/commands/comments.md +48 -0
- package/package.json +58 -0
- package/src/bin/droid.ts +58 -0
- package/src/commands/config.ts +86 -0
- package/src/commands/install.ts +48 -0
- package/src/commands/setup.ts +149 -0
- package/src/commands/skills.ts +159 -0
- package/src/commands/uninstall.ts +18 -0
- package/src/commands/update.ts +58 -0
- package/src/index.ts +5 -0
- package/src/lib/config.test.ts +99 -0
- package/src/lib/config.ts +154 -0
- package/src/lib/skill-config.ts +93 -0
- package/src/lib/skills.test.ts +138 -0
- package/src/lib/skills.ts +285 -0
- package/src/lib/types.test.ts +65 -0
- package/src/lib/types.ts +68 -0
- package/src/lib/version.test.ts +23 -0
- package/src/lib/version.ts +47 -0
- package/src/skills/comments/SKILL.md +65 -0
- package/src/skills/comments/SKILL.yaml +18 -0
- package/src/skills/comments/commands/comments.md +48 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
|
2
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
import { homedir } from 'os';
|
|
6
|
+
import YAML from 'yaml';
|
|
7
|
+
import { AITool, SkillStatus } from './types.js';
|
|
8
|
+
import {
|
|
9
|
+
getSkillsInstallPath,
|
|
10
|
+
getCommandsInstallPath,
|
|
11
|
+
getAIConfigPath,
|
|
12
|
+
getSkillStatusDisplay,
|
|
13
|
+
getBundledSkillsDir,
|
|
14
|
+
} from './skills.js';
|
|
15
|
+
|
|
16
|
+
describe('getSkillsInstallPath', () => {
|
|
17
|
+
it('should return Claude Code path', () => {
|
|
18
|
+
const path = getSkillsInstallPath(AITool.ClaudeCode);
|
|
19
|
+
expect(path).toBe(join(homedir(), '.claude', 'skills'));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should return OpenCode path', () => {
|
|
23
|
+
const path = getSkillsInstallPath(AITool.OpenCode);
|
|
24
|
+
expect(path).toBe(join(homedir(), '.config', 'opencode', 'skills'));
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('getCommandsInstallPath', () => {
|
|
29
|
+
it('should return Claude Code commands path', () => {
|
|
30
|
+
const path = getCommandsInstallPath(AITool.ClaudeCode);
|
|
31
|
+
expect(path).toBe(join(homedir(), '.claude', 'commands'));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should return OpenCode commands path', () => {
|
|
35
|
+
const path = getCommandsInstallPath(AITool.OpenCode);
|
|
36
|
+
expect(path).toBe(join(homedir(), '.config', 'opencode', 'command'));
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('getAIConfigPath', () => {
|
|
41
|
+
it('should return Claude Code CLAUDE.md path', () => {
|
|
42
|
+
const path = getAIConfigPath(AITool.ClaudeCode);
|
|
43
|
+
expect(path).toBe(join(homedir(), '.claude', 'CLAUDE.md'));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should return OpenCode AGENTS.md path', () => {
|
|
47
|
+
const path = getAIConfigPath(AITool.OpenCode);
|
|
48
|
+
expect(path).toBe(join(homedir(), '.config', 'opencode', 'AGENTS.md'));
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('getBundledSkillsDir', () => {
|
|
53
|
+
it('should return a path ending in skills', () => {
|
|
54
|
+
const dir = getBundledSkillsDir();
|
|
55
|
+
expect(dir.endsWith('skills')).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should return an existing directory', () => {
|
|
59
|
+
const dir = getBundledSkillsDir();
|
|
60
|
+
expect(existsSync(dir)).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('getSkillStatusDisplay', () => {
|
|
65
|
+
it('should return [alpha] for alpha status', () => {
|
|
66
|
+
expect(getSkillStatusDisplay(SkillStatus.Alpha)).toBe('[alpha]');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should return [beta] for beta status', () => {
|
|
70
|
+
expect(getSkillStatusDisplay(SkillStatus.Beta)).toBe('[beta]');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should return empty string for stable status', () => {
|
|
74
|
+
expect(getSkillStatusDisplay(SkillStatus.Stable)).toBe('');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should return empty string for undefined status', () => {
|
|
78
|
+
expect(getSkillStatusDisplay(undefined)).toBe('');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('skill manifest parsing', () => {
|
|
83
|
+
let testDir: string;
|
|
84
|
+
|
|
85
|
+
beforeEach(() => {
|
|
86
|
+
testDir = join(tmpdir(), `droid-skill-test-${Date.now()}`);
|
|
87
|
+
mkdirSync(testDir, { recursive: true });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
afterEach(() => {
|
|
91
|
+
if (existsSync(testDir)) {
|
|
92
|
+
rmSync(testDir, { recursive: true });
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should parse valid skill manifest', () => {
|
|
97
|
+
const manifest = {
|
|
98
|
+
name: 'test-skill',
|
|
99
|
+
description: 'A test skill',
|
|
100
|
+
version: '1.0.0',
|
|
101
|
+
status: 'beta',
|
|
102
|
+
dependencies: ['comments'],
|
|
103
|
+
provides_output: true,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const manifestPath = join(testDir, 'SKILL.yaml');
|
|
107
|
+
writeFileSync(manifestPath, YAML.stringify(manifest), 'utf-8');
|
|
108
|
+
|
|
109
|
+
const content = require('fs').readFileSync(manifestPath, 'utf-8');
|
|
110
|
+
const parsed = YAML.parse(content);
|
|
111
|
+
|
|
112
|
+
expect(parsed.name).toBe('test-skill');
|
|
113
|
+
expect(parsed.description).toBe('A test skill');
|
|
114
|
+
expect(parsed.version).toBe('1.0.0');
|
|
115
|
+
expect(parsed.status).toBe('beta');
|
|
116
|
+
expect(parsed.dependencies).toEqual(['comments']);
|
|
117
|
+
expect(parsed.provides_output).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should handle manifest without optional fields', () => {
|
|
121
|
+
const manifest = {
|
|
122
|
+
name: 'minimal-skill',
|
|
123
|
+
description: 'Minimal',
|
|
124
|
+
version: '0.1.0',
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const manifestPath = join(testDir, 'SKILL.yaml');
|
|
128
|
+
writeFileSync(manifestPath, YAML.stringify(manifest), 'utf-8');
|
|
129
|
+
|
|
130
|
+
const content = require('fs').readFileSync(manifestPath, 'utf-8');
|
|
131
|
+
const parsed = YAML.parse(content);
|
|
132
|
+
|
|
133
|
+
expect(parsed.name).toBe('minimal-skill');
|
|
134
|
+
expect(parsed.status).toBeUndefined();
|
|
135
|
+
expect(parsed.dependencies).toBeUndefined();
|
|
136
|
+
expect(parsed.provides_output).toBeUndefined();
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync, cpSync, rmSync } from 'fs';
|
|
2
|
+
import { join, dirname } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import YAML from 'yaml';
|
|
6
|
+
import { loadConfig, saveConfig } from './config.js';
|
|
7
|
+
import { AITool, SkillStatus, type SkillManifest, type InstalledSkill } from './types.js';
|
|
8
|
+
|
|
9
|
+
// Marker comments for CLAUDE.md skill registration
|
|
10
|
+
const DROID_SKILLS_START = '<!-- droid-skills-start -->';
|
|
11
|
+
const DROID_SKILLS_END = '<!-- droid-skills-end -->';
|
|
12
|
+
|
|
13
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const BUNDLED_SKILLS_DIR = join(__dirname, '../skills');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Get the path to bundled skills directory
|
|
18
|
+
*/
|
|
19
|
+
export function getBundledSkillsDir(): string {
|
|
20
|
+
return BUNDLED_SKILLS_DIR;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Get the installation path for skills based on AI tool
|
|
25
|
+
*/
|
|
26
|
+
export function getSkillsInstallPath(aiTool: AITool): string {
|
|
27
|
+
switch (aiTool) {
|
|
28
|
+
case AITool.ClaudeCode:
|
|
29
|
+
return join(homedir(), '.claude', 'skills');
|
|
30
|
+
case AITool.OpenCode:
|
|
31
|
+
return join(homedir(), '.config', 'opencode', 'skills');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get the commands installation path based on AI tool
|
|
37
|
+
*/
|
|
38
|
+
export function getCommandsInstallPath(aiTool: AITool): string {
|
|
39
|
+
switch (aiTool) {
|
|
40
|
+
case AITool.ClaudeCode:
|
|
41
|
+
return join(homedir(), '.claude', 'commands');
|
|
42
|
+
case AITool.OpenCode:
|
|
43
|
+
return join(homedir(), '.config', 'opencode', 'command');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get the path to the AI tool's main config markdown file
|
|
49
|
+
*/
|
|
50
|
+
export function getAIConfigPath(aiTool: AITool): string {
|
|
51
|
+
switch (aiTool) {
|
|
52
|
+
case AITool.ClaudeCode:
|
|
53
|
+
return join(homedir(), '.claude', 'CLAUDE.md');
|
|
54
|
+
case AITool.OpenCode:
|
|
55
|
+
return join(homedir(), '.config', 'opencode', 'AGENTS.md');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Update the AI tool's config file with skill registrations
|
|
61
|
+
*/
|
|
62
|
+
export function updateAIConfigSkills(aiTool: AITool, installedSkills: string[]): void {
|
|
63
|
+
const configPath = getAIConfigPath(aiTool);
|
|
64
|
+
|
|
65
|
+
let content = '';
|
|
66
|
+
if (existsSync(configPath)) {
|
|
67
|
+
content = readFileSync(configPath, 'utf-8');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Generate skills section
|
|
71
|
+
const skillLines = installedSkills.map(name => {
|
|
72
|
+
const relativePath = `skills/${name}/SKILL.md`;
|
|
73
|
+
return `- [${name}](${relativePath})`;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const skillsSection = installedSkills.length > 0
|
|
77
|
+
? `${DROID_SKILLS_START}\n## Droid Skills\n\n${skillLines.join('\n')}\n${DROID_SKILLS_END}`
|
|
78
|
+
: '';
|
|
79
|
+
|
|
80
|
+
// Check if section already exists
|
|
81
|
+
const startIdx = content.indexOf(DROID_SKILLS_START);
|
|
82
|
+
const endIdx = content.indexOf(DROID_SKILLS_END);
|
|
83
|
+
|
|
84
|
+
if (startIdx !== -1 && endIdx !== -1) {
|
|
85
|
+
// Replace existing section
|
|
86
|
+
content = content.slice(0, startIdx) + skillsSection + content.slice(endIdx + DROID_SKILLS_END.length);
|
|
87
|
+
} else if (skillsSection) {
|
|
88
|
+
// Append new section
|
|
89
|
+
content = content.trim() + '\n\n' + skillsSection + '\n';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Ensure parent directory exists
|
|
93
|
+
const configDir = dirname(configPath);
|
|
94
|
+
if (!existsSync(configDir)) {
|
|
95
|
+
mkdirSync(configDir, { recursive: true });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
writeFileSync(configPath, content, 'utf-8');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Load a skill manifest from a skill directory
|
|
103
|
+
*/
|
|
104
|
+
export function loadSkillManifest(skillDir: string): SkillManifest | null {
|
|
105
|
+
const manifestPath = join(skillDir, 'SKILL.yaml');
|
|
106
|
+
|
|
107
|
+
if (!existsSync(manifestPath)) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const content = readFileSync(manifestPath, 'utf-8');
|
|
113
|
+
return YAML.parse(content) as SkillManifest;
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Get all bundled skills
|
|
121
|
+
*/
|
|
122
|
+
export function getBundledSkills(): SkillManifest[] {
|
|
123
|
+
if (!existsSync(BUNDLED_SKILLS_DIR)) {
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const skillDirs = readdirSync(BUNDLED_SKILLS_DIR, { withFileTypes: true })
|
|
128
|
+
.filter((dirent) => dirent.isDirectory())
|
|
129
|
+
.map((dirent) => dirent.name);
|
|
130
|
+
|
|
131
|
+
const skills: SkillManifest[] = [];
|
|
132
|
+
|
|
133
|
+
for (const skillDir of skillDirs) {
|
|
134
|
+
const manifest = loadSkillManifest(join(BUNDLED_SKILLS_DIR, skillDir));
|
|
135
|
+
if (manifest) {
|
|
136
|
+
skills.push(manifest);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return skills;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Check if a skill is installed
|
|
145
|
+
*/
|
|
146
|
+
export function isSkillInstalled(skillName: string): boolean {
|
|
147
|
+
const config = loadConfig();
|
|
148
|
+
return skillName in config.skills;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Get installed skill info
|
|
153
|
+
*/
|
|
154
|
+
export function getInstalledSkill(skillName: string): InstalledSkill | null {
|
|
155
|
+
const config = loadConfig();
|
|
156
|
+
return config.skills[skillName] || null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Install a skill
|
|
161
|
+
*/
|
|
162
|
+
export function installSkill(skillName: string): { success: boolean; message: string } {
|
|
163
|
+
const config = loadConfig();
|
|
164
|
+
const bundledSkillDir = join(BUNDLED_SKILLS_DIR, skillName);
|
|
165
|
+
|
|
166
|
+
if (!existsSync(bundledSkillDir)) {
|
|
167
|
+
return { success: false, message: `Skill '${skillName}' not found` };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const manifest = loadSkillManifest(bundledSkillDir);
|
|
171
|
+
if (!manifest) {
|
|
172
|
+
return { success: false, message: `Invalid skill manifest for '${skillName}'` };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Check dependencies
|
|
176
|
+
if (manifest.dependencies) {
|
|
177
|
+
for (const dep of manifest.dependencies) {
|
|
178
|
+
if (!isSkillInstalled(dep)) {
|
|
179
|
+
return {
|
|
180
|
+
success: false,
|
|
181
|
+
message: `Missing dependency: '${dep}'. Install it first with \`droid install ${dep}\``,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const skillsPath = getSkillsInstallPath(config.ai_tool);
|
|
188
|
+
const targetSkillDir = join(skillsPath, skillName);
|
|
189
|
+
|
|
190
|
+
// Ensure skills directory exists
|
|
191
|
+
if (!existsSync(skillsPath)) {
|
|
192
|
+
mkdirSync(skillsPath, { recursive: true });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Copy SKILL.md (the actual skill file for Claude Code / OpenCode)
|
|
196
|
+
const skillMdSource = join(bundledSkillDir, 'SKILL.md');
|
|
197
|
+
if (existsSync(skillMdSource)) {
|
|
198
|
+
if (!existsSync(targetSkillDir)) {
|
|
199
|
+
mkdirSync(targetSkillDir, { recursive: true });
|
|
200
|
+
}
|
|
201
|
+
const skillMdTarget = join(targetSkillDir, 'SKILL.md');
|
|
202
|
+
const content = readFileSync(skillMdSource, 'utf-8');
|
|
203
|
+
writeFileSync(skillMdTarget, content);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Copy commands if present
|
|
207
|
+
const commandsSource = join(bundledSkillDir, 'commands');
|
|
208
|
+
if (existsSync(commandsSource)) {
|
|
209
|
+
const commandsPath = getCommandsInstallPath(config.ai_tool);
|
|
210
|
+
if (!existsSync(commandsPath)) {
|
|
211
|
+
mkdirSync(commandsPath, { recursive: true });
|
|
212
|
+
}
|
|
213
|
+
cpSync(commandsSource, commandsPath, { recursive: true });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Update config
|
|
217
|
+
config.skills[skillName] = {
|
|
218
|
+
version: manifest.version,
|
|
219
|
+
installed_at: new Date().toISOString(),
|
|
220
|
+
};
|
|
221
|
+
saveConfig(config);
|
|
222
|
+
|
|
223
|
+
// Update AI tool's config file with skill reference
|
|
224
|
+
const installedSkillNames = Object.keys(config.skills);
|
|
225
|
+
updateAIConfigSkills(config.ai_tool, installedSkillNames);
|
|
226
|
+
|
|
227
|
+
return { success: true, message: `Installed ${skillName} v${manifest.version}` };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Uninstall a skill
|
|
232
|
+
*/
|
|
233
|
+
export function uninstallSkill(skillName: string): { success: boolean; message: string } {
|
|
234
|
+
const config = loadConfig();
|
|
235
|
+
|
|
236
|
+
if (!isSkillInstalled(skillName)) {
|
|
237
|
+
return { success: false, message: `Skill '${skillName}' is not installed` };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Remove skill files from AI tool location
|
|
241
|
+
const skillsPath = getSkillsInstallPath(config.ai_tool);
|
|
242
|
+
const skillDir = join(skillsPath, skillName);
|
|
243
|
+
if (existsSync(skillDir)) {
|
|
244
|
+
rmSync(skillDir, { recursive: true });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Remove command files if they exist
|
|
248
|
+
const bundledSkillDir = join(BUNDLED_SKILLS_DIR, skillName);
|
|
249
|
+
const commandsSource = join(bundledSkillDir, 'commands');
|
|
250
|
+
if (existsSync(commandsSource)) {
|
|
251
|
+
const commandsPath = getCommandsInstallPath(config.ai_tool);
|
|
252
|
+
const commandFiles = readdirSync(commandsSource);
|
|
253
|
+
for (const file of commandFiles) {
|
|
254
|
+
const commandPath = join(commandsPath, file);
|
|
255
|
+
if (existsSync(commandPath)) {
|
|
256
|
+
rmSync(commandPath);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Remove from config
|
|
262
|
+
delete config.skills[skillName];
|
|
263
|
+
saveConfig(config);
|
|
264
|
+
|
|
265
|
+
// Update AI tool's config file to remove skill reference
|
|
266
|
+
const installedSkillNames = Object.keys(config.skills);
|
|
267
|
+
updateAIConfigSkills(config.ai_tool, installedSkillNames);
|
|
268
|
+
|
|
269
|
+
return { success: true, message: `Uninstalled ${skillName}` };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Get skill status display string
|
|
274
|
+
*/
|
|
275
|
+
export function getSkillStatusDisplay(status?: SkillStatus): string {
|
|
276
|
+
switch (status) {
|
|
277
|
+
case SkillStatus.Alpha:
|
|
278
|
+
return '[alpha]';
|
|
279
|
+
case SkillStatus.Beta:
|
|
280
|
+
return '[beta]';
|
|
281
|
+
case SkillStatus.Stable:
|
|
282
|
+
default:
|
|
283
|
+
return '';
|
|
284
|
+
}
|
|
285
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, it, expect } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
AITool,
|
|
4
|
+
BuiltInOutput,
|
|
5
|
+
SkillStatus,
|
|
6
|
+
ConfigOptionType,
|
|
7
|
+
getAITag,
|
|
8
|
+
} from './types.js';
|
|
9
|
+
|
|
10
|
+
describe('AITool enum', () => {
|
|
11
|
+
it('should have correct values', () => {
|
|
12
|
+
expect(AITool.ClaudeCode).toBe('claude-code');
|
|
13
|
+
expect(AITool.OpenCode).toBe('opencode');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('should be usable in switch statements', () => {
|
|
17
|
+
const tool = AITool.ClaudeCode;
|
|
18
|
+
let result = '';
|
|
19
|
+
|
|
20
|
+
switch (tool) {
|
|
21
|
+
case AITool.ClaudeCode:
|
|
22
|
+
result = 'claude';
|
|
23
|
+
break;
|
|
24
|
+
case AITool.OpenCode:
|
|
25
|
+
result = 'opencode';
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
expect(result).toBe('claude');
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('BuiltInOutput enum', () => {
|
|
34
|
+
it('should have correct values', () => {
|
|
35
|
+
expect(BuiltInOutput.Terminal).toBe('terminal');
|
|
36
|
+
expect(BuiltInOutput.Editor).toBe('editor');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should only have Terminal and Editor (Brain is skill-provided)', () => {
|
|
40
|
+
const values = Object.values(BuiltInOutput);
|
|
41
|
+
expect(values).toEqual(['terminal', 'editor']);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('SkillStatus enum', () => {
|
|
46
|
+
it('should have correct values', () => {
|
|
47
|
+
expect(SkillStatus.Stable).toBe('stable');
|
|
48
|
+
expect(SkillStatus.Beta).toBe('beta');
|
|
49
|
+
expect(SkillStatus.Alpha).toBe('alpha');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('ConfigOptionType enum', () => {
|
|
54
|
+
it('should have correct values', () => {
|
|
55
|
+
expect(ConfigOptionType.String).toBe('string');
|
|
56
|
+
expect(ConfigOptionType.Boolean).toBe('boolean');
|
|
57
|
+
expect(ConfigOptionType.Select).toBe('select');
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('getAITag', () => {
|
|
62
|
+
it('should always return @droid', () => {
|
|
63
|
+
expect(getAITag()).toBe('@droid');
|
|
64
|
+
});
|
|
65
|
+
});
|
package/src/lib/types.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export enum AITool {
|
|
2
|
+
ClaudeCode = 'claude-code',
|
|
3
|
+
OpenCode = 'opencode',
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Get the AI's mention tag
|
|
8
|
+
* Always @droid - consistent branding regardless of underlying AI tool
|
|
9
|
+
*/
|
|
10
|
+
export function getAITag(): string {
|
|
11
|
+
return '@droid';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Built-in output preferences (always available)
|
|
15
|
+
export enum BuiltInOutput {
|
|
16
|
+
Terminal = 'terminal',
|
|
17
|
+
Editor = 'editor',
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Output preference in config can be a built-in OR a skill name
|
|
21
|
+
export type OutputPreference = BuiltInOutput | string;
|
|
22
|
+
|
|
23
|
+
export enum SkillStatus {
|
|
24
|
+
Stable = 'stable',
|
|
25
|
+
Beta = 'beta',
|
|
26
|
+
Alpha = 'alpha',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export enum ConfigOptionType {
|
|
30
|
+
String = 'string',
|
|
31
|
+
Boolean = 'boolean',
|
|
32
|
+
Select = 'select',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface DroidConfig {
|
|
36
|
+
ai_tool: AITool;
|
|
37
|
+
user_mention: string;
|
|
38
|
+
output_preference: OutputPreference;
|
|
39
|
+
git_username: string;
|
|
40
|
+
skills: Record<string, InstalledSkill>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface InstalledSkill {
|
|
44
|
+
version: string;
|
|
45
|
+
installed_at: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface SkillManifest {
|
|
49
|
+
name: string;
|
|
50
|
+
description: string;
|
|
51
|
+
version: string;
|
|
52
|
+
status?: SkillStatus;
|
|
53
|
+
dependencies?: string[];
|
|
54
|
+
config_schema?: Record<string, ConfigOption>;
|
|
55
|
+
// Skills can declare they provide an output target
|
|
56
|
+
provides_output?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ConfigOption {
|
|
60
|
+
type: ConfigOptionType;
|
|
61
|
+
description: string;
|
|
62
|
+
default?: string | boolean;
|
|
63
|
+
options?: string[]; // for select type
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface SkillOverrides {
|
|
67
|
+
[key: string]: string | boolean | number;
|
|
68
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, it, expect } from 'bun:test';
|
|
2
|
+
import { getVersion } from './version.js';
|
|
3
|
+
|
|
4
|
+
describe('getVersion', () => {
|
|
5
|
+
it('should return a version string', () => {
|
|
6
|
+
const version = getVersion();
|
|
7
|
+
expect(typeof version).toBe('string');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('should return a valid semver format', () => {
|
|
11
|
+
const version = getVersion();
|
|
12
|
+
// Basic semver check: major.minor.patch
|
|
13
|
+
const semverRegex = /^\d+\.\d+\.\d+/;
|
|
14
|
+
expect(version).toMatch(semverRegex);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('should return version from package.json', () => {
|
|
18
|
+
const version = getVersion();
|
|
19
|
+
// Version should be a non-empty semver string (actual value changes with releases)
|
|
20
|
+
expect(version.length).toBeGreaterThan(0);
|
|
21
|
+
expect(version).not.toBe('0.0.0');
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { fileURLToPath } from 'url';
|
|
3
|
+
import { dirname, join } from 'path';
|
|
4
|
+
import { execSync } from 'child_process';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const packageJsonPath = join(__dirname, '../../package.json');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Get the current version from package.json
|
|
12
|
+
*/
|
|
13
|
+
export function getVersion(): string {
|
|
14
|
+
try {
|
|
15
|
+
const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
|
16
|
+
return pkg.version as string;
|
|
17
|
+
} catch {
|
|
18
|
+
return '0.0.0';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Check for updates (non-blocking)
|
|
24
|
+
* Shows a message if a new version is available
|
|
25
|
+
*/
|
|
26
|
+
export async function checkForUpdates(): Promise<void> {
|
|
27
|
+
try {
|
|
28
|
+
const currentVersion = getVersion();
|
|
29
|
+
|
|
30
|
+
// Use npm view to check latest version
|
|
31
|
+
const latestVersion = execSync('npm view @orderful/droid version 2>/dev/null', {
|
|
32
|
+
encoding: 'utf-8',
|
|
33
|
+
timeout: 3000,
|
|
34
|
+
}).trim();
|
|
35
|
+
|
|
36
|
+
if (latestVersion && latestVersion !== currentVersion) {
|
|
37
|
+
console.log(
|
|
38
|
+
chalk.yellow(
|
|
39
|
+
`\n⚠️ A new version of droid is available (${currentVersion} → ${latestVersion})`
|
|
40
|
+
)
|
|
41
|
+
);
|
|
42
|
+
console.log(chalk.yellow(` Run ${chalk.bold('droid update')} to upgrade\n`));
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
// Silently fail - update check is non-critical
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Comments Skill
|
|
2
|
+
|
|
3
|
+
Enable inline conversation in any file using `> @droid` and `> @{user}` markers.
|
|
4
|
+
|
|
5
|
+
## How It Works
|
|
6
|
+
|
|
7
|
+
Leave comments for the AI using `> @droid`:
|
|
8
|
+
|
|
9
|
+
```markdown
|
|
10
|
+
> @droid What do you think of this approach?
|
|
11
|
+
|
|
12
|
+
> @droid Can you add error handling here?
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The AI will respond with `> @{user_mention}` (configured in droid setup, e.g., `@fry`):
|
|
16
|
+
|
|
17
|
+
```markdown
|
|
18
|
+
> @fry I think this approach is solid. One consideration...
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Tag Convention
|
|
22
|
+
|
|
23
|
+
| Who | Tag |
|
|
24
|
+
|-----|-----|
|
|
25
|
+
| AI | `@droid` (+ any configured aliases) |
|
|
26
|
+
| User | Configured (e.g., `@fry`) |
|
|
27
|
+
|
|
28
|
+
## Commands
|
|
29
|
+
|
|
30
|
+
- `/comments check` - Scan for AI markers and address each one
|
|
31
|
+
- `/comments check {path}` - Scope to specific file or directory
|
|
32
|
+
- `/comments cleanup` - Find resolved comment threads and remove them
|
|
33
|
+
|
|
34
|
+
## Behavior
|
|
35
|
+
|
|
36
|
+
### On `/comments check`:
|
|
37
|
+
1. Search for `> @droid` (and any configured `ai_mentions`) in the specified scope
|
|
38
|
+
2. If there's a `git diff`, check those files first for relevant context
|
|
39
|
+
3. For each comment, determine intent:
|
|
40
|
+
- **Action request** ("do X", "add Y", "fix Z") → Execute the action, remove the comment
|
|
41
|
+
- **Question** ("what do you think", "should we") → Respond with `> @{user_mention}`, keep original comment
|
|
42
|
+
|
|
43
|
+
### On `/comments cleanup`:
|
|
44
|
+
1. Find AI tag and `> @{user_mention}` pairs where conversation appears resolved
|
|
45
|
+
2. Remove both markers
|
|
46
|
+
3. Output a summary of what was discussed/decided
|
|
47
|
+
|
|
48
|
+
## Configuration
|
|
49
|
+
|
|
50
|
+
Configured via `~/.droid/skills/comments/overrides.yaml` or inherits from global `~/.droid/config.yaml`:
|
|
51
|
+
|
|
52
|
+
```yaml
|
|
53
|
+
# Override the user mention (default: from global config)
|
|
54
|
+
user_mention: "@fry"
|
|
55
|
+
|
|
56
|
+
# Additional AI mentions to recognize (e.g., if you're used to @claude)
|
|
57
|
+
ai_mentions: "@claude"
|
|
58
|
+
|
|
59
|
+
# Keep comments after addressing them (default: false)
|
|
60
|
+
preserve_comments: false
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## File Type Support
|
|
64
|
+
|
|
65
|
+
Works in any text file. Respects `.gitignore` and skips common build directories (node_modules, dist, .git, etc.).
|