@supercorks/skills-installer 1.10.0 → 1.11.1

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/README.md CHANGED
@@ -27,7 +27,7 @@ npx @supercorks/skills-installer install
27
27
  - `.claude/agents/` (Claude)
28
28
  - Custom path of your choice
29
29
 
30
- 3. **Gitignore option** - Optionally add the installation path to `.gitignore`
30
+ 3. **Gitignore option** - If launched from inside a git repository, optionally add the installation path to `.gitignore`
31
31
 
32
32
  4. **Select skills/subagents** - Interactive checkbox to pick what to install:
33
33
  - Use `↑`/`↓` to navigate
package/bin/install.js CHANGED
@@ -27,6 +27,7 @@ import { fetchAvailableSubagents, fetchSubagentMetadata } from '../lib/subagents
27
27
  import {
28
28
  sparseCloneSkills,
29
29
  isGitAvailable,
30
+ isInsideGitWorkTree,
30
31
  listCheckedOutSkills,
31
32
  updateSparseCheckout,
32
33
  sparseCloneSubagents,
@@ -279,8 +280,14 @@ async function runSkillsInstallForTarget(skills, existingInstalls, target) {
279
280
 
280
281
  // Ask about .gitignore (only for fresh installs and if not already in .gitignore)
281
282
  let shouldGitignore = false;
283
+ const isInGitWorkTree = isInsideGitWorkTree();
282
284
  const gitignorePath = resolveInstallPath('.gitignore');
283
- if (!isManageMode && !isHomePath(installPath) && !isInGitignore(gitignorePath, installPath)) {
285
+ if (
286
+ isInGitWorkTree &&
287
+ !isManageMode &&
288
+ !isHomePath(installPath) &&
289
+ !isInGitignore(gitignorePath, installPath)
290
+ ) {
284
291
  shouldGitignore = await promptGitignore(installPath);
285
292
  }
286
293
 
@@ -433,8 +440,14 @@ async function runSubagentsInstallForTarget(subagents, existingInstalls, target)
433
440
 
434
441
  // Ask about .gitignore (only for fresh installs and if not already in .gitignore)
435
442
  let shouldGitignore = false;
443
+ const isInGitWorkTree = isInsideGitWorkTree();
436
444
  const gitignorePath = resolveInstallPath('.gitignore');
437
- if (!isManageMode && !isHomePath(installPath) && !isInGitignore(gitignorePath, installPath)) {
445
+ if (
446
+ isInGitWorkTree &&
447
+ !isManageMode &&
448
+ !isHomePath(installPath) &&
449
+ !isInGitignore(gitignorePath, installPath)
450
+ ) {
438
451
  shouldGitignore = await promptGitignore(installPath);
439
452
  }
440
453
 
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}
@@ -21,6 +28,24 @@ export function isGitAvailable() {
21
28
  }
22
29
  }
23
30
 
31
+ /**
32
+ * Check whether a directory is inside a git work tree
33
+ * @param {string} path - Directory to check
34
+ * @returns {boolean}
35
+ */
36
+ export function isInsideGitWorkTree(path = process.cwd()) {
37
+ const absolutePath = resolvePath(path);
38
+ try {
39
+ const output = execSync('git rev-parse --is-inside-work-tree', {
40
+ cwd: absolutePath,
41
+ stdio: ['ignore', 'pipe', 'ignore']
42
+ }).toString().trim();
43
+ return output === 'true';
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
48
+
24
49
  /**
25
50
  * Execute a git command and return the result
26
51
  * @param {string[]} args - Git command arguments
@@ -52,6 +77,44 @@ function runGitCommand(args, cwd) {
52
77
  });
53
78
  }
54
79
 
80
+ async function getDefaultRemoteBranch(cwd) {
81
+ try {
82
+ const output = await runGitCommand(['ls-remote', '--symref', 'origin', 'HEAD'], cwd);
83
+ const match = output.match(/ref:\s+refs\/heads\/([^\s]+)\s+HEAD/);
84
+ return match?.[1] || 'main';
85
+ } catch {
86
+ return 'main';
87
+ }
88
+ }
89
+
90
+ async function configureSparseCheckout(cwd, patterns) {
91
+ await runGitCommand(['sparse-checkout', 'init', '--no-cone'], cwd);
92
+ const sparseCheckoutPath = join(cwd, '.git', 'info', 'sparse-checkout');
93
+ writeFileSync(sparseCheckoutPath, patterns + '\n');
94
+ }
95
+
96
+ async function initializeRepoInExistingDirectory(cwd, repoUrl, patterns, onProgress) {
97
+ onProgress('Initializing git repository in existing directory...');
98
+ await runGitCommand(['init'], cwd);
99
+
100
+ try {
101
+ await runGitCommand(['remote', 'set-url', 'origin', repoUrl], cwd);
102
+ } catch {
103
+ await runGitCommand(['remote', 'add', 'origin', repoUrl], cwd);
104
+ }
105
+
106
+ onProgress('Fetching repository...');
107
+ await runGitCommand(['fetch', 'origin'], cwd);
108
+
109
+ onProgress('Configuring sparse-checkout...');
110
+ await configureSparseCheckout(cwd, patterns);
111
+
112
+ const defaultBranch = await getDefaultRemoteBranch(cwd);
113
+
114
+ onProgress('Checking out files...');
115
+ await runGitCommand(['checkout', '-B', defaultBranch, `origin/${defaultBranch}`], cwd);
116
+ }
117
+
55
118
  /**
56
119
  * Perform a sparse clone of the repository with only selected skills
57
120
  * This method preserves full git history and allows push capability
@@ -63,17 +126,20 @@ function runGitCommand(args, cwd) {
63
126
  */
64
127
  export async function sparseCloneSkills(targetPath, skillFolders, onProgress = () => {}) {
65
128
  const repoUrl = getRepoUrl();
66
- const absolutePath = resolve(targetPath);
129
+ const absolutePath = resolvePath(targetPath);
130
+ const existedBefore = existsSync(absolutePath);
131
+ const patterns = skillFolders.map(folder => `/${folder}/`).join('\n');
132
+ let shouldAdoptExistingDirectory = false;
67
133
 
68
134
  // Check if target already exists and has content
69
- if (existsSync(absolutePath)) {
135
+ if (existedBefore) {
70
136
  const gitDir = join(absolutePath, '.git');
71
137
  if (existsSync(gitDir)) {
72
138
  throw new Error(`Directory "${targetPath}" already contains a git repository. Please remove it first or choose a different path.`);
73
139
  }
74
140
  const entries = readdirSync(absolutePath).filter(name => name !== '.DS_Store');
75
141
  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.`);
142
+ shouldAdoptExistingDirectory = true;
77
143
  }
78
144
  }
79
145
 
@@ -81,43 +147,39 @@ export async function sparseCloneSkills(targetPath, skillFolders, onProgress = (
81
147
  mkdirSync(absolutePath, { recursive: true });
82
148
 
83
149
  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);
150
+ if (shouldAdoptExistingDirectory) {
151
+ await initializeRepoInExistingDirectory(absolutePath, repoUrl, patterns, onProgress);
152
+ } else {
153
+ // Clone with blob filter for minimal download, no checkout yet
154
+ onProgress('Initializing sparse clone...');
155
+ await runGitCommand([
156
+ 'clone',
157
+ '--filter=blob:none',
158
+ '--no-checkout',
159
+ '--sparse',
160
+ repoUrl,
161
+ '.'
162
+ ], absolutePath);
163
+
164
+ // Use non-cone mode for precise control over what's checked out
165
+ // This prevents root-level files (README.md, etc.) from being included
166
+ onProgress('Configuring sparse-checkout...');
167
+ await configureSparseCheckout(absolutePath, patterns);
168
+
169
+ // Checkout the files
170
+ onProgress('Checking out files...');
171
+ await runGitCommand(['checkout'], absolutePath);
172
+ }
113
173
 
114
174
  onProgress('Done!');
115
175
  } catch (error) {
116
176
  // Clean up on failure
117
- try {
118
- rmSync(absolutePath, { recursive: true, force: true });
119
- } catch {
120
- // Ignore cleanup errors
177
+ if (!existedBefore) {
178
+ try {
179
+ rmSync(absolutePath, { recursive: true, force: true });
180
+ } catch {
181
+ // Ignore cleanup errors
182
+ }
121
183
  }
122
184
  throw error;
123
185
  }
@@ -132,7 +194,7 @@ export async function sparseCloneSkills(targetPath, skillFolders, onProgress = (
132
194
  * @returns {Promise<void>}
133
195
  */
134
196
  export async function updateSparseCheckout(repoPath, skillFolders, onProgress = () => {}) {
135
- const absolutePath = resolve(repoPath);
197
+ const absolutePath = resolvePath(repoPath);
136
198
 
137
199
  if (!existsSync(join(absolutePath, '.git'))) {
138
200
  throw new Error(`"${repoPath}" is not a git repository`);
@@ -166,7 +228,7 @@ export async function updateSparseCheckout(repoPath, skillFolders, onProgress =
166
228
  * @returns {Promise<void>}
167
229
  */
168
230
  export async function addSkillsToSparseCheckout(repoPath, skillFolders) {
169
- const absolutePath = resolve(repoPath);
231
+ const absolutePath = resolvePath(repoPath);
170
232
 
171
233
  if (!existsSync(join(absolutePath, '.git'))) {
172
234
  throw new Error(`"${repoPath}" is not a git repository`);
@@ -187,7 +249,7 @@ export async function addSkillsToSparseCheckout(repoPath, skillFolders) {
187
249
  * @returns {Promise<string[]>} List of skill folder names
188
250
  */
189
251
  export async function listCheckedOutSkills(repoPath) {
190
- const absolutePath = resolve(repoPath);
252
+ const absolutePath = resolvePath(repoPath);
191
253
 
192
254
  if (!existsSync(join(absolutePath, '.git'))) {
193
255
  throw new Error(`"${repoPath}" is not a git repository`);
@@ -214,7 +276,7 @@ export async function listCheckedOutSkills(repoPath) {
214
276
  * @returns {Promise<string>}
215
277
  */
216
278
  export async function pullUpdates(repoPath) {
217
- const absolutePath = resolve(repoPath);
279
+ const absolutePath = resolvePath(repoPath);
218
280
  return runGitCommand(['pull'], absolutePath);
219
281
  }
220
282
 
@@ -225,7 +287,7 @@ export async function pullUpdates(repoPath) {
225
287
  * @returns {Promise<Set<string>>} Set of skill folder names that need updates
226
288
  */
227
289
  export async function checkSkillsForUpdates(repoPath, skillFolders) {
228
- const absolutePath = resolve(repoPath);
290
+ const absolutePath = resolvePath(repoPath);
229
291
  const needsUpdate = new Set();
230
292
 
231
293
  if (!existsSync(join(absolutePath, '.git'))) {
@@ -272,7 +334,7 @@ export async function checkSkillsForUpdates(repoPath, skillFolders) {
272
334
  * @returns {Promise<Set<string>>} Set of agent filenames that need updates
273
335
  */
274
336
  export async function checkSubagentsForUpdates(repoPath, agentFilenames) {
275
- const absolutePath = resolve(repoPath);
337
+ const absolutePath = resolvePath(repoPath);
276
338
  const needsUpdate = new Set();
277
339
 
278
340
  if (!existsSync(join(absolutePath, '.git'))) {
@@ -324,17 +386,20 @@ export async function checkSubagentsForUpdates(repoPath, agentFilenames) {
324
386
  */
325
387
  export async function sparseCloneSubagents(targetPath, agentFilenames, onProgress = () => {}) {
326
388
  const repoUrl = getSubagentsRepoUrl();
327
- const absolutePath = resolve(targetPath);
389
+ const absolutePath = resolvePath(targetPath);
390
+ const existedBefore = existsSync(absolutePath);
391
+ const patterns = agentFilenames.map(filename => `/${filename}`).join('\n');
392
+ let shouldAdoptExistingDirectory = false;
328
393
 
329
394
  // Check if target already exists and has content
330
- if (existsSync(absolutePath)) {
395
+ if (existedBefore) {
331
396
  const gitDir = join(absolutePath, '.git');
332
397
  if (existsSync(gitDir)) {
333
398
  throw new Error(`Directory "${targetPath}" already contains a git repository. Please remove it first or choose a different path.`);
334
399
  }
335
400
  const entries = readdirSync(absolutePath).filter(name => name !== '.DS_Store');
336
401
  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.`);
402
+ shouldAdoptExistingDirectory = true;
338
403
  }
339
404
  }
340
405
 
@@ -342,41 +407,38 @@ export async function sparseCloneSubagents(targetPath, agentFilenames, onProgres
342
407
  mkdirSync(absolutePath, { recursive: true });
343
408
 
344
409
  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);
410
+ if (shouldAdoptExistingDirectory) {
411
+ await initializeRepoInExistingDirectory(absolutePath, repoUrl, patterns, onProgress);
412
+ } else {
413
+ // Clone with blob filter for minimal download, no checkout yet
414
+ onProgress('Initializing sparse clone...');
415
+ await runGitCommand([
416
+ 'clone',
417
+ '--filter=blob:none',
418
+ '--no-checkout',
419
+ '--sparse',
420
+ repoUrl,
421
+ '.'
422
+ ], absolutePath);
423
+
424
+ // Use non-cone mode for precise control over what's checked out
425
+ onProgress('Configuring sparse-checkout...');
426
+ await configureSparseCheckout(absolutePath, patterns);
427
+
428
+ // Checkout the files
429
+ onProgress('Checking out files...');
430
+ await runGitCommand(['checkout'], absolutePath);
431
+ }
372
432
 
373
433
  onProgress('Done!');
374
434
  } catch (error) {
375
435
  // Clean up on failure
376
- try {
377
- rmSync(absolutePath, { recursive: true, force: true });
378
- } catch {
379
- // Ignore cleanup errors
436
+ if (!existedBefore) {
437
+ try {
438
+ rmSync(absolutePath, { recursive: true, force: true });
439
+ } catch {
440
+ // Ignore cleanup errors
441
+ }
380
442
  }
381
443
  throw error;
382
444
  }
@@ -390,7 +452,7 @@ export async function sparseCloneSubagents(targetPath, agentFilenames, onProgres
390
452
  * @returns {Promise<void>}
391
453
  */
392
454
  export async function updateSubagentsSparseCheckout(repoPath, agentFilenames, onProgress = () => {}) {
393
- const absolutePath = resolve(repoPath);
455
+ const absolutePath = resolvePath(repoPath);
394
456
 
395
457
  if (!existsSync(join(absolutePath, '.git'))) {
396
458
  throw new Error(`"${repoPath}" is not a git repository`);
@@ -423,7 +485,7 @@ export async function updateSubagentsSparseCheckout(repoPath, agentFilenames, on
423
485
  * @returns {Promise<string[]>} List of agent filenames
424
486
  */
425
487
  export async function listCheckedOutSubagents(repoPath) {
426
- const absolutePath = resolve(repoPath);
488
+ const absolutePath = resolvePath(repoPath);
427
489
 
428
490
  if (!existsSync(join(absolutePath, '.git'))) {
429
491
  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.1",
4
4
  "description": "Interactive CLI installer for AI agent skills and subagents",
5
5
  "type": "module",
6
6
  "bin": {