cli-jaw 1.4.4 → 1.4.7

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.
@@ -250,79 +250,171 @@ export function syncToAll(config) {
250
250
  }
251
251
  return results;
252
252
  }
253
- // ─── Skills symlink helper ─────────────────────────
253
+ function buildLinkReport(links, extra) {
254
+ const summary = links.reduce((acc, item) => {
255
+ const key = item.action || 'unknown';
256
+ acc[key] = (acc[key] || 0) + 1;
257
+ return acc;
258
+ }, {});
259
+ return { links, summary, ...extra };
260
+ }
254
261
  /**
255
- * Ensure {workingDir}/.agents/skills ~/.cli-jaw/skills
256
- * Also ensure ~/.agent/skills ~/.agents/skills (compat)
257
- * Also ensure {workingDir}/.claude/skills + ~/.claude/skills (Claude Code CLI)
262
+ * Working-dir only: {workingDir}/.agents/skills, optionally .claude/skills.
263
+ * NEVER touches home shared paths (~/.agents, ~/.agent, ~/.claude).
258
264
  */
259
- export function ensureSkillsSymlinks(workingDir, opts = {}) {
260
- const onConflict = opts.onConflict === 'skip' ? 'skip' : 'backup';
261
- const skillsSource = join(JAW_HOME, 'skills');
265
+ export function ensureWorkingDirSkillsLinks(workingDir, opts = {}) {
266
+ const { _homedir = os.homedir(), _jawHome = JAW_HOME, onConflict = 'skip', includeClaude = false } = opts;
267
+ const skillsSource = join(_jawHome, 'skills');
262
268
  fs.mkdirSync(skillsSource, { recursive: true });
263
- const backupContext = createBackupContext();
269
+ // CRITICAL: workingDir === homedir → skip to prevent implicit shared path creation
270
+ const resolvedWd = resolve(workingDir);
271
+ if (resolvedWd === resolve(_homedir)) {
272
+ return buildLinkReport([], {
273
+ skipped: true,
274
+ reason: 'workingDir is homedir — use ensureSharedHomeSkillsLinks() for explicit opt-in',
275
+ source: skillsSource,
276
+ });
277
+ }
278
+ const { allowReplaceManaged = false } = opts;
279
+ const backupContext = createBackupContext(_jawHome);
280
+ const safeOpts = { onConflict, backupContext, allowReplaceManaged, jawHome: _jawHome };
264
281
  const links = [];
265
- // 1. {workingDir}/.agents/skills → ~/.cli-jaw/skills
282
+ // 1. {workingDir}/.agents/skills → skills source
266
283
  const wdLink = join(workingDir, '.agents', 'skills');
267
- links.push(ensureSymlinkSafe(skillsSource, wdLink, { onConflict, backupContext, name: 'wdAgents' }));
268
- // 2. Home fallback: ~/.agents/skills (if different from workingDir)
269
- const homeLink = join(os.homedir(), '.agents', 'skills');
270
- if (homeLink !== wdLink) {
284
+ links.push(ensureSymlinkSafe(skillsSource, wdLink, { ...safeOpts, name: 'wdAgents' }));
285
+ // 2. Optionally {workingDir}/.claude/skills skills source
286
+ if (includeClaude) {
287
+ const wdClaudeSkills = join(workingDir, '.claude', 'skills');
288
+ links.push(ensureSymlinkSafe(skillsSource, wdClaudeSkills, { ...safeOpts, name: 'wdClaude' }));
289
+ }
290
+ return buildLinkReport(links, {
291
+ skipped: false,
292
+ source: skillsSource,
293
+ strategy: onConflict,
294
+ backupRoot: backupContext.root,
295
+ });
296
+ }
297
+ /**
298
+ * Opt-in only: create shared home symlinks (~/.agents/skills, ~/.agent/skills, ~/.claude/skills).
299
+ * Must NEVER be called by default — only via explicit env flag or CLI command.
300
+ */
301
+ export function ensureSharedHomeSkillsLinks(opts = {}) {
302
+ const { _homedir = os.homedir(), _jawHome = JAW_HOME, onConflict = 'backup', includeAgents = true, includeCompatAgent = true, includeClaude = true, } = opts;
303
+ const skillsSource = join(_jawHome, 'skills');
304
+ fs.mkdirSync(skillsSource, { recursive: true });
305
+ const backupContext = createBackupContext(_jawHome);
306
+ const links = [];
307
+ if (includeAgents) {
308
+ const homeLink = join(_homedir, '.agents', 'skills');
271
309
  links.push(ensureSymlinkSafe(skillsSource, homeLink, { onConflict, backupContext, name: 'homeAgents' }));
272
310
  }
273
- else {
274
- links.push({
275
- status: 'skip',
276
- action: 'same_path',
277
- name: 'homeAgents',
278
- linkPath: homeLink,
279
- target: skillsSource,
280
- });
311
+ if (includeCompatAgent) {
312
+ const homeAgents = join(_homedir, '.agents', 'skills');
313
+ const compatLink = join(_homedir, '.agent', 'skills');
314
+ links.push(ensureSymlinkSafe(homeAgents, compatLink, { onConflict, backupContext, name: 'compatAgent' }));
281
315
  }
282
- // 3. Compat: ~/.agent/skills → ~/.agents/skills
283
- const compatLink = join(os.homedir(), '.agent', 'skills');
284
- links.push(ensureSymlinkSafe(homeLink, compatLink, { onConflict, backupContext, name: 'compatAgent' }));
285
- // 4. Claude Code CLI: {workingDir}/.claude/skills → ~/.cli-jaw/skills
286
- const wdClaudeSkills = join(workingDir, '.claude', 'skills');
287
- links.push(ensureSymlinkSafe(skillsSource, wdClaudeSkills, { onConflict, backupContext, name: 'wdClaude' }));
288
- // 5. Home Claude Code: ~/.claude/skills → ~/.cli-jaw/skills
289
- const homeClaudeSkills = join(os.homedir(), '.claude', 'skills');
290
- if (homeClaudeSkills !== wdClaudeSkills) {
316
+ if (includeClaude) {
317
+ const homeClaudeSkills = join(_homedir, '.claude', 'skills');
291
318
  links.push(ensureSymlinkSafe(skillsSource, homeClaudeSkills, { onConflict, backupContext, name: 'homeClaude' }));
292
319
  }
293
- else {
294
- links.push({
295
- status: 'skip',
296
- action: 'same_path',
297
- name: 'homeClaude',
298
- linkPath: homeClaudeSkills,
299
- target: skillsSource,
300
- });
301
- }
302
- const summary = links.reduce((acc, item) => {
303
- const key = item.action || 'unknown';
304
- acc[key] = (acc[key] || 0) + 1;
305
- return acc;
306
- }, {});
307
- return {
320
+ return buildLinkReport(links, {
308
321
  source: skillsSource,
309
322
  strategy: onConflict,
310
323
  backupRoot: backupContext.root,
311
- links,
312
- summary,
313
- };
324
+ });
325
+ }
326
+ /**
327
+ * Detect shared path contamination: check if cli-jaw has taken over home shared paths.
328
+ */
329
+ export function detectSharedPathContamination(opts) {
330
+ const homedir = opts?._homedir ?? os.homedir();
331
+ const jawHome = opts?._jawHome ?? JAW_HOME;
332
+ const skillsTarget = join(jawHome, 'skills');
333
+ const sharedPaths = [
334
+ join(homedir, '.agents', 'skills'),
335
+ join(homedir, '.agent', 'skills'),
336
+ join(homedir, '.claude', 'skills'),
337
+ ];
338
+ const paths = sharedPaths.map(p => {
339
+ const exists = fs.existsSync(p);
340
+ let isSymlink = false;
341
+ let target = null;
342
+ let isCliJaw = false;
343
+ if (exists) {
344
+ try {
345
+ const stat = fs.lstatSync(p);
346
+ isSymlink = stat.isSymbolicLink();
347
+ if (isSymlink) {
348
+ const rawTarget = fs.readlinkSync(p);
349
+ target = resolveSymlinkTarget(p, rawTarget);
350
+ isCliJaw = resolve(target) === resolve(skillsTarget);
351
+ }
352
+ }
353
+ catch { /* ignore */ }
354
+ }
355
+ return { path: p, exists, isSymlink, target, isCliJaw };
356
+ });
357
+ // Check backup traces
358
+ const backupDir = join(jawHome, 'backups', 'skills-conflicts');
359
+ const backupTraces = [];
360
+ if (fs.existsSync(backupDir)) {
361
+ try {
362
+ backupTraces.push(...fs.readdirSync(backupDir).map(f => join(backupDir, f)));
363
+ }
364
+ catch { /* ignore */ }
365
+ }
366
+ const contaminated = paths.filter(p => p.isCliJaw);
367
+ let status = 'clean';
368
+ let summary = 'No shared path contamination detected';
369
+ if (contaminated.length > 0) {
370
+ status = 'contaminated';
371
+ const pathList = contaminated.map(p => p.path).join(', ');
372
+ summary = `cli-jaw symlinks found at shared paths: ${pathList}`;
373
+ }
374
+ else if (backupTraces.length > 0) {
375
+ // Backup traces without active symlinks = previously resolved, not active contamination
376
+ status = 'resolved';
377
+ summary = `No active symlinks; backup traces preserved for rollback (${backupTraces.length} file(s))`;
378
+ }
379
+ return { status, paths, backupTraces, summary };
380
+ }
381
+ /**
382
+ * @deprecated Use ensureWorkingDirSkillsLinks or ensureSharedHomeSkillsLinks instead.
383
+ * Kept only for backward compatibility during transition — delegates to new helpers.
384
+ */
385
+ export function ensureSkillsSymlinks(workingDir, opts = {}) {
386
+ // Delegate to working-dir-only helper (isolated-by-default)
387
+ return ensureWorkingDirSkillsLinks(workingDir, {
388
+ onConflict: opts.onConflict === 'skip' ? 'skip' : 'backup',
389
+ includeClaude: true,
390
+ });
314
391
  }
315
- function createBackupContext() {
392
+ function createBackupContext(jawHome) {
316
393
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
317
- return { root: join(JAW_HOME, 'backups', 'skills-conflicts', stamp) };
394
+ return { root: join(jawHome ?? JAW_HOME, 'backups', 'skills-conflicts', stamp) };
318
395
  }
319
396
  function resolveSymlinkTarget(linkPath, rawTarget) {
320
397
  return isAbsolute(rawTarget)
321
398
  ? resolve(rawTarget)
322
399
  : resolve(dirname(linkPath), rawTarget);
323
400
  }
401
+ function isCliJawManaged(linkPath, jawHome) {
402
+ try {
403
+ const stat = fs.lstatSync(linkPath);
404
+ if (stat.isSymbolicLink()) {
405
+ const rawTarget = fs.readlinkSync(linkPath);
406
+ const currentTarget = resolveSymlinkTarget(linkPath, rawTarget);
407
+ // Symlink pointing into any .cli-jaw directory is managed
408
+ return jawHome ? currentTarget.startsWith(resolve(jawHome)) : currentTarget.includes('.cli-jaw');
409
+ }
410
+ }
411
+ catch { /* not a symlink or doesn't exist */ }
412
+ return false;
413
+ }
324
414
  function ensureSymlinkSafe(target, linkPath, opts = {}) {
325
415
  const onConflict = opts.onConflict === 'skip' ? 'skip' : 'backup';
416
+ const allowReplaceManaged = opts.allowReplaceManaged === true;
417
+ const jawHome = opts.jawHome;
326
418
  const backupContext = opts.backupContext || createBackupContext();
327
419
  const absTarget = resolve(target);
328
420
  const baseResult = {
@@ -338,18 +430,31 @@ function ensureSymlinkSafe(target, linkPath, opts = {}) {
338
430
  if (currentTarget === absTarget) {
339
431
  return { ...baseResult, status: 'ok', action: 'noop' };
340
432
  }
341
- fs.unlinkSync(linkPath);
342
- fs.mkdirSync(dirname(linkPath), { recursive: true });
343
- fs.symlinkSync(target, linkPath);
344
- console.log(`[skills] symlink(updated): ${linkPath} → ${target}`);
345
- return {
346
- ...baseResult,
347
- status: 'ok',
348
- action: 'replace_symlink',
349
- previousTarget: rawTarget,
350
- };
433
+ // Stale symlink: only replace if managed by cli-jaw AND caller opts in
434
+ const managed = isCliJawManaged(linkPath, jawHome);
435
+ if (managed && allowReplaceManaged) {
436
+ fs.unlinkSync(linkPath);
437
+ fs.mkdirSync(dirname(linkPath), { recursive: true });
438
+ fs.symlinkSync(target, linkPath);
439
+ console.log(`[skills] symlink(updated): ${linkPath} → ${target}`);
440
+ return {
441
+ ...baseResult,
442
+ status: 'ok',
443
+ action: 'replace_symlink',
444
+ previousTarget: rawTarget,
445
+ managed,
446
+ };
447
+ }
448
+ // Not managed, respect onConflict
449
+ if (onConflict === 'skip') {
450
+ console.warn(`[skills] conflict(skip): ${linkPath} (unmanaged symlink preserved)`);
451
+ return { ...baseResult, status: 'skip', action: 'conflict_skip' };
452
+ }
453
+ // backup mode: fall through to backup_replace below
351
454
  }
352
455
  if (onConflict === 'skip') {
456
+ // Non-symlink path: check if allowReplaceManaged applies
457
+ // (real dirs are never "managed" — only symlinks can be reliably attributed to cli-jaw)
353
458
  console.warn(`[skills] conflict(skip): ${linkPath} (existing path preserved)`);
354
459
  return { ...baseResult, status: 'skip', action: 'conflict_skip' };
355
460
  }
@@ -543,7 +648,7 @@ const CODEX_ACTIVE = new Set([
543
648
  const OPENCLAW_ACTIVE = new Set([
544
649
  'browser', 'notion', 'memory', 'vision-click',
545
650
  'screen-capture', 'docx', 'xlsx', 'pptx', 'hwp', 'github', 'telegram-send',
546
- 'video',
651
+ 'video', 'pdf-vision',
547
652
  ]);
548
653
  function getSkillVersion(id, registry) {
549
654
  return registry?.skills?.[id]?.version ?? null;
@@ -610,31 +715,41 @@ export function copyDefaultSkills() {
610
715
  console.log(`[skills] Codex: not installed, using bundled fallback`);
611
716
  }
612
717
  // ─── 2. Populate skills_ref/ ─────────────────────
613
- // Priority: bundled (dev) → git clone (npm install) → offline fallback
718
+ // Priority: git clone (always latest) → bundled fallback (dev) → offline
614
719
  const packageRefDir = join(findPackageRoot(), 'skills_ref');
615
720
  const SKILLS_REPO = 'https://github.com/lidge-jun/cli-jaw-skills.git';
616
- if (fs.existsSync(packageRefDir)) {
617
- // Dev / local: copy from bundled skills_ref/ (version-aware)
618
- const srcReg = loadRegistry(packageRefDir);
721
+ let skillsSourceResolved = false;
722
+ // 2a. Try GitHub clone first (public repo, no auth needed)
723
+ try {
724
+ const tmpClone = join(JAW_HOME, '.skills_clone_tmp');
725
+ if (fs.existsSync(tmpClone))
726
+ fs.rmSync(tmpClone, { recursive: true });
727
+ console.log(`[skills] fetching latest skills from GitHub...`);
728
+ execSync(`git clone --depth 1 ${SKILLS_REPO} "${tmpClone}"`, {
729
+ stdio: 'pipe', timeout: 120000,
730
+ });
731
+ // Version-aware merge from clone
732
+ const srcReg = loadRegistry(tmpClone);
619
733
  const dstReg = loadRegistry(refDir);
620
- const entries = fs.readdirSync(packageRefDir, { withFileTypes: true });
621
- let refCopied = 0, refUpdated = 0;
622
- for (const entry of entries) {
623
- const src = join(packageRefDir, entry.name);
734
+ const cloned = fs.readdirSync(tmpClone, { withFileTypes: true });
735
+ let cloneNew = 0, cloneUpdated = 0;
736
+ for (const entry of cloned) {
737
+ if (entry.name === '.git')
738
+ continue;
739
+ const src = join(tmpClone, entry.name);
624
740
  const dst = join(refDir, entry.name);
625
741
  if (entry.isDirectory()) {
626
742
  if (!fs.existsSync(dst)) {
627
743
  copyDirRecursive(src, dst);
628
- refCopied++;
744
+ cloneNew++;
629
745
  }
630
746
  else {
631
747
  const sv = getSkillVersion(entry.name, srcReg);
632
748
  const dv = getSkillVersion(entry.name, dstReg);
633
- // Migration: dv===null means pre-version install → always update
634
749
  if (sv && (!dv || semverGt(sv, dv))) {
635
750
  fs.rmSync(dst, { recursive: true, force: true });
636
751
  copyDirRecursive(src, dst);
637
- refUpdated++;
752
+ cloneUpdated++;
638
753
  console.log(`[skills] updated: ${entry.name} ${dv ?? '(none)'} → ${sv}`);
639
754
  }
640
755
  }
@@ -643,36 +758,28 @@ export function copyDefaultSkills() {
643
758
  fs.copyFileSync(src, dst);
644
759
  }
645
760
  }
646
- if (refCopied > 0)
647
- console.log(`[skills] Bundled: ${refCopied} new skills → ref`);
648
- if (refUpdated > 0)
649
- console.log(`[skills] Bundled: ${refUpdated} skills updated`);
761
+ fs.rmSync(tmpClone, { recursive: true, force: true });
762
+ console.log(`[skills] ✅ GitHub: ${cloneNew} new, ${cloneUpdated} updated`);
763
+ skillsSourceResolved = true;
650
764
  }
651
- else {
652
- // npm install (no bundled dir) → clone or update from GitHub
653
- const needsClone = !fs.existsSync(join(refDir, 'registry.json'));
654
- try {
655
- console.log(`[skills] ${needsClone ? 'cloning' : 'updating'} skills from ${SKILLS_REPO}...`);
656
- const tmpClone = join(JAW_HOME, '.skills_clone_tmp');
657
- if (fs.existsSync(tmpClone))
658
- fs.rmSync(tmpClone, { recursive: true });
659
- execSync(`git clone --depth 1 ${SKILLS_REPO} "${tmpClone}"`, {
660
- stdio: 'pipe', timeout: 120000,
661
- });
662
- // Version-aware merge (same logic as bundled path)
663
- const srcReg = loadRegistry(tmpClone);
765
+ catch (e) {
766
+ console.log(`[skills] GitHub clone skipped: ${e.message?.slice(0, 60)}`);
767
+ }
768
+ // 2b. Fallback: bundled skills_ref/ (dev mode with initialized submodule)
769
+ if (!skillsSourceResolved) {
770
+ const bundledHasContent = fs.existsSync(packageRefDir) && fs.existsSync(join(packageRefDir, 'registry.json'));
771
+ if (bundledHasContent) {
772
+ const srcReg = loadRegistry(packageRefDir);
664
773
  const dstReg = loadRegistry(refDir);
665
- const cloned = fs.readdirSync(tmpClone, { withFileTypes: true });
666
- let cloneNew = 0, cloneUpdated = 0;
667
- for (const entry of cloned) {
668
- if (entry.name === '.git')
669
- continue;
670
- const src = join(tmpClone, entry.name);
774
+ const entries = fs.readdirSync(packageRefDir, { withFileTypes: true });
775
+ let refCopied = 0, refUpdated = 0;
776
+ for (const entry of entries) {
777
+ const src = join(packageRefDir, entry.name);
671
778
  const dst = join(refDir, entry.name);
672
779
  if (entry.isDirectory()) {
673
780
  if (!fs.existsSync(dst)) {
674
781
  copyDirRecursive(src, dst);
675
- cloneNew++;
782
+ refCopied++;
676
783
  }
677
784
  else {
678
785
  const sv = getSkillVersion(entry.name, srcReg);
@@ -680,7 +787,7 @@ export function copyDefaultSkills() {
680
787
  if (sv && (!dv || semverGt(sv, dv))) {
681
788
  fs.rmSync(dst, { recursive: true, force: true });
682
789
  copyDirRecursive(src, dst);
683
- cloneUpdated++;
790
+ refUpdated++;
684
791
  console.log(`[skills] updated: ${entry.name} ${dv ?? '(none)'} → ${sv}`);
685
792
  }
686
793
  }
@@ -689,17 +796,18 @@ export function copyDefaultSkills() {
689
796
  fs.copyFileSync(src, dst);
690
797
  }
691
798
  }
692
- fs.rmSync(tmpClone, { recursive: true, force: true });
693
- console.log(`[skills] ${cloneNew} new, ${cloneUpdated} updated ${refDir}`);
799
+ if (refCopied > 0)
800
+ console.log(`[skills] Bundled fallback: ${refCopied} new skillsref`);
801
+ if (refUpdated > 0)
802
+ console.log(`[skills] Bundled fallback: ${refUpdated} skills updated`);
803
+ skillsSourceResolved = true;
694
804
  }
695
- catch (e) {
696
- if (needsClone) {
697
- console.warn(`[skills] ⚠️ clone failed: ${e.message?.slice(0, 80)}`);
698
- console.warn(`[skills] offline mode — skills will be available after 'jaw init'`);
699
- }
700
- else {
701
- console.log(`[skills] update check skipped: ${e.message?.slice(0, 60)}`);
702
- }
805
+ }
806
+ if (!skillsSourceResolved) {
807
+ const hasExisting = fs.existsSync(join(refDir, 'registry.json'));
808
+ if (!hasExisting) {
809
+ console.warn(`[skills] ⚠️ no source available (no network + no bundled skills)`);
810
+ console.warn(`[skills] offline mode — skills will be available after 'jaw init'`);
703
811
  }
704
812
  }
705
813
  // ─── 3. Auto-activate from refDir ───────────────
@@ -757,45 +865,56 @@ export function softResetSkills() {
757
865
  const activeDir = join(JAW_HOME, 'skills');
758
866
  const refDir = join(JAW_HOME, 'skills_ref');
759
867
  const packageRefDir = join(findPackageRoot(), 'skills_ref');
760
- // 1. Source for ref update: bundled (dev) or git clone (npm install)
761
- let sourceDir = packageRefDir;
868
+ // 1. Source for ref update: GitHub clone (latest) bundled fallback (dev)
869
+ const SKILLS_REPO = 'https://github.com/lidge-jun/cli-jaw-skills.git';
870
+ let sourceDir = null;
762
871
  let tmpCloneDir = null;
763
- if (!fs.existsSync(packageRefDir)) {
764
- // npm install — skills_ref excluded from package, clone from GitHub
765
- const SKILLS_REPO = 'https://github.com/lidge-jun/cli-jaw-skills.git';
872
+ // 1a. Try GitHub clone first (public repo, always latest)
873
+ try {
766
874
  tmpCloneDir = join(JAW_HOME, '.skills_clone_tmp');
767
- try {
768
- if (fs.existsSync(tmpCloneDir))
769
- fs.rmSync(tmpCloneDir, { recursive: true });
770
- console.log(`[skills:soft-reset] cloning latest skills from ${SKILLS_REPO}...`);
771
- execSync(`git clone --depth 1 ${SKILLS_REPO} "${tmpCloneDir}"`, {
772
- stdio: 'pipe', timeout: 120000,
773
- });
774
- sourceDir = tmpCloneDir;
875
+ if (fs.existsSync(tmpCloneDir))
876
+ fs.rmSync(tmpCloneDir, { recursive: true });
877
+ console.log(`[skills:soft-reset] fetching latest skills from GitHub...`);
878
+ execSync(`git clone --depth 1 ${SKILLS_REPO} "${tmpCloneDir}"`, {
879
+ stdio: 'pipe', timeout: 120000,
880
+ });
881
+ sourceDir = tmpCloneDir;
882
+ }
883
+ catch (e) {
884
+ console.log(`[skills:soft-reset] GitHub clone skipped: ${e.message?.slice(0, 60)}`);
885
+ tmpCloneDir = null;
886
+ }
887
+ // 1b. Fallback: bundled skills_ref/ (dev mode with initialized submodule)
888
+ if (!sourceDir) {
889
+ const bundledReady = fs.existsSync(packageRefDir) && fs.existsSync(join(packageRefDir, 'registry.json'));
890
+ if (bundledReady) {
891
+ sourceDir = packageRefDir;
892
+ console.log(`[skills:soft-reset] using bundled fallback`);
775
893
  }
776
- catch (e) {
777
- console.warn(`[skills:soft-reset] ⚠️ clone failed: ${e.message?.slice(0, 80)}`);
778
- console.warn(`[skills:soft-reset] keeping current skills unchanged`);
894
+ else {
895
+ console.warn(`[skills:soft-reset] ⚠️ no source available — keeping current skills unchanged`);
779
896
  return { restored: 0, added: 0 };
780
897
  }
781
898
  }
782
899
  // 2. skills_ref/ 전체를 소스에서 다시 복사 (덮어쓰기)
783
- if (fs.existsSync(sourceDir)) {
784
- for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) {
785
- if (entry.name === '.git')
786
- continue;
787
- const src = join(sourceDir, entry.name);
788
- const dst = join(refDir, entry.name);
789
- if (entry.isDirectory()) {
790
- if (fs.existsSync(dst))
791
- fs.rmSync(dst, { recursive: true, force: true });
792
- copyDirRecursive(src, dst);
793
- }
794
- else if (entry.isFile()) {
795
- fs.copyFileSync(src, dst);
796
- }
900
+ for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) {
901
+ if (entry.name === '.git')
902
+ continue;
903
+ const src = join(sourceDir, entry.name);
904
+ const dst = join(refDir, entry.name);
905
+ if (entry.isDirectory()) {
906
+ if (fs.existsSync(dst))
907
+ fs.rmSync(dst, { recursive: true, force: true });
908
+ copyDirRecursive(src, dst);
909
+ }
910
+ else if (entry.isFile()) {
911
+ fs.copyFileSync(src, dst);
797
912
  }
798
913
  }
914
+ // Cleanup temp clone
915
+ if (tmpCloneDir && fs.existsSync(tmpCloneDir)) {
916
+ fs.rmSync(tmpCloneDir, { recursive: true, force: true });
917
+ }
799
918
  // 3. active skills → ref에 같은 이름이 있으면 무조건 덮어쓰기
800
919
  let restored = 0;
801
920
  if (fs.existsSync(activeDir)) {