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