@dhruvwill/skills-cli 1.0.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/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc +111 -0
- package/PRD.md +56 -0
- package/README.md +329 -0
- package/bun.lock +45 -0
- package/index.ts +2 -0
- package/package.json +37 -0
- package/src/cli.ts +205 -0
- package/src/commands/doctor.ts +248 -0
- package/src/commands/source.ts +191 -0
- package/src/commands/status.ts +84 -0
- package/src/commands/sync.ts +72 -0
- package/src/commands/target.ts +97 -0
- package/src/commands/update.ts +108 -0
- package/src/lib/config.ts +127 -0
- package/src/lib/hash.ts +80 -0
- package/src/lib/paths.ts +236 -0
- package/src/types.ts +36 -0
- package/tests/cli.test.ts +252 -0
- package/tests/config.test.ts +135 -0
- package/tests/hash.test.ts +119 -0
- package/tests/paths.test.ts +173 -0
- package/tsconfig.json +29 -0
package/src/lib/paths.ts
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { homedir } from "os";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
|
|
4
|
+
/** Root directory for skills CLI */
|
|
5
|
+
export const SKILLS_ROOT = join(homedir(), ".skills");
|
|
6
|
+
|
|
7
|
+
/** Central store for all ingested skills */
|
|
8
|
+
export const SKILLS_STORE = join(SKILLS_ROOT, "store");
|
|
9
|
+
|
|
10
|
+
/** Configuration file path */
|
|
11
|
+
export const CONFIG_PATH = join(SKILLS_ROOT, "config.json");
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Get the store path for a given namespace
|
|
15
|
+
*/
|
|
16
|
+
export function getNamespacePath(namespace: string): string {
|
|
17
|
+
return join(SKILLS_STORE, namespace);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ParsedGitUrl {
|
|
21
|
+
host: string;
|
|
22
|
+
owner: string;
|
|
23
|
+
repo: string;
|
|
24
|
+
branch?: string;
|
|
25
|
+
subdir?: string;
|
|
26
|
+
cloneUrl: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Parse a Git URL from various hosts (GitHub, GitLab, Bitbucket, self-hosted)
|
|
31
|
+
*
|
|
32
|
+
* Supports formats:
|
|
33
|
+
* - https://github.com/owner/repo
|
|
34
|
+
* - https://github.com/owner/repo/tree/branch/path
|
|
35
|
+
* - https://gitlab.com/owner/repo
|
|
36
|
+
* - https://gitlab.com/owner/repo/-/tree/branch/path
|
|
37
|
+
* - https://bitbucket.org/owner/repo
|
|
38
|
+
* - https://bitbucket.org/owner/repo/src/branch/path
|
|
39
|
+
* - https://any-host.com/owner/repo.git
|
|
40
|
+
* - git@host:owner/repo.git
|
|
41
|
+
*/
|
|
42
|
+
export function parseGitUrl(url: string): ParsedGitUrl | null {
|
|
43
|
+
// Try each parser in order
|
|
44
|
+
return parseGitHubUrl(url)
|
|
45
|
+
|| parseGitLabUrl(url)
|
|
46
|
+
|| parseBitbucketUrl(url)
|
|
47
|
+
|| parseGenericGitUrl(url);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Parse GitHub URLs
|
|
52
|
+
*/
|
|
53
|
+
function parseGitHubUrl(url: string): ParsedGitUrl | null {
|
|
54
|
+
// GitHub with subdirectory (tree or blob)
|
|
55
|
+
const subdirMatch = url.match(/github\.com\/([^\/]+)\/([^\/]+)\/(?:tree|blob)\/([^\/]+)\/(.+)/);
|
|
56
|
+
if (subdirMatch) {
|
|
57
|
+
const [, owner, repo, branch, subdir] = subdirMatch;
|
|
58
|
+
return {
|
|
59
|
+
host: "github.com",
|
|
60
|
+
owner,
|
|
61
|
+
repo: repo.replace(/\.git$/, ""),
|
|
62
|
+
branch,
|
|
63
|
+
subdir: subdir.replace(/\/$/, ""),
|
|
64
|
+
cloneUrl: `https://github.com/${owner}/${repo.replace(/\.git$/, "")}.git`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// GitHub basic HTTPS
|
|
69
|
+
const httpsMatch = url.match(/github\.com\/([^\/]+)\/([^\/\.\s]+)/);
|
|
70
|
+
if (httpsMatch) {
|
|
71
|
+
const [, owner, repo] = httpsMatch;
|
|
72
|
+
return {
|
|
73
|
+
host: "github.com",
|
|
74
|
+
owner,
|
|
75
|
+
repo: repo.replace(/\.git$/, ""),
|
|
76
|
+
cloneUrl: `https://github.com/${owner}/${repo.replace(/\.git$/, "")}.git`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// GitHub SSH
|
|
81
|
+
const sshMatch = url.match(/git@github\.com:([^\/]+)\/(.+?)(?:\.git)?$/);
|
|
82
|
+
if (sshMatch) {
|
|
83
|
+
const [, owner, repo] = sshMatch;
|
|
84
|
+
return {
|
|
85
|
+
host: "github.com",
|
|
86
|
+
owner,
|
|
87
|
+
repo: repo.replace(/\.git$/, ""),
|
|
88
|
+
cloneUrl: `git@github.com:${owner}/${repo.replace(/\.git$/, "")}.git`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Parse GitLab URLs
|
|
97
|
+
*/
|
|
98
|
+
function parseGitLabUrl(url: string): ParsedGitUrl | null {
|
|
99
|
+
// GitLab with subdirectory (uses /-/tree/ pattern)
|
|
100
|
+
const subdirMatch = url.match(/gitlab\.com\/([^\/]+)\/([^\/]+)\/-\/tree\/([^\/]+)\/(.+)/);
|
|
101
|
+
if (subdirMatch) {
|
|
102
|
+
const [, owner, repo, branch, subdir] = subdirMatch;
|
|
103
|
+
return {
|
|
104
|
+
host: "gitlab.com",
|
|
105
|
+
owner,
|
|
106
|
+
repo: repo.replace(/\.git$/, ""),
|
|
107
|
+
branch,
|
|
108
|
+
subdir: subdir.replace(/\/$/, ""),
|
|
109
|
+
cloneUrl: `https://gitlab.com/${owner}/${repo.replace(/\.git$/, "")}.git`,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// GitLab basic HTTPS
|
|
114
|
+
const httpsMatch = url.match(/gitlab\.com\/([^\/]+)\/([^\/\.\s]+)/);
|
|
115
|
+
if (httpsMatch) {
|
|
116
|
+
const [, owner, repo] = httpsMatch;
|
|
117
|
+
return {
|
|
118
|
+
host: "gitlab.com",
|
|
119
|
+
owner,
|
|
120
|
+
repo: repo.replace(/\.git$/, ""),
|
|
121
|
+
cloneUrl: `https://gitlab.com/${owner}/${repo.replace(/\.git$/, "")}.git`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// GitLab SSH
|
|
126
|
+
const sshMatch = url.match(/git@gitlab\.com:([^\/]+)\/(.+?)(?:\.git)?$/);
|
|
127
|
+
if (sshMatch) {
|
|
128
|
+
const [, owner, repo] = sshMatch;
|
|
129
|
+
return {
|
|
130
|
+
host: "gitlab.com",
|
|
131
|
+
owner,
|
|
132
|
+
repo: repo.replace(/\.git$/, ""),
|
|
133
|
+
cloneUrl: `git@gitlab.com:${owner}/${repo.replace(/\.git$/, "")}.git`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Parse Bitbucket URLs
|
|
142
|
+
*/
|
|
143
|
+
function parseBitbucketUrl(url: string): ParsedGitUrl | null {
|
|
144
|
+
// Bitbucket with subdirectory (uses /src/branch/path pattern)
|
|
145
|
+
const subdirMatch = url.match(/bitbucket\.org\/([^\/]+)\/([^\/]+)\/src\/([^\/]+)\/(.+)/);
|
|
146
|
+
if (subdirMatch) {
|
|
147
|
+
const [, owner, repo, branch, subdir] = subdirMatch;
|
|
148
|
+
return {
|
|
149
|
+
host: "bitbucket.org",
|
|
150
|
+
owner,
|
|
151
|
+
repo: repo.replace(/\.git$/, ""),
|
|
152
|
+
branch,
|
|
153
|
+
subdir: subdir.replace(/\/$/, ""),
|
|
154
|
+
cloneUrl: `https://bitbucket.org/${owner}/${repo.replace(/\.git$/, "")}.git`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Bitbucket basic HTTPS
|
|
159
|
+
const httpsMatch = url.match(/bitbucket\.org\/([^\/]+)\/([^\/\.\s]+)/);
|
|
160
|
+
if (httpsMatch) {
|
|
161
|
+
const [, owner, repo] = httpsMatch;
|
|
162
|
+
return {
|
|
163
|
+
host: "bitbucket.org",
|
|
164
|
+
owner,
|
|
165
|
+
repo: repo.replace(/\.git$/, ""),
|
|
166
|
+
cloneUrl: `https://bitbucket.org/${owner}/${repo.replace(/\.git$/, "")}.git`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Bitbucket SSH
|
|
171
|
+
const sshMatch = url.match(/git@bitbucket\.org:([^\/]+)\/(.+?)(?:\.git)?$/);
|
|
172
|
+
if (sshMatch) {
|
|
173
|
+
const [, owner, repo] = sshMatch;
|
|
174
|
+
return {
|
|
175
|
+
host: "bitbucket.org",
|
|
176
|
+
owner,
|
|
177
|
+
repo: repo.replace(/\.git$/, ""),
|
|
178
|
+
cloneUrl: `git@bitbucket.org:${owner}/${repo.replace(/\.git$/, "")}.git`,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Parse generic Git URLs (self-hosted, other providers)
|
|
187
|
+
*/
|
|
188
|
+
function parseGenericGitUrl(url: string): ParsedGitUrl | null {
|
|
189
|
+
// SSH format: git@host:owner/repo.git
|
|
190
|
+
const sshMatch = url.match(/git@([^:]+):([^\/]+)\/(.+?)(?:\.git)?$/);
|
|
191
|
+
if (sshMatch) {
|
|
192
|
+
const [, host, owner, repo] = sshMatch;
|
|
193
|
+
return {
|
|
194
|
+
host,
|
|
195
|
+
owner,
|
|
196
|
+
repo: repo.replace(/\.git$/, ""),
|
|
197
|
+
cloneUrl: url.endsWith(".git") ? url : `${url}.git`,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// HTTPS format: https://host/owner/repo.git or https://host/owner/repo
|
|
202
|
+
const httpsMatch = url.match(/https?:\/\/([^\/]+)\/([^\/]+)\/([^\/\.\s]+)/);
|
|
203
|
+
if (httpsMatch) {
|
|
204
|
+
const [, host, owner, repo] = httpsMatch;
|
|
205
|
+
return {
|
|
206
|
+
host,
|
|
207
|
+
owner,
|
|
208
|
+
repo: repo.replace(/\.git$/, ""),
|
|
209
|
+
cloneUrl: url.endsWith(".git") ? url : `${url}.git`,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Get the namespace for a parsed Git URL
|
|
218
|
+
* For subdirectories, uses owner/skill-name
|
|
219
|
+
* For full repos, uses owner/repo
|
|
220
|
+
*/
|
|
221
|
+
export function getRemoteNamespace(parsed: ParsedGitUrl): string {
|
|
222
|
+
if (parsed.subdir) {
|
|
223
|
+
// Use the last part of the subdir path as the skill name
|
|
224
|
+
const subdirName = parsed.subdir.split("/").pop() || parsed.subdir;
|
|
225
|
+
return `${parsed.owner}/${subdirName}`;
|
|
226
|
+
}
|
|
227
|
+
return `${parsed.owner}/${parsed.repo}`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Get the local namespace for a folder path
|
|
232
|
+
*/
|
|
233
|
+
export function getLocalNamespace(folderPath: string): string {
|
|
234
|
+
const folderName = folderPath.split(/[\/\\]/).filter(Boolean).pop() || "unnamed";
|
|
235
|
+
return `local/${folderName}`;
|
|
236
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source configuration - where skills originate from
|
|
3
|
+
*/
|
|
4
|
+
export interface Source {
|
|
5
|
+
/** Type of source */
|
|
6
|
+
type: "remote" | "local";
|
|
7
|
+
/** Git URL for remote sources */
|
|
8
|
+
url?: string;
|
|
9
|
+
/** Absolute path for local sources */
|
|
10
|
+
path?: string;
|
|
11
|
+
/** Store subdirectory (e.g., "anthropic/skills" or "local/my-folder") */
|
|
12
|
+
namespace: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Target configuration - where skills are synced to
|
|
17
|
+
*/
|
|
18
|
+
export interface Target {
|
|
19
|
+
/** User-friendly name (e.g., "cursor") */
|
|
20
|
+
name: string;
|
|
21
|
+
/** Absolute path to target directory */
|
|
22
|
+
path: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Main configuration file structure
|
|
27
|
+
*/
|
|
28
|
+
export interface Config {
|
|
29
|
+
sources: Source[];
|
|
30
|
+
targets: Target[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Sync status for targets
|
|
35
|
+
*/
|
|
36
|
+
export type SyncStatus = "synced" | "not synced" | "target missing";
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach, beforeAll, afterAll } from "bun:test";
|
|
2
|
+
import { mkdir, rm, writeFile, readdir } from "fs/promises";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
import { $ } from "bun";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Integration tests for CLI commands
|
|
9
|
+
*
|
|
10
|
+
* These tests create temporary directories and clean up after themselves.
|
|
11
|
+
* They test the full CLI workflow including source and target management.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
describe("CLI Integration Tests", () => {
|
|
15
|
+
let testDir: string;
|
|
16
|
+
let testSourceDir: string;
|
|
17
|
+
let testTargetDir: string;
|
|
18
|
+
|
|
19
|
+
beforeAll(async () => {
|
|
20
|
+
// Create a master test directory
|
|
21
|
+
testDir = join(tmpdir(), `skills-integration-${Date.now()}`);
|
|
22
|
+
await mkdir(testDir, { recursive: true });
|
|
23
|
+
|
|
24
|
+
// Create test subdirectories
|
|
25
|
+
testSourceDir = join(testDir, "test-source");
|
|
26
|
+
testTargetDir = join(testDir, "test-target");
|
|
27
|
+
|
|
28
|
+
await mkdir(testSourceDir, { recursive: true });
|
|
29
|
+
await mkdir(testTargetDir, { recursive: true });
|
|
30
|
+
|
|
31
|
+
// Create a test skill in source directory
|
|
32
|
+
await writeFile(join(testSourceDir, "SKILL.md"), "# Test Skill\n\nThis is a test skill.");
|
|
33
|
+
await writeFile(join(testSourceDir, "rules.md"), "# Rules\n\n- Rule 1\n- Rule 2");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterAll(async () => {
|
|
37
|
+
// Clean up test directory
|
|
38
|
+
await rm(testDir, { recursive: true, force: true });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("Local Source Management", () => {
|
|
42
|
+
test("can add a local source", async () => {
|
|
43
|
+
const result = await $`bun run src/cli.ts source add ${testSourceDir} --local`.text();
|
|
44
|
+
|
|
45
|
+
expect(result).toContain("Adding local source");
|
|
46
|
+
expect(result).toContain("Successfully added local source");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("lists the added source", async () => {
|
|
50
|
+
const result = await $`bun run src/cli.ts source list`.text();
|
|
51
|
+
|
|
52
|
+
expect(result).toContain("local/test-source");
|
|
53
|
+
expect(result).toContain("local");
|
|
54
|
+
expect(result).toContain("OK");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("can remove the source", async () => {
|
|
58
|
+
const result = await $`bun run src/cli.ts source remove local/test-source`.text();
|
|
59
|
+
|
|
60
|
+
expect(result).toContain("Removed source");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("source is no longer listed", async () => {
|
|
64
|
+
const result = await $`bun run src/cli.ts source list`.text();
|
|
65
|
+
|
|
66
|
+
// The specific test source should be removed (may have other sources)
|
|
67
|
+
expect(result).not.toContain("local/test-source");
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("Target Management", () => {
|
|
72
|
+
beforeEach(async () => {
|
|
73
|
+
// Add a source first
|
|
74
|
+
await $`bun run src/cli.ts source add ${testSourceDir} --local`.quiet();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
afterEach(async () => {
|
|
78
|
+
// Clean up
|
|
79
|
+
try {
|
|
80
|
+
await $`bun run src/cli.ts target remove test-target`.quiet();
|
|
81
|
+
} catch {}
|
|
82
|
+
try {
|
|
83
|
+
await $`bun run src/cli.ts source remove local/test-source`.quiet();
|
|
84
|
+
} catch {}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("can add a target", async () => {
|
|
88
|
+
const result = await $`bun run src/cli.ts target add test-target ${testTargetDir}`.text();
|
|
89
|
+
|
|
90
|
+
expect(result).toContain("Adding target: test-target");
|
|
91
|
+
expect(result).toContain("Successfully registered target");
|
|
92
|
+
expect(result).toContain("Performing initial sync");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("lists the added target with sync status", async () => {
|
|
96
|
+
await $`bun run src/cli.ts target add test-target ${testTargetDir}`.quiet();
|
|
97
|
+
|
|
98
|
+
const result = await $`bun run src/cli.ts target list`.text();
|
|
99
|
+
|
|
100
|
+
expect(result).toContain("test-target");
|
|
101
|
+
expect(result).toContain("synced");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("can remove the target", async () => {
|
|
105
|
+
await $`bun run src/cli.ts target add test-target ${testTargetDir}`.quiet();
|
|
106
|
+
|
|
107
|
+
const result = await $`bun run src/cli.ts target remove test-target`.text();
|
|
108
|
+
|
|
109
|
+
expect(result).toContain("Removed target");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("Sync Operations", () => {
|
|
114
|
+
beforeEach(async () => {
|
|
115
|
+
// Add source and target
|
|
116
|
+
await $`bun run src/cli.ts source add ${testSourceDir} --local`.quiet();
|
|
117
|
+
await $`bun run src/cli.ts target add test-target ${testTargetDir}`.quiet();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
afterEach(async () => {
|
|
121
|
+
try {
|
|
122
|
+
await $`bun run src/cli.ts target remove test-target`.quiet();
|
|
123
|
+
} catch {}
|
|
124
|
+
try {
|
|
125
|
+
await $`bun run src/cli.ts source remove local/test-source`.quiet();
|
|
126
|
+
} catch {}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("sync copies files to target", async () => {
|
|
130
|
+
const result = await $`bun run src/cli.ts sync`.text();
|
|
131
|
+
|
|
132
|
+
expect(result).toContain("Syncing skills to all targets");
|
|
133
|
+
expect(result).toContain("Synced");
|
|
134
|
+
expect(result).toContain("Sync complete");
|
|
135
|
+
|
|
136
|
+
// Verify files exist in target
|
|
137
|
+
const files = await readdir(testTargetDir, { recursive: true });
|
|
138
|
+
expect(files.length).toBeGreaterThan(0);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("update refreshes sources", async () => {
|
|
142
|
+
const result = await $`bun run src/cli.ts update`.text();
|
|
143
|
+
|
|
144
|
+
expect(result).toContain("Updating all sources");
|
|
145
|
+
expect(result).toContain("Updated");
|
|
146
|
+
expect(result).toContain("Update complete");
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("Status and Doctor", () => {
|
|
151
|
+
test("status shows overview", async () => {
|
|
152
|
+
const result = await $`bun run src/cli.ts status`.text();
|
|
153
|
+
|
|
154
|
+
expect(result).toContain("Skills Status");
|
|
155
|
+
expect(result).toContain("Paths");
|
|
156
|
+
expect(result).toContain("Sources");
|
|
157
|
+
expect(result).toContain("Targets");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("doctor runs diagnostics", async () => {
|
|
161
|
+
const result = await $`bun run src/cli.ts doctor`.text();
|
|
162
|
+
|
|
163
|
+
expect(result).toContain("Skills Doctor");
|
|
164
|
+
expect(result).toContain("Git");
|
|
165
|
+
expect(result).toContain("passed");
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("Help and Version", () => {
|
|
170
|
+
test("--help shows usage", async () => {
|
|
171
|
+
const result = await $`bun run src/cli.ts --help`.text();
|
|
172
|
+
|
|
173
|
+
expect(result).toContain("skills");
|
|
174
|
+
expect(result).toContain("USAGE");
|
|
175
|
+
expect(result).toContain("COMMANDS");
|
|
176
|
+
expect(result).toContain("source");
|
|
177
|
+
expect(result).toContain("target");
|
|
178
|
+
expect(result).toContain("sync");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("-h shows usage", async () => {
|
|
182
|
+
const result = await $`bun run src/cli.ts -h`.text();
|
|
183
|
+
|
|
184
|
+
expect(result).toContain("USAGE");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("--version shows version", async () => {
|
|
188
|
+
const result = await $`bun run src/cli.ts --version`.text();
|
|
189
|
+
|
|
190
|
+
expect(result).toMatch(/skills v\d+\.\d+\.\d+/);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("-v shows version", async () => {
|
|
194
|
+
const result = await $`bun run src/cli.ts -v`.text();
|
|
195
|
+
|
|
196
|
+
expect(result).toMatch(/skills v\d+\.\d+\.\d+/);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("help command shows usage", async () => {
|
|
200
|
+
const result = await $`bun run src/cli.ts help`.text();
|
|
201
|
+
|
|
202
|
+
expect(result).toContain("USAGE");
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe("Error Handling", () => {
|
|
207
|
+
test("unknown command shows error", async () => {
|
|
208
|
+
try {
|
|
209
|
+
await $`bun run src/cli.ts unknown-command`.text();
|
|
210
|
+
expect(true).toBe(false); // Should not reach
|
|
211
|
+
} catch (error: any) {
|
|
212
|
+
expect(error.stderr.toString()).toContain("Unknown command");
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("source add without flags shows error", async () => {
|
|
217
|
+
try {
|
|
218
|
+
await $`bun run src/cli.ts source add /some/path`.text();
|
|
219
|
+
expect(true).toBe(false); // Should not reach
|
|
220
|
+
} catch (error: any) {
|
|
221
|
+
expect(error.stderr.toString()).toContain("--remote or --local");
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("target add without path shows error", async () => {
|
|
226
|
+
try {
|
|
227
|
+
await $`bun run src/cli.ts target add myname`.text();
|
|
228
|
+
expect(true).toBe(false); // Should not reach
|
|
229
|
+
} catch (error: any) {
|
|
230
|
+
expect(error.stderr.toString()).toContain("Usage:");
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("removing non-existent source shows error", async () => {
|
|
235
|
+
try {
|
|
236
|
+
await $`bun run src/cli.ts source remove nonexistent/source`.text();
|
|
237
|
+
expect(true).toBe(false); // Should not reach
|
|
238
|
+
} catch (error: any) {
|
|
239
|
+
expect(error.stderr.toString()).toContain("not found");
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("removing non-existent target shows error", async () => {
|
|
244
|
+
try {
|
|
245
|
+
await $`bun run src/cli.ts target remove nonexistent`.text();
|
|
246
|
+
expect(true).toBe(false); // Should not reach
|
|
247
|
+
} catch (error: any) {
|
|
248
|
+
expect(error.stderr.toString()).toContain("not found");
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { mkdir, rm } from "fs/promises";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
|
|
6
|
+
describe("Config Management", () => {
|
|
7
|
+
let testDir: string;
|
|
8
|
+
let testConfigPath: string;
|
|
9
|
+
let testStorePath: string;
|
|
10
|
+
|
|
11
|
+
beforeEach(async () => {
|
|
12
|
+
// Create a unique temp directory for each test
|
|
13
|
+
testDir = join(tmpdir(), `skills-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
14
|
+
testConfigPath = join(testDir, "config.json");
|
|
15
|
+
testStorePath = join(testDir, "store");
|
|
16
|
+
await mkdir(testDir, { recursive: true });
|
|
17
|
+
await mkdir(testStorePath, { recursive: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
// Clean up test directory
|
|
22
|
+
await rm(testDir, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("creates default config when none exists", async () => {
|
|
26
|
+
expect(await Bun.file(testConfigPath).exists()).toBe(false);
|
|
27
|
+
|
|
28
|
+
// Write default config
|
|
29
|
+
const defaultConfig = { sources: [], targets: [] };
|
|
30
|
+
await Bun.write(testConfigPath, JSON.stringify(defaultConfig, null, 2));
|
|
31
|
+
|
|
32
|
+
// Re-check with fresh file reference
|
|
33
|
+
const configFile = Bun.file(testConfigPath);
|
|
34
|
+
expect(await configFile.exists()).toBe(true);
|
|
35
|
+
const content = await configFile.json();
|
|
36
|
+
expect(content).toEqual({ sources: [], targets: [] });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("reads existing config", async () => {
|
|
40
|
+
const config = {
|
|
41
|
+
sources: [{ type: "remote", url: "https://github.com/test/repo", namespace: "test/repo" }],
|
|
42
|
+
targets: [{ name: "cursor", path: "/home/user/.cursor/skills" }],
|
|
43
|
+
};
|
|
44
|
+
await Bun.write(testConfigPath, JSON.stringify(config, null, 2));
|
|
45
|
+
|
|
46
|
+
const content = await Bun.file(testConfigPath).json();
|
|
47
|
+
expect(content.sources).toHaveLength(1);
|
|
48
|
+
expect(content.targets).toHaveLength(1);
|
|
49
|
+
expect(content.sources[0].namespace).toBe("test/repo");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("adds source to config", async () => {
|
|
53
|
+
const config = { sources: [], targets: [] };
|
|
54
|
+
await Bun.write(testConfigPath, JSON.stringify(config, null, 2));
|
|
55
|
+
|
|
56
|
+
// Read, modify, write
|
|
57
|
+
const content = await Bun.file(testConfigPath).json();
|
|
58
|
+
content.sources.push({
|
|
59
|
+
type: "remote",
|
|
60
|
+
url: "https://github.com/new/repo",
|
|
61
|
+
namespace: "new/repo",
|
|
62
|
+
});
|
|
63
|
+
await Bun.write(testConfigPath, JSON.stringify(content, null, 2));
|
|
64
|
+
|
|
65
|
+
const updated = await Bun.file(testConfigPath).json();
|
|
66
|
+
expect(updated.sources).toHaveLength(1);
|
|
67
|
+
expect(updated.sources[0].namespace).toBe("new/repo");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("removes source from config", async () => {
|
|
71
|
+
const config = {
|
|
72
|
+
sources: [
|
|
73
|
+
{ type: "remote", url: "https://github.com/test/repo1", namespace: "test/repo1" },
|
|
74
|
+
{ type: "remote", url: "https://github.com/test/repo2", namespace: "test/repo2" },
|
|
75
|
+
],
|
|
76
|
+
targets: [],
|
|
77
|
+
};
|
|
78
|
+
await Bun.write(testConfigPath, JSON.stringify(config, null, 2));
|
|
79
|
+
|
|
80
|
+
// Read, modify, write
|
|
81
|
+
const content = await Bun.file(testConfigPath).json();
|
|
82
|
+
content.sources = content.sources.filter((s: any) => s.namespace !== "test/repo1");
|
|
83
|
+
await Bun.write(testConfigPath, JSON.stringify(content, null, 2));
|
|
84
|
+
|
|
85
|
+
const updated = await Bun.file(testConfigPath).json();
|
|
86
|
+
expect(updated.sources).toHaveLength(1);
|
|
87
|
+
expect(updated.sources[0].namespace).toBe("test/repo2");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("adds target to config", async () => {
|
|
91
|
+
const config = { sources: [], targets: [] };
|
|
92
|
+
await Bun.write(testConfigPath, JSON.stringify(config, null, 2));
|
|
93
|
+
|
|
94
|
+
const content = await Bun.file(testConfigPath).json();
|
|
95
|
+
content.targets.push({
|
|
96
|
+
name: "cursor",
|
|
97
|
+
path: "/home/user/.cursor/skills",
|
|
98
|
+
});
|
|
99
|
+
await Bun.write(testConfigPath, JSON.stringify(content, null, 2));
|
|
100
|
+
|
|
101
|
+
const updated = await Bun.file(testConfigPath).json();
|
|
102
|
+
expect(updated.targets).toHaveLength(1);
|
|
103
|
+
expect(updated.targets[0].name).toBe("cursor");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("removes target from config", async () => {
|
|
107
|
+
const config = {
|
|
108
|
+
sources: [],
|
|
109
|
+
targets: [
|
|
110
|
+
{ name: "cursor", path: "/home/user/.cursor/skills" },
|
|
111
|
+
{ name: "claude", path: "/home/user/.claude/skills" },
|
|
112
|
+
],
|
|
113
|
+
};
|
|
114
|
+
await Bun.write(testConfigPath, JSON.stringify(config, null, 2));
|
|
115
|
+
|
|
116
|
+
const content = await Bun.file(testConfigPath).json();
|
|
117
|
+
content.targets = content.targets.filter((t: any) => t.name !== "cursor");
|
|
118
|
+
await Bun.write(testConfigPath, JSON.stringify(content, null, 2));
|
|
119
|
+
|
|
120
|
+
const updated = await Bun.file(testConfigPath).json();
|
|
121
|
+
expect(updated.targets).toHaveLength(1);
|
|
122
|
+
expect(updated.targets[0].name).toBe("claude");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("handles corrupted config gracefully", async () => {
|
|
126
|
+
await Bun.write(testConfigPath, "not valid json {{{");
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
await Bun.file(testConfigPath).json();
|
|
130
|
+
expect(true).toBe(false); // Should not reach here
|
|
131
|
+
} catch (error) {
|
|
132
|
+
expect(error).toBeDefined();
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
});
|