@supercorks/skills-installer 1.10.0 → 1.11.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.
Files changed (2) hide show
  1. package/lib/git.js +122 -78
  2. package/package.json +1 -1
package/lib/git.js CHANGED
@@ -5,9 +5,16 @@
5
5
  import { execSync, spawn } from 'child_process';
6
6
  import { existsSync, mkdirSync, rmSync, writeFileSync, readFileSync, appendFileSync, readdirSync } from 'fs';
7
7
  import { join, resolve } from 'path';
8
+ import { homedir } from 'os';
8
9
  import { getRepoUrl } from './skills.js';
9
10
  import { getSubagentsRepoUrl } from './subagents.js';
10
11
 
12
+ function resolvePath(path) {
13
+ if (path === '~') return homedir();
14
+ if (path.startsWith('~/')) return resolve(homedir(), path.slice(2));
15
+ return resolve(path);
16
+ }
17
+
11
18
  /**
12
19
  * Check if git is available
13
20
  * @returns {boolean}
@@ -52,6 +59,44 @@ function runGitCommand(args, cwd) {
52
59
  });
53
60
  }
54
61
 
62
+ async function getDefaultRemoteBranch(cwd) {
63
+ try {
64
+ const output = await runGitCommand(['ls-remote', '--symref', 'origin', 'HEAD'], cwd);
65
+ const match = output.match(/ref:\s+refs\/heads\/([^\s]+)\s+HEAD/);
66
+ return match?.[1] || 'main';
67
+ } catch {
68
+ return 'main';
69
+ }
70
+ }
71
+
72
+ async function configureSparseCheckout(cwd, patterns) {
73
+ await runGitCommand(['sparse-checkout', 'init', '--no-cone'], cwd);
74
+ const sparseCheckoutPath = join(cwd, '.git', 'info', 'sparse-checkout');
75
+ writeFileSync(sparseCheckoutPath, patterns + '\n');
76
+ }
77
+
78
+ async function initializeRepoInExistingDirectory(cwd, repoUrl, patterns, onProgress) {
79
+ onProgress('Initializing git repository in existing directory...');
80
+ await runGitCommand(['init'], cwd);
81
+
82
+ try {
83
+ await runGitCommand(['remote', 'set-url', 'origin', repoUrl], cwd);
84
+ } catch {
85
+ await runGitCommand(['remote', 'add', 'origin', repoUrl], cwd);
86
+ }
87
+
88
+ onProgress('Fetching repository...');
89
+ await runGitCommand(['fetch', 'origin'], cwd);
90
+
91
+ onProgress('Configuring sparse-checkout...');
92
+ await configureSparseCheckout(cwd, patterns);
93
+
94
+ const defaultBranch = await getDefaultRemoteBranch(cwd);
95
+
96
+ onProgress('Checking out files...');
97
+ await runGitCommand(['checkout', '-B', defaultBranch, `origin/${defaultBranch}`], cwd);
98
+ }
99
+
55
100
  /**
56
101
  * Perform a sparse clone of the repository with only selected skills
57
102
  * This method preserves full git history and allows push capability
@@ -63,17 +108,20 @@ function runGitCommand(args, cwd) {
63
108
  */
64
109
  export async function sparseCloneSkills(targetPath, skillFolders, onProgress = () => {}) {
65
110
  const repoUrl = getRepoUrl();
66
- const absolutePath = resolve(targetPath);
111
+ const absolutePath = resolvePath(targetPath);
112
+ const existedBefore = existsSync(absolutePath);
113
+ const patterns = skillFolders.map(folder => `/${folder}/`).join('\n');
114
+ let shouldAdoptExistingDirectory = false;
67
115
 
68
116
  // Check if target already exists and has content
69
- if (existsSync(absolutePath)) {
117
+ if (existedBefore) {
70
118
  const gitDir = join(absolutePath, '.git');
71
119
  if (existsSync(gitDir)) {
72
120
  throw new Error(`Directory "${targetPath}" already contains a git repository. Please remove it first or choose a different path.`);
73
121
  }
74
122
  const entries = readdirSync(absolutePath).filter(name => name !== '.DS_Store');
75
123
  if (entries.length > 0) {
76
- throw new Error(`Directory "${targetPath}" already exists and is not empty. Choose an empty directory or an existing managed installation.`);
124
+ shouldAdoptExistingDirectory = true;
77
125
  }
78
126
  }
79
127
 
@@ -81,43 +129,39 @@ export async function sparseCloneSkills(targetPath, skillFolders, onProgress = (
81
129
  mkdirSync(absolutePath, { recursive: true });
82
130
 
83
131
  try {
84
- // Clone with blob filter for minimal download, no checkout yet
85
- onProgress('Initializing sparse clone...');
86
- await runGitCommand([
87
- 'clone',
88
- '--filter=blob:none',
89
- '--no-checkout',
90
- '--sparse',
91
- repoUrl,
92
- '.'
93
- ], absolutePath);
94
-
95
- // Use non-cone mode for precise control over what's checked out
96
- // This prevents root-level files (README.md, etc.) from being included
97
- onProgress('Configuring sparse-checkout...');
98
- await runGitCommand([
99
- 'sparse-checkout',
100
- 'init',
101
- '--no-cone'
102
- ], absolutePath);
103
-
104
- // Write explicit patterns - only skill folders, no root files
105
- // Format: /folder/* includes everything in that folder recursively
106
- const sparseCheckoutPath = join(absolutePath, '.git', 'info', 'sparse-checkout');
107
- const patterns = skillFolders.map(folder => `/${folder}/`).join('\n');
108
- writeFileSync(sparseCheckoutPath, patterns + '\n');
109
-
110
- // Checkout the files
111
- onProgress('Checking out files...');
112
- await runGitCommand(['checkout'], absolutePath);
132
+ if (shouldAdoptExistingDirectory) {
133
+ await initializeRepoInExistingDirectory(absolutePath, repoUrl, patterns, onProgress);
134
+ } else {
135
+ // Clone with blob filter for minimal download, no checkout yet
136
+ onProgress('Initializing sparse clone...');
137
+ await runGitCommand([
138
+ 'clone',
139
+ '--filter=blob:none',
140
+ '--no-checkout',
141
+ '--sparse',
142
+ repoUrl,
143
+ '.'
144
+ ], absolutePath);
145
+
146
+ // Use non-cone mode for precise control over what's checked out
147
+ // This prevents root-level files (README.md, etc.) from being included
148
+ onProgress('Configuring sparse-checkout...');
149
+ await configureSparseCheckout(absolutePath, patterns);
150
+
151
+ // Checkout the files
152
+ onProgress('Checking out files...');
153
+ await runGitCommand(['checkout'], absolutePath);
154
+ }
113
155
 
114
156
  onProgress('Done!');
115
157
  } catch (error) {
116
158
  // Clean up on failure
117
- try {
118
- rmSync(absolutePath, { recursive: true, force: true });
119
- } catch {
120
- // Ignore cleanup errors
159
+ if (!existedBefore) {
160
+ try {
161
+ rmSync(absolutePath, { recursive: true, force: true });
162
+ } catch {
163
+ // Ignore cleanup errors
164
+ }
121
165
  }
122
166
  throw error;
123
167
  }
@@ -132,7 +176,7 @@ export async function sparseCloneSkills(targetPath, skillFolders, onProgress = (
132
176
  * @returns {Promise<void>}
133
177
  */
134
178
  export async function updateSparseCheckout(repoPath, skillFolders, onProgress = () => {}) {
135
- const absolutePath = resolve(repoPath);
179
+ const absolutePath = resolvePath(repoPath);
136
180
 
137
181
  if (!existsSync(join(absolutePath, '.git'))) {
138
182
  throw new Error(`"${repoPath}" is not a git repository`);
@@ -166,7 +210,7 @@ export async function updateSparseCheckout(repoPath, skillFolders, onProgress =
166
210
  * @returns {Promise<void>}
167
211
  */
168
212
  export async function addSkillsToSparseCheckout(repoPath, skillFolders) {
169
- const absolutePath = resolve(repoPath);
213
+ const absolutePath = resolvePath(repoPath);
170
214
 
171
215
  if (!existsSync(join(absolutePath, '.git'))) {
172
216
  throw new Error(`"${repoPath}" is not a git repository`);
@@ -187,7 +231,7 @@ export async function addSkillsToSparseCheckout(repoPath, skillFolders) {
187
231
  * @returns {Promise<string[]>} List of skill folder names
188
232
  */
189
233
  export async function listCheckedOutSkills(repoPath) {
190
- const absolutePath = resolve(repoPath);
234
+ const absolutePath = resolvePath(repoPath);
191
235
 
192
236
  if (!existsSync(join(absolutePath, '.git'))) {
193
237
  throw new Error(`"${repoPath}" is not a git repository`);
@@ -214,7 +258,7 @@ export async function listCheckedOutSkills(repoPath) {
214
258
  * @returns {Promise<string>}
215
259
  */
216
260
  export async function pullUpdates(repoPath) {
217
- const absolutePath = resolve(repoPath);
261
+ const absolutePath = resolvePath(repoPath);
218
262
  return runGitCommand(['pull'], absolutePath);
219
263
  }
220
264
 
@@ -225,7 +269,7 @@ export async function pullUpdates(repoPath) {
225
269
  * @returns {Promise<Set<string>>} Set of skill folder names that need updates
226
270
  */
227
271
  export async function checkSkillsForUpdates(repoPath, skillFolders) {
228
- const absolutePath = resolve(repoPath);
272
+ const absolutePath = resolvePath(repoPath);
229
273
  const needsUpdate = new Set();
230
274
 
231
275
  if (!existsSync(join(absolutePath, '.git'))) {
@@ -272,7 +316,7 @@ export async function checkSkillsForUpdates(repoPath, skillFolders) {
272
316
  * @returns {Promise<Set<string>>} Set of agent filenames that need updates
273
317
  */
274
318
  export async function checkSubagentsForUpdates(repoPath, agentFilenames) {
275
- const absolutePath = resolve(repoPath);
319
+ const absolutePath = resolvePath(repoPath);
276
320
  const needsUpdate = new Set();
277
321
 
278
322
  if (!existsSync(join(absolutePath, '.git'))) {
@@ -324,17 +368,20 @@ export async function checkSubagentsForUpdates(repoPath, agentFilenames) {
324
368
  */
325
369
  export async function sparseCloneSubagents(targetPath, agentFilenames, onProgress = () => {}) {
326
370
  const repoUrl = getSubagentsRepoUrl();
327
- const absolutePath = resolve(targetPath);
371
+ const absolutePath = resolvePath(targetPath);
372
+ const existedBefore = existsSync(absolutePath);
373
+ const patterns = agentFilenames.map(filename => `/${filename}`).join('\n');
374
+ let shouldAdoptExistingDirectory = false;
328
375
 
329
376
  // Check if target already exists and has content
330
- if (existsSync(absolutePath)) {
377
+ if (existedBefore) {
331
378
  const gitDir = join(absolutePath, '.git');
332
379
  if (existsSync(gitDir)) {
333
380
  throw new Error(`Directory "${targetPath}" already contains a git repository. Please remove it first or choose a different path.`);
334
381
  }
335
382
  const entries = readdirSync(absolutePath).filter(name => name !== '.DS_Store');
336
383
  if (entries.length > 0) {
337
- throw new Error(`Directory "${targetPath}" already exists and is not empty. Choose an empty directory or an existing managed installation.`);
384
+ shouldAdoptExistingDirectory = true;
338
385
  }
339
386
  }
340
387
 
@@ -342,41 +389,38 @@ export async function sparseCloneSubagents(targetPath, agentFilenames, onProgres
342
389
  mkdirSync(absolutePath, { recursive: true });
343
390
 
344
391
  try {
345
- // Clone with blob filter for minimal download, no checkout yet
346
- onProgress('Initializing sparse clone...');
347
- await runGitCommand([
348
- 'clone',
349
- '--filter=blob:none',
350
- '--no-checkout',
351
- '--sparse',
352
- repoUrl,
353
- '.'
354
- ], absolutePath);
355
-
356
- // Use non-cone mode for precise control over what's checked out
357
- onProgress('Configuring sparse-checkout...');
358
- await runGitCommand([
359
- 'sparse-checkout',
360
- 'init',
361
- '--no-cone'
362
- ], absolutePath);
363
-
364
- // Write explicit patterns - only selected agent files
365
- const sparseCheckoutPath = join(absolutePath, '.git', 'info', 'sparse-checkout');
366
- const patterns = agentFilenames.map(filename => `/${filename}`).join('\n');
367
- writeFileSync(sparseCheckoutPath, patterns + '\n');
368
-
369
- // Checkout the files
370
- onProgress('Checking out files...');
371
- await runGitCommand(['checkout'], absolutePath);
392
+ if (shouldAdoptExistingDirectory) {
393
+ await initializeRepoInExistingDirectory(absolutePath, repoUrl, patterns, onProgress);
394
+ } else {
395
+ // Clone with blob filter for minimal download, no checkout yet
396
+ onProgress('Initializing sparse clone...');
397
+ await runGitCommand([
398
+ 'clone',
399
+ '--filter=blob:none',
400
+ '--no-checkout',
401
+ '--sparse',
402
+ repoUrl,
403
+ '.'
404
+ ], absolutePath);
405
+
406
+ // Use non-cone mode for precise control over what's checked out
407
+ onProgress('Configuring sparse-checkout...');
408
+ await configureSparseCheckout(absolutePath, patterns);
409
+
410
+ // Checkout the files
411
+ onProgress('Checking out files...');
412
+ await runGitCommand(['checkout'], absolutePath);
413
+ }
372
414
 
373
415
  onProgress('Done!');
374
416
  } catch (error) {
375
417
  // Clean up on failure
376
- try {
377
- rmSync(absolutePath, { recursive: true, force: true });
378
- } catch {
379
- // Ignore cleanup errors
418
+ if (!existedBefore) {
419
+ try {
420
+ rmSync(absolutePath, { recursive: true, force: true });
421
+ } catch {
422
+ // Ignore cleanup errors
423
+ }
380
424
  }
381
425
  throw error;
382
426
  }
@@ -390,7 +434,7 @@ export async function sparseCloneSubagents(targetPath, agentFilenames, onProgres
390
434
  * @returns {Promise<void>}
391
435
  */
392
436
  export async function updateSubagentsSparseCheckout(repoPath, agentFilenames, onProgress = () => {}) {
393
- const absolutePath = resolve(repoPath);
437
+ const absolutePath = resolvePath(repoPath);
394
438
 
395
439
  if (!existsSync(join(absolutePath, '.git'))) {
396
440
  throw new Error(`"${repoPath}" is not a git repository`);
@@ -423,7 +467,7 @@ export async function updateSubagentsSparseCheckout(repoPath, agentFilenames, on
423
467
  * @returns {Promise<string[]>} List of agent filenames
424
468
  */
425
469
  export async function listCheckedOutSubagents(repoPath) {
426
- const absolutePath = resolve(repoPath);
470
+ const absolutePath = resolvePath(repoPath);
427
471
 
428
472
  if (!existsSync(join(absolutePath, '.git'))) {
429
473
  throw new Error(`"${repoPath}" is not a git repository`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supercorks/skills-installer",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "description": "Interactive CLI installer for AI agent skills and subagents",
5
5
  "type": "module",
6
6
  "bin": {