@soleri/core 9.13.0 → 9.14.4

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 (119) hide show
  1. package/dist/engine/bin/soleri-engine.js +7 -2
  2. package/dist/engine/bin/soleri-engine.js.map +1 -1
  3. package/dist/flows/types.d.ts +34 -30
  4. package/dist/flows/types.d.ts.map +1 -1
  5. package/dist/index.d.ts +3 -2
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +3 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/knowledge-packs/community/.gitkeep +0 -0
  10. package/dist/knowledge-packs/salvador/salvador-craft/soleri-pack.json +10 -0
  11. package/dist/knowledge-packs/salvador/salvador-craft/vault/accessibility.json +53 -0
  12. package/dist/knowledge-packs/salvador/salvador-craft/vault/design-tokens.json +26 -0
  13. package/dist/knowledge-packs/salvador/salvador-craft/vault/design.json +33 -0
  14. package/dist/knowledge-packs/salvador/salvador-craft/vault/styling.json +44 -0
  15. package/dist/knowledge-packs/salvador/salvador-craft/vault/ux-laws.json +36 -0
  16. package/dist/knowledge-packs/salvador/salvador-craft/vault/ux.json +36 -0
  17. package/dist/knowledge-packs/salvador/salvador-engineering/soleri-pack.json +10 -0
  18. package/dist/knowledge-packs/salvador/salvador-engineering/vault/architecture.json +143 -0
  19. package/dist/knowledge-packs/salvador/salvador-engineering/vault/commercial.json +16 -0
  20. package/dist/knowledge-packs/salvador/salvador-engineering/vault/communication.json +33 -0
  21. package/dist/knowledge-packs/salvador/salvador-engineering/vault/component.json +16 -0
  22. package/dist/knowledge-packs/salvador/salvador-engineering/vault/express.json +34 -0
  23. package/dist/knowledge-packs/salvador/salvador-engineering/vault/leadership.json +33 -0
  24. package/dist/knowledge-packs/salvador/salvador-engineering/vault/methodology.json +33 -0
  25. package/dist/knowledge-packs/salvador/salvador-engineering/vault/monorepo.json +33 -0
  26. package/dist/knowledge-packs/salvador/salvador-engineering/vault/other.json +73 -0
  27. package/dist/knowledge-packs/salvador/salvador-engineering/vault/performance.json +35 -0
  28. package/dist/knowledge-packs/salvador/salvador-engineering/vault/prisma.json +33 -0
  29. package/dist/knowledge-packs/salvador/salvador-engineering/vault/product-strategy.json +42 -0
  30. package/dist/knowledge-packs/salvador/salvador-engineering/vault/react.json +47 -0
  31. package/dist/knowledge-packs/salvador/salvador-engineering/vault/security.json +34 -0
  32. package/dist/knowledge-packs/salvador/salvador-engineering/vault/testing.json +33 -0
  33. package/dist/knowledge-packs/salvador/salvador-engineering/vault/tooling.json +85 -0
  34. package/dist/knowledge-packs/salvador/salvador-engineering/vault/typescript.json +34 -0
  35. package/dist/knowledge-packs/salvador/salvador-engineering/vault/workflow.json +46 -0
  36. package/dist/knowledge-packs/salvador/salvador-uipro/soleri-pack.json +10 -0
  37. package/dist/knowledge-packs/salvador/salvador-uipro/vault/design.json +2589 -0
  38. package/dist/knowledge-packs/starter/api-design/soleri-pack.json +9 -0
  39. package/dist/knowledge-packs/starter/api-design/vault/patterns.json +137 -0
  40. package/dist/knowledge-packs/starter/architecture/soleri-pack.json +10 -0
  41. package/dist/knowledge-packs/starter/architecture/vault/patterns.json +137 -0
  42. package/dist/knowledge-packs/starter/design/soleri-pack.json +10 -0
  43. package/dist/knowledge-packs/starter/design/vault/patterns.json +137 -0
  44. package/dist/knowledge-packs/starter/nodejs/soleri-pack.json +9 -0
  45. package/dist/knowledge-packs/starter/nodejs/vault/patterns.json +137 -0
  46. package/dist/knowledge-packs/starter/react/soleri-pack.json +9 -0
  47. package/dist/knowledge-packs/starter/react/vault/patterns.json +164 -0
  48. package/dist/knowledge-packs/starter/security/soleri-pack.json +10 -0
  49. package/dist/knowledge-packs/starter/security/vault/patterns.json +137 -0
  50. package/dist/knowledge-packs/starter/testing/soleri-pack.json +9 -0
  51. package/dist/knowledge-packs/starter/testing/vault/patterns.json +128 -0
  52. package/dist/knowledge-packs/starter/typescript/soleri-pack.json +9 -0
  53. package/dist/knowledge-packs/starter/typescript/vault/patterns.json +164 -0
  54. package/dist/packs/index.d.ts +1 -1
  55. package/dist/packs/index.d.ts.map +1 -1
  56. package/dist/packs/index.js +1 -1
  57. package/dist/packs/index.js.map +1 -1
  58. package/dist/packs/resolver.d.ts +6 -0
  59. package/dist/packs/resolver.d.ts.map +1 -1
  60. package/dist/packs/resolver.js +20 -1
  61. package/dist/packs/resolver.js.map +1 -1
  62. package/dist/runtime/admin-setup-ops.js +1 -1
  63. package/dist/runtime/admin-setup-ops.js.map +1 -1
  64. package/dist/runtime/capture-ops.d.ts.map +1 -1
  65. package/dist/runtime/capture-ops.js +2 -1
  66. package/dist/runtime/capture-ops.js.map +1 -1
  67. package/dist/runtime/intake-ops.d.ts.map +1 -1
  68. package/dist/runtime/intake-ops.js +5 -5
  69. package/dist/runtime/intake-ops.js.map +1 -1
  70. package/dist/runtime/orchestrate-ops.d.ts.map +1 -1
  71. package/dist/runtime/orchestrate-ops.js +26 -2
  72. package/dist/runtime/orchestrate-ops.js.map +1 -1
  73. package/dist/runtime/planning-extra-ops.d.ts.map +1 -1
  74. package/dist/runtime/planning-extra-ops.js +5 -7
  75. package/dist/runtime/planning-extra-ops.js.map +1 -1
  76. package/dist/runtime/playbook-ops.d.ts.map +1 -1
  77. package/dist/runtime/playbook-ops.js +2 -1
  78. package/dist/runtime/playbook-ops.js.map +1 -1
  79. package/dist/runtime/schema-helpers.d.ts +7 -0
  80. package/dist/runtime/schema-helpers.d.ts.map +1 -0
  81. package/dist/runtime/schema-helpers.js +21 -0
  82. package/dist/runtime/schema-helpers.js.map +1 -0
  83. package/dist/runtime/sync-ops.d.ts.map +1 -1
  84. package/dist/runtime/sync-ops.js +3 -4
  85. package/dist/runtime/sync-ops.js.map +1 -1
  86. package/dist/runtime/vault-extra-ops.d.ts.map +1 -1
  87. package/dist/runtime/vault-extra-ops.js +5 -4
  88. package/dist/runtime/vault-extra-ops.js.map +1 -1
  89. package/dist/skills/sync-skills.d.ts +26 -7
  90. package/dist/skills/sync-skills.d.ts.map +1 -1
  91. package/dist/skills/sync-skills.js +132 -32
  92. package/dist/skills/sync-skills.js.map +1 -1
  93. package/dist/skills/validate-skill-docs.d.ts +24 -0
  94. package/dist/skills/validate-skill-docs.d.ts.map +1 -0
  95. package/dist/skills/validate-skill-docs.js +476 -0
  96. package/dist/skills/validate-skill-docs.js.map +1 -0
  97. package/package.json +2 -2
  98. package/src/__tests__/deviation-detection.test.ts +49 -0
  99. package/src/enforcement/adapters/claude-code.test.ts +9 -9
  100. package/src/engine/bin/soleri-engine.ts +7 -2
  101. package/src/flows/types.ts +4 -0
  102. package/src/index.ts +15 -2
  103. package/src/packs/index.ts +6 -1
  104. package/src/packs/resolver.ts +24 -1
  105. package/src/runtime/admin-setup-ops.test.ts +2 -0
  106. package/src/runtime/admin-setup-ops.ts +1 -1
  107. package/src/runtime/capture-ops.ts +2 -1
  108. package/src/runtime/intake-ops.ts +7 -7
  109. package/src/runtime/orchestrate-ops.ts +29 -2
  110. package/src/runtime/planning-extra-ops.ts +35 -37
  111. package/src/runtime/playbook-ops.ts +2 -1
  112. package/src/runtime/schema-helpers.test.ts +45 -0
  113. package/src/runtime/schema-helpers.ts +19 -0
  114. package/src/runtime/sync-ops.ts +8 -9
  115. package/src/runtime/vault-extra-ops.ts +5 -4
  116. package/src/skills/__tests__/sync-skills.test.ts +102 -29
  117. package/src/skills/__tests__/validate-skill-docs.test.ts +58 -0
  118. package/src/skills/sync-skills.ts +152 -32
  119. package/src/skills/validate-skill-docs.ts +562 -0
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
- import { mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs';
2
+ import { mkdirSync, writeFileSync, rmSync, existsSync, lstatSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { tmpdir } from 'node:os';
5
5
 
@@ -9,6 +9,7 @@ import { tmpdir } from 'node:os';
9
9
 
10
10
  let sourceDir: string;
11
11
  let fakeHome: string;
12
+ let fakeProject: string;
12
13
 
13
14
  function setup(): void {
14
15
  const base = join(tmpdir(), `soleri-sync-test-${Date.now()}`);
@@ -19,6 +20,9 @@ function setup(): void {
19
20
 
20
21
  fakeHome = join(base, 'fake-home');
21
22
  mkdirSync(join(fakeHome, '.claude', 'skills'), { recursive: true });
23
+
24
+ fakeProject = join(base, 'fake-project');
25
+ mkdirSync(join(fakeProject, '.claude', 'skills'), { recursive: true });
22
26
  }
23
27
 
24
28
  function teardown(): void {
@@ -40,29 +44,36 @@ function createSourceSkill(name: string, content?: string): string {
40
44
  }
41
45
 
42
46
  /** Create a directory in the fake ~/.claude/skills/ target */
43
- function createTargetSkillDir(name: string): string {
47
+ function createGlobalSkillDir(name: string): string {
44
48
  const dir = join(fakeHome, '.claude', 'skills', name);
45
49
  mkdirSync(dir, { recursive: true });
46
50
  writeFileSync(join(dir, 'SKILL.md'), `---\nname: ${name}\n---\n\nStale skill.\n`);
47
51
  return dir;
48
52
  }
49
53
 
50
- function targetSkillsDir(): string {
54
+ function globalSkillsDir(): string {
51
55
  return join(fakeHome, '.claude', 'skills');
52
56
  }
53
57
 
54
- function targetDirExists(name: string): boolean {
55
- return existsSync(join(targetSkillsDir(), name));
58
+ function globalDirExists(name: string): boolean {
59
+ return existsSync(join(globalSkillsDir(), name));
60
+ }
61
+
62
+ function projectSkillsDir(): string {
63
+ return join(fakeProject, '.claude', 'skills');
64
+ }
65
+
66
+ function projectDirExists(name: string): boolean {
67
+ return existsSync(join(projectSkillsDir(), name));
56
68
  }
57
69
 
58
70
  // =============================================================================
59
- // TESTS
71
+ // TESTS — Global install (orphan cleanup)
60
72
  // =============================================================================
61
73
 
62
- describe('syncSkillsToClaudeCode — orphan cleanup', () => {
74
+ describe('syncSkillsToClaudeCode — global orphan cleanup', () => {
63
75
  beforeEach(() => {
64
76
  setup();
65
- // Mock homedir() so syncSkillsToClaudeCode writes to our temp directory
66
77
  vi.mock('node:os', async (importOriginal) => {
67
78
  const original = await importOriginal<typeof import('node:os')>();
68
79
  return {
@@ -77,56 +88,118 @@ describe('syncSkillsToClaudeCode — orphan cleanup', () => {
77
88
  teardown();
78
89
  });
79
90
 
80
- it('removes orphan directories that match the agent prefix', async () => {
81
- // Source has "my-skill", target has stale "test-agent-old-skill"
91
+ it('removes orphan directories that match the agent prefix (global)', async () => {
82
92
  createSourceSkill('my-skill');
83
- createTargetSkillDir('test-agent-old-skill');
93
+ createGlobalSkillDir('test-agent-old-skill');
84
94
 
85
95
  const { syncSkillsToClaudeCode } = await import('../sync-skills.js');
86
- const result = syncSkillsToClaudeCode([sourceDir], 'Test Agent');
96
+ const result = syncSkillsToClaudeCode([sourceDir], 'Test Agent', { global: true });
87
97
 
88
- // The orphan should be reported as removed
89
98
  expect(result.removed).toContain('test-agent-old-skill');
90
- // The orphan directory should be gone
91
- expect(targetDirExists('test-agent-old-skill')).toBe(false);
99
+ expect(globalDirExists('test-agent-old-skill')).toBe(false);
92
100
  });
93
101
 
94
- it('does NOT remove directories that do not match the agent prefix', async () => {
102
+ it('does NOT remove directories that do not match the agent prefix (global)', async () => {
95
103
  createSourceSkill('my-skill');
96
- // "other-agent-skill" does NOT start with "test-agent-"
97
- createTargetSkillDir('other-agent-skill');
104
+ createGlobalSkillDir('other-agent-skill');
98
105
 
99
106
  const { syncSkillsToClaudeCode } = await import('../sync-skills.js');
100
- const result = syncSkillsToClaudeCode([sourceDir], 'Test Agent');
107
+ const result = syncSkillsToClaudeCode([sourceDir], 'Test Agent', { global: true });
101
108
 
102
- // Should still exist — not our prefix
103
- expect(targetDirExists('other-agent-skill')).toBe(true);
109
+ expect(globalDirExists('other-agent-skill')).toBe(true);
104
110
  expect(result.removed).not.toContain('other-agent-skill');
105
111
  });
106
112
 
107
- it('does NOT remove a skill directory that was just synced', async () => {
113
+ it('does NOT remove a skill directory that was just synced (global)', async () => {
108
114
  createSourceSkill('active-skill');
109
- // This directory matches the prefix AND is a current skill
110
- createTargetSkillDir('test-agent-active-skill');
115
+ createGlobalSkillDir('test-agent-active-skill');
111
116
 
112
117
  const { syncSkillsToClaudeCode } = await import('../sync-skills.js');
113
- const result = syncSkillsToClaudeCode([sourceDir], 'Test Agent');
118
+ const result = syncSkillsToClaudeCode([sourceDir], 'Test Agent', { global: true });
114
119
 
115
- // "active-skill" should be synced (installed/updated/skipped), not removed
116
120
  const synced = [...result.installed, ...result.updated, ...result.skipped];
117
121
  expect(synced).toContain('active-skill');
118
122
  expect(result.removed).not.toContain('test-agent-active-skill');
119
- expect(targetDirExists('test-agent-active-skill')).toBe(true);
123
+ expect(globalDirExists('test-agent-active-skill')).toBe(true);
120
124
  });
121
125
 
122
- it('returns an empty removed array when there are no orphans', async () => {
126
+ it('returns an empty removed array when there are no orphans (global)', async () => {
123
127
  createSourceSkill('only-skill');
124
128
 
125
129
  const { syncSkillsToClaudeCode } = await import('../sync-skills.js');
126
- const result = syncSkillsToClaudeCode([sourceDir], 'Test Agent');
130
+ const result = syncSkillsToClaudeCode([sourceDir], 'Test Agent', { global: true });
127
131
 
128
132
  expect(result.removed).toBeDefined();
129
133
  expect(Array.isArray(result.removed)).toBe(true);
130
134
  expect(result.removed).toHaveLength(0);
131
135
  });
132
136
  });
137
+
138
+ // =============================================================================
139
+ // TESTS — Project-local install (symlinks + canonical names)
140
+ // =============================================================================
141
+
142
+ describe('syncSkillsToClaudeCode — project-local install', () => {
143
+ beforeEach(() => {
144
+ setup();
145
+ vi.mock('node:os', async (importOriginal) => {
146
+ const original = await importOriginal<typeof import('node:os')>();
147
+ return {
148
+ ...original,
149
+ homedir: () => fakeHome,
150
+ };
151
+ });
152
+ });
153
+
154
+ afterEach(() => {
155
+ vi.restoreAllMocks();
156
+ teardown();
157
+ });
158
+
159
+ it('creates symlinks with canonical (unprefixed) names', async () => {
160
+ createSourceSkill('soleri-vault-capture');
161
+
162
+ const { syncSkillsToClaudeCode } = await import('../sync-skills.js');
163
+ const result = syncSkillsToClaudeCode([sourceDir], 'Ernesto', {
164
+ projectRoot: fakeProject,
165
+ });
166
+
167
+ // Should use canonical name, not "ernesto-soleri-vault-capture"
168
+ expect(result.installed).toContain('soleri-vault-capture');
169
+ expect(projectDirExists('soleri-vault-capture')).toBe(true);
170
+
171
+ // Should be a symlink
172
+ const stat = lstatSync(join(projectSkillsDir(), 'soleri-vault-capture'));
173
+ expect(stat.isSymbolicLink()).toBe(true);
174
+ });
175
+
176
+ it('cleans stale global entries with agent-soleri- prefix', async () => {
177
+ createSourceSkill('soleri-vault-capture');
178
+ // Simulate old global duplicates
179
+ createGlobalSkillDir('ernesto-soleri-vault-capture');
180
+ createGlobalSkillDir('ernesto-soleri-vault-navigator');
181
+
182
+ const { syncSkillsToClaudeCode } = await import('../sync-skills.js');
183
+ const result = syncSkillsToClaudeCode([sourceDir], 'Ernesto', {
184
+ projectRoot: fakeProject,
185
+ });
186
+
187
+ expect(result.cleanedGlobal).toContain('ernesto-soleri-vault-capture');
188
+ expect(result.cleanedGlobal).toContain('ernesto-soleri-vault-navigator');
189
+ expect(globalDirExists('ernesto-soleri-vault-capture')).toBe(false);
190
+ expect(globalDirExists('ernesto-soleri-vault-navigator')).toBe(false);
191
+ });
192
+
193
+ it('does not clean global entries that do not match agent-soleri- prefix', async () => {
194
+ createSourceSkill('soleri-vault-capture');
195
+ createGlobalSkillDir('other-agent-skill');
196
+
197
+ const { syncSkillsToClaudeCode } = await import('../sync-skills.js');
198
+ const result = syncSkillsToClaudeCode([sourceDir], 'Ernesto', {
199
+ projectRoot: fakeProject,
200
+ });
201
+
202
+ expect(result.cleanedGlobal).not.toContain('other-agent-skill');
203
+ expect(globalDirExists('other-agent-skill')).toBe(true);
204
+ });
205
+ });
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Unit tests for the SKILL.md validator.
3
+ */
4
+
5
+ import { describe, it, expect } from 'vitest';
6
+ import { validateSkillDocs } from '../validate-skill-docs.js';
7
+
8
+ // Resolve monorepo root from this file's location (packages/core/src/skills/__tests__)
9
+ import { dirname, resolve } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ const __dirname2 = dirname(fileURLToPath(import.meta.url));
12
+ const ROOT_DIR = resolve(__dirname2, '../../../../..');
13
+
14
+ describe('validateSkillDocs', () => {
15
+ it('builds a non-empty schema registry', () => {
16
+ const result = validateSkillDocs(ROOT_DIR);
17
+ expect(result.registrySize).toBeGreaterThan(100);
18
+ });
19
+
20
+ it('discovers SKILL.md files', () => {
21
+ const result = validateSkillDocs(ROOT_DIR);
22
+ expect(result.totalFiles).toBeGreaterThan(10);
23
+ });
24
+
25
+ it('extracts op examples from SKILL.md files', () => {
26
+ const result = validateSkillDocs(ROOT_DIR);
27
+ expect(result.totalExamples).toBeGreaterThan(20);
28
+ });
29
+
30
+ it('returns structured error objects with file, line, opName, message', () => {
31
+ const result = validateSkillDocs(ROOT_DIR);
32
+ // We expect some errors in the current state of docs
33
+ if (result.errors.length > 0) {
34
+ const err = result.errors[0];
35
+ expect(err).toHaveProperty('file');
36
+ expect(err).toHaveProperty('line');
37
+ expect(err).toHaveProperty('opName');
38
+ expect(err).toHaveProperty('message');
39
+ expect(typeof err.line).toBe('number');
40
+ expect(err.line).toBeGreaterThan(0);
41
+ }
42
+ });
43
+
44
+ it('detects the create_plan scope mismatch', () => {
45
+ const result = validateSkillDocs(ROOT_DIR);
46
+ const scopeError = result.errors.find(
47
+ (e) => e.opName === 'create_plan' && e.message.includes('scope'),
48
+ );
49
+ expect(scopeError).toBeDefined();
50
+ expect(scopeError!.message).toContain('Expected string');
51
+ });
52
+
53
+ it('detects unknown ops', () => {
54
+ const result = validateSkillDocs(ROOT_DIR);
55
+ const unknownOps = result.errors.filter((e) => e.message.includes('unknown op'));
56
+ expect(unknownOps.length).toBeGreaterThan(0);
57
+ });
58
+ });
@@ -1,22 +1,29 @@
1
1
  /**
2
2
  * Skill sync — discovers SKILL.md files in agent skills directories
3
- * and copies them to ~/.claude/skills/ for Claude Code discovery.
3
+ * and installs them for Claude Code discovery.
4
+ *
5
+ * Default: project-local `.claude/skills/` with canonical (unprefixed) names
6
+ * and symlinks so source edits propagate automatically.
7
+ *
8
+ * Optional `global: true` installs to `~/.claude/skills/` with agent-prefixed
9
+ * names and file copies (symlinks to project paths don't work globally).
4
10
  *
5
- * Injects agent branding so users know which agent owns the skill.
6
11
  * Called automatically at engine startup and by admin_setup_global.
7
12
  */
8
13
 
9
14
  import {
10
15
  cpSync,
11
16
  existsSync,
17
+ lstatSync,
12
18
  mkdirSync,
13
19
  readdirSync,
14
20
  readFileSync,
15
21
  rmSync,
16
22
  statSync,
23
+ symlinkSync,
17
24
  writeFileSync,
18
25
  } from 'node:fs';
19
- import { join, dirname } from 'node:path';
26
+ import { join, dirname, relative } from 'node:path';
20
27
  import { homedir } from 'node:os';
21
28
  import type { SkillMetadata, SourceType } from '../packs/types.js';
22
29
  import { classifyTrust } from './trust-classifier.js';
@@ -35,6 +42,15 @@ export interface SyncResult {
35
42
  skipped: string[];
36
43
  failed: string[];
37
44
  removed: string[];
45
+ /** Stale global entries cleaned up during project-local install */
46
+ cleanedGlobal: string[];
47
+ }
48
+
49
+ export interface SyncOptions {
50
+ /** Install globally to ~/.claude/skills/ with agent-prefixed names (default: false) */
51
+ global?: boolean;
52
+ /** Project root for project-local installs (default: process.cwd()) */
53
+ projectRoot?: string;
38
54
  }
39
55
 
40
56
  /** Error thrown when a skill requires approval due to scripts trust level */
@@ -96,42 +112,97 @@ function brandSkillContent(content: string, agentName: string, prefixedName?: st
96
112
  }
97
113
 
98
114
  /**
99
- * Sync skills from agent directory to ~/.claude/skills/.
100
- * - New skills are installed with agent branding
101
- * - Changed skills are overwritten (compared by mtime)
102
- * - Missing source skills leave target untouched (other agents may own them)
115
+ * Sync skills to Claude Code's skills directory.
116
+ *
117
+ * Default (project-local): installs to `<projectRoot>/.claude/skills/` using
118
+ * canonical (unprefixed) names and symlinks for automatic propagation.
119
+ *
120
+ * Global (`options.global: true`): installs to `~/.claude/skills/` using
121
+ * agent-prefixed names and file copies (symlinks to project paths won't work).
122
+ *
123
+ * Project-local installs also clean up stale `{agent}-soleri-*` entries from
124
+ * the global `~/.claude/skills/` directory to eliminate duplication.
103
125
  */
104
- export function syncSkillsToClaudeCode(skillsDirs: string[], agentName?: string): SyncResult {
105
- const skillsDir = join(homedir(), '.claude', 'skills');
126
+ export function syncSkillsToClaudeCode(
127
+ skillsDirs: string[],
128
+ agentName?: string,
129
+ options: SyncOptions = {},
130
+ ): SyncResult {
131
+ const isGlobal = options.global === true;
132
+ const projectRoot = options.projectRoot ?? process.cwd();
133
+
134
+ const skillsDir = isGlobal
135
+ ? join(homedir(), '.claude', 'skills')
136
+ : join(projectRoot, '.claude', 'skills');
137
+
106
138
  const skills = discoverSkills(skillsDirs);
107
- const result: SyncResult = { installed: [], updated: [], skipped: [], failed: [], removed: [] };
139
+ const result: SyncResult = {
140
+ installed: [],
141
+ updated: [],
142
+ skipped: [],
143
+ failed: [],
144
+ removed: [],
145
+ cleanedGlobal: [],
146
+ };
108
147
 
109
148
  if (skills.length === 0) return result;
110
149
 
111
150
  for (const skill of skills) {
112
- const prefix = agentName ? `${agentName.toLowerCase().replace(/\s+/g, '-')}-` : '';
113
- const skillName = `${prefix}${skill.name}`;
151
+ // Global installs use agent-prefixed names; local uses canonical names
152
+ const skillName =
153
+ isGlobal && agentName
154
+ ? `${agentName.toLowerCase().replace(/\s+/g, '-')}-${skill.name}`
155
+ : skill.name;
114
156
  const targetDir = join(skillsDir, skillName);
115
157
  const targetPath = join(targetDir, 'SKILL.md');
158
+
116
159
  try {
117
- const sourceContent = readFileSync(skill.sourcePath, 'utf-8');
118
- const branded = agentName
119
- ? brandSkillContent(sourceContent, agentName, skillName)
120
- : sourceContent;
121
-
122
- if (!existsSync(targetPath)) {
123
- mkdirSync(targetDir, { recursive: true });
124
- writeFileSync(targetPath, branded);
125
- result.installed.push(skill.name);
126
- } else {
127
- const sourceMtime = statSync(skill.sourcePath).mtimeMs;
128
- const targetMtime = statSync(targetPath).mtimeMs;
129
- if (sourceMtime > targetMtime) {
160
+ if (isGlobal) {
161
+ // Global: copy with branding (original behavior)
162
+ const sourceContent = readFileSync(skill.sourcePath, 'utf-8');
163
+ const branded = agentName
164
+ ? brandSkillContent(sourceContent, agentName, skillName)
165
+ : sourceContent;
166
+
167
+ if (!existsSync(targetPath)) {
168
+ mkdirSync(targetDir, { recursive: true });
130
169
  writeFileSync(targetPath, branded);
131
- result.updated.push(skill.name);
170
+ result.installed.push(skill.name);
132
171
  } else {
133
- result.skipped.push(skill.name);
172
+ const sourceMtime = statSync(skill.sourcePath).mtimeMs;
173
+ const targetMtime = statSync(targetPath).mtimeMs;
174
+ if (sourceMtime > targetMtime) {
175
+ writeFileSync(targetPath, branded);
176
+ result.updated.push(skill.name);
177
+ } else {
178
+ result.skipped.push(skill.name);
179
+ }
134
180
  }
181
+ } else {
182
+ // Project-local: symlink the skill directory
183
+ const sourceSkillDir = dirname(skill.sourcePath);
184
+
185
+ if (existsSync(targetDir)) {
186
+ // Check if it's already a symlink pointing to the right place
187
+ try {
188
+ const stat = lstatSync(targetDir);
189
+ if (stat.isSymbolicLink()) {
190
+ // Already a symlink — skip
191
+ result.skipped.push(skill.name);
192
+ continue;
193
+ }
194
+ } catch {
195
+ // lstat failed — remove and recreate
196
+ }
197
+ // Not a symlink — remove the copy and replace with symlink
198
+ rmSync(targetDir, { recursive: true, force: true });
199
+ }
200
+
201
+ mkdirSync(skillsDir, { recursive: true });
202
+ // Use relative symlink so the project stays portable
203
+ const relPath = relative(skillsDir, sourceSkillDir);
204
+ symlinkSync(relPath, targetDir);
205
+ result.installed.push(skill.name);
135
206
  }
136
207
  } catch {
137
208
  result.failed.push(skill.name);
@@ -142,16 +213,18 @@ export function syncSkillsToClaudeCode(skillsDirs: string[], agentName?: string)
142
213
  if (agentName) {
143
214
  const prefix = `${agentName.toLowerCase().replace(/\s+/g, '-')}-`;
144
215
  const syncedNames = new Set<string>(
145
- [...result.installed, ...result.updated, ...result.skipped, ...result.failed].map(
146
- (name) => `${prefix}${name}`,
216
+ [...result.installed, ...result.updated, ...result.skipped, ...result.failed].map((name) =>
217
+ isGlobal ? `${prefix}${name}` : name,
147
218
  ),
148
219
  );
149
220
 
150
221
  try {
151
222
  const entries = readdirSync(skillsDir, { withFileTypes: true });
152
223
  for (const entry of entries) {
153
- if (!entry.isDirectory()) continue;
154
- if (!entry.name.startsWith(prefix)) continue;
224
+ if (!entry.isDirectory() && !lstatSync(join(skillsDir, entry.name)).isSymbolicLink())
225
+ continue;
226
+ const matchPrefix = isGlobal ? entry.name.startsWith(prefix) : true;
227
+ if (!matchPrefix) continue;
155
228
  if (syncedNames.has(entry.name)) continue;
156
229
 
157
230
  // Orphan detected — stage backup then remove
@@ -160,7 +233,10 @@ export function syncSkillsToClaudeCode(skillsDirs: string[], agentName?: string)
160
233
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
161
234
  const stagingDir = join(homedir(), '.soleri', 'staging', timestamp);
162
235
  mkdirSync(stagingDir, { recursive: true });
163
- cpSync(orphanPath, join(stagingDir, entry.name), { recursive: true });
236
+ const orphanStat = lstatSync(orphanPath);
237
+ if (!orphanStat.isSymbolicLink()) {
238
+ cpSync(orphanPath, join(stagingDir, entry.name), { recursive: true });
239
+ }
164
240
  rmSync(orphanPath, { recursive: true, force: true });
165
241
  result.removed.push(entry.name);
166
242
  } catch {
@@ -172,9 +248,53 @@ export function syncSkillsToClaudeCode(skillsDirs: string[], agentName?: string)
172
248
  }
173
249
  }
174
250
 
251
+ // Task 3: Clean up stale global entries when doing a project-local install
252
+ if (!isGlobal && agentName) {
253
+ cleanStaleGlobalSkills(agentName, result);
254
+ }
255
+
175
256
  return result;
176
257
  }
177
258
 
259
+ /**
260
+ * Remove ALL `{agent}-soleri-*` entries from ~/.claude/skills/
261
+ * to clean up duplicates left by the old global-install behavior.
262
+ *
263
+ * Cleans entries from ALL agents, not just the current one — any
264
+ * `*-soleri-*` entry in the global dir is a stale copy from a previous
265
+ * global install. Canonical skills now live in project-local .claude/skills/.
266
+ */
267
+ function cleanStaleGlobalSkills(agentName: string, result: SyncResult): void {
268
+ const globalSkillsDir = join(homedir(), '.claude', 'skills');
269
+ if (!existsSync(globalSkillsDir)) return;
270
+
271
+ // Match any agent-prefixed soleri skill: <anything>-soleri-<skillname>
272
+ // Canonical project-local names look like "soleri-*" (no agent prefix).
273
+ const stalePattern = /^.+-soleri-.+$/;
274
+
275
+ try {
276
+ const entries = readdirSync(globalSkillsDir, { withFileTypes: true });
277
+ for (const entry of entries) {
278
+ if (!entry.isDirectory()) continue;
279
+ if (!stalePattern.test(entry.name)) continue;
280
+
281
+ const staleDir = join(globalSkillsDir, entry.name);
282
+ try {
283
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
284
+ const stagingDir = join(homedir(), '.soleri', 'staging', timestamp);
285
+ mkdirSync(stagingDir, { recursive: true });
286
+ cpSync(staleDir, join(stagingDir, entry.name), { recursive: true });
287
+ rmSync(staleDir, { recursive: true, force: true });
288
+ result.cleanedGlobal.push(entry.name);
289
+ } catch {
290
+ // Best-effort cleanup — don't fail the sync
291
+ }
292
+ }
293
+ } catch {
294
+ // Global skills dir unreadable — nothing to clean
295
+ }
296
+ }
297
+
178
298
  // =============================================================================
179
299
  // TRUST CLASSIFICATION & SOURCE TRACKING
180
300
  // =============================================================================