|
22 | 22 | // -y .agents/skills/<skill> links into node_modules/<package>/… |
23 | 23 | // .claude/skills/<skill> links into .agents/skills/<skill> |
24 | 24 | // |
25 | | -// and with nothing to read it prints "Nothing to sync." and exits 0. |
| 25 | +// With nothing to read it prints "Nothing to sync." and exits 0; a skill whose |
| 26 | +// directory is already there and whose lock entry is not a sync's (one from |
| 27 | +// `skills add`) is SKIPPED, left as it was, and the run still exits 0. |
26 | 28 | // |
27 | 29 | // ## Why both CLIs are stubbed, and how the stubs are kept honest |
28 | 30 | // |
@@ -164,21 +166,32 @@ for (const dep of deps) { |
164 | 166 | } |
165 | 167 | } |
166 | 168 | if (found.length === 0) { console.log('Nothing to sync.'); process.exit(0); } |
| 169 | +const lockPath = path.join(cwd, 'skills-lock.json'); |
| 170 | +let prior = { version: 1, skills: {} }; |
| 171 | +try { prior = JSON.parse(fs.readFileSync(lockPath, 'utf8')); } catch {} |
| 172 | +const synced = found.filter((skill) => { |
| 173 | + const taken = SHAPES[key].some((dest) => fs.existsSync(path.join(cwd, dest.dir, skill.name))); |
| 174 | + if (taken && prior.skills?.[skill.name]?.sourceType !== 'node_modules') { |
| 175 | + console.warn('Skipped ' + skill.name + ' from ' + skill.dep + ': installed with skills add'); |
| 176 | + return false; |
| 177 | + } |
| 178 | + return true; |
| 179 | +}); |
167 | 180 | for (const dest of SHAPES[key]) { |
168 | 181 | fs.mkdirSync(path.join(cwd, dest.dir), { recursive: true }); |
169 | | - for (const skill of found) { |
| 182 | + for (const skill of synced) { |
170 | 183 | const target = path.join(cwd, dest.dir, skill.name); |
171 | 184 | if (dest.kind === 'copy') fs.cpSync(skill.src, target, { recursive: true }); |
172 | 185 | else if (dest.kind === 'link-package') fs.symlinkSync(path.relative(path.dirname(target), skill.src), target); |
173 | 186 | else fs.symlinkSync(dest.into + '/' + skill.name, target); |
174 | 187 | } |
175 | 188 | } |
176 | | -const lock = { version: 1, skills: {} }; |
177 | | -for (const s of found) { |
| 189 | +const lock = { version: 1, skills: { ...prior.skills } }; |
| 190 | +for (const s of synced) { |
178 | 191 | lock.skills[s.name] = { source: s.dep, sourceType: 'node_modules', skillPath: s.skillPath, version: s.version }; |
179 | 192 | } |
180 | 193 | fs.writeFileSync(path.join(cwd, 'skills-lock.json'), JSON.stringify(lock, null, 2) + '\\n'); |
181 | | -console.log('npx-stub: synced ' + found.length + ' skills'); |
| 194 | +console.log('npx-stub: synced ' + synced.length + ' skills'); |
182 | 195 | `; |
183 | 196 | } |
184 | 197 |
|
@@ -427,6 +440,33 @@ describe('without a package to read, no skills — and the run says how to get t |
427 | 440 | expect(block).toContain(skillsInstallHint(DEFAULT_SKILLS_AGENT)); |
428 | 441 | }); |
429 | 442 |
|
| 443 | + // Scaffolding into the current directory is the one path onto existing |
| 444 | + // content. A skill that `skills add` put there earlier is SKIPPED by the |
| 445 | + // sync, exit 0, its directory still present — so neither the exit code nor |
| 446 | + // the directory can be the verdict; the lock entry naming the package is. |
| 447 | + it('a skill already there from `skills add` is reported, not counted as installed', () => { |
| 448 | + const project = path.join(TMP, 'here-app'); |
| 449 | + const legacy = path.join(project, '.claude', 'skills', 'objectstack-data'); |
| 450 | + fs.mkdirSync(legacy, { recursive: true }); |
| 451 | + fs.writeFileSync(path.join(legacy, 'SKILL.md'), '---\nname: objectstack-data\n---\n\n# from main\n'); |
| 452 | + fs.writeFileSync( |
| 453 | + path.join(project, 'skills-lock.json'), |
| 454 | + JSON.stringify({ version: 1, skills: { 'objectstack-data': { source: 'objectstack-ai/objectstack', sourceType: 'github' } } }) + '\n', |
| 455 | + ); |
| 456 | + const receipt = path.join(TMP, 'here-app.npx-argv.log'); |
| 457 | + const stdout = execFileSync(TSX, [INDEX_TS], { |
| 458 | + cwd: project, |
| 459 | + encoding: 'utf8', |
| 460 | + env: { ...process.env, NPX_STUB_RECEIPT: receipt, PATH: `${BIN}${path.delimiter}${process.env.PATH ?? ''}` }, |
| 461 | + }); |
| 462 | + expect(fs.readFileSync(receipt, 'utf8').trim().split('\n')).toHaveLength(1); |
| 463 | + expect(listSkills(path.join(project, DEFAULT_SKILLS_DIR))).toEqual(STUB_CATALOG); |
| 464 | + expect(fs.readFileSync(path.join(legacy, 'SKILL.md'), 'utf8')).toContain('# from main'); |
| 465 | + expect(stdout).toContain('1 of 3 skill(s)'); |
| 466 | + expect(stdout).toContain('(objectstack-data)'); |
| 467 | + expect(stdout).not.toContain('Installed for'); |
| 468 | + }); |
| 469 | + |
430 | 470 | // The sync exits 0 with "Nothing to sync." when there is no package, so a |
431 | 471 | // scaffolder that trusted the exit code would print "Installed". |
432 | 472 | it('an install that leaves no catalog: the run says so, and never claims Installed', () => { |
|
0 commit comments