showdar-skills 0.2.1 → 0.2.3

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/bin/showdar.js CHANGED
@@ -49,6 +49,10 @@ async function main() {
49
49
  const version = await packageVersion();
50
50
 
51
51
  if (command === 'help' || command === '--help' || command === '-h') return printHelp(version);
52
+ if (command === '--version' || command === '-V') {
53
+ console.log(version);
54
+ return;
55
+ }
52
56
  if (args.includes('--help') || args.includes('-h')) return printHelp(version, command);
53
57
 
54
58
  const scope = ['init', 'status', 'doctor', 'remove'].includes(command) ? scopeAfter(args) : null;
@@ -83,6 +87,10 @@ async function main() {
83
87
  ? await initGlobal({ homeRoot: homedir(), packageRoot, profile, ai, skillIds, commandNames, packageVersion: version })
84
88
  : await initProject({ projectRoot, packageRoot, profile, ai, skillIds, commandNames, packageVersion: version });
85
89
  console.log(`Showdar Skills installed.\nScope: ${scope}\nProfile: ${profile}\nAI: ${ai}\nTargets: ${result.targets.join(', ')}\nSkills: ${result.skills}\nOpenCode commands: ${result.commands}`);
90
+ if (scope === 'project') {
91
+ console.log(`Requested: ${result.requestedSkills}\nInstalled in project: ${result.installedSkills}\nSatisfied by global: ${result.satisfiedByGlobal}\nSkipped duplicate copies: ${result.skippedDuplicates}`);
92
+ }
93
+ for (const warning of result.warnings ?? []) console.log(`warning: ${warning}`);
86
94
  if (scope === 'global') console.log(`Manifest: ${globalManifestPath()}`);
87
95
  if (result.targets.includes('codex')) console.log('Codex: invoke skills directly with $showdar-<name> or let native skill discovery route by description.');
88
96
  if (result.targets.includes('opencode')) console.log('OpenCode: use native skill discovery or /showdar/<command>.');
@@ -97,7 +105,9 @@ async function main() {
97
105
  return;
98
106
  }
99
107
  console.log(`Showdar Skills\nScope: ${result.scope}\nProfile: ${result.profile}\nAI: ${result.ai}\nTargets: ${result.targets.join(', ')}\nSkills: ${result.skills}\nCommands: ${result.commands}\nHealth: ${result.healthy ? 'OK' : 'BROKEN'}`);
108
+ if (scope === 'project') console.log(`Requested: ${result.requestedSkills}\nInstalled in project: ${result.installedSkills}\nSatisfied by global: ${result.satisfiedByGlobal}`);
100
109
  for (const issue of result.issues) console.log(`- ${issue}`);
110
+ for (const warning of result.warnings ?? []) console.log(`warning: ${warning}`);
101
111
  if (command === 'doctor' && !result.healthy) process.exitCode = 1;
102
112
  return;
103
113
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "showdar-skills",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Production-grade software engineering lifecycle skills for coding agents.",
5
5
  "type": "module",
6
6
  "bin": { "showdar": "./bin/showdar.js" },
@@ -1,5 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawnSync } from 'node:child_process';
3
3
  const cwd=process.argv[2]??process.cwd(); const base=process.argv[3]??'HEAD';
4
- const run=a=>{const r=spawnSync('git',a,{cwd,encoding:'utf8'});return r.status===0?r.stdout.trim():null};
5
- console.log(JSON.stringify({base,status:run(['status','--short']),files:run(['diff','--name-only',base])?.split('\n').filter(Boolean)??[],stat:run(['diff','--stat',base]),diff:run(['diff','--no-ext-diff','--unified=3',base])},null,2));
4
+ const run=(args)=>{const r=spawnSync('git',args,{cwd,encoding:'utf8',shell:false});if(r.error)throw r.error;if(r.status!==0)throw new Error((r.stderr||r.stdout).trim()||`git ${args.join(' ')} failed`);return r.stdout.trim()};
5
+ try {
6
+ if (base.startsWith('-')) throw new Error('revision must not begin with "-"');
7
+ const resolved=run(['rev-parse','--verify',`${base}^{commit}`]);
8
+ console.log(JSON.stringify({base,status:run(['status','--short']),files:run(['diff','--no-ext-diff','--name-only',resolved])?.split('\n').filter(Boolean)??[],stat:run(['diff','--no-ext-diff','--stat',resolved]),diff:run(['diff','--no-ext-diff','--unified=3',resolved])},null,2));
9
+ } catch (error) {
10
+ console.error(`collect-diff: ${error.message}`);
11
+ process.exitCode=1;
12
+ }
@@ -0,0 +1,39 @@
1
+ import { lstat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ export function safeOwnedPath(baseRoot, relative, allowedRoots = []) {
5
+ if (typeof relative !== 'string' || !relative) return null;
6
+ const roots = [baseRoot, ...allowedRoots].map((root) => path.resolve(root));
7
+ const target = path.resolve(relative.startsWith(path.sep) ? relative : path.join(roots[0], relative));
8
+ return roots.some((root) => target === root || target.startsWith(`${root}${path.sep}`)) ? target : null;
9
+ }
10
+
11
+ export async function lstatWithoutSymlink(target) {
12
+ const info = await lstat(target);
13
+ if (info.isSymbolicLink()) throw new Error(`Refusing managed path through symlink: ${target}`);
14
+ return info;
15
+ }
16
+
17
+ export async function assertSafeManagedPath(baseRoot, target, allowedRoots = []) {
18
+ const root = path.resolve(baseRoot);
19
+ const resolved = safeOwnedPath(root, target, allowedRoots);
20
+ if (!resolved) throw new Error(`Invalid managed path: ${target}`);
21
+
22
+ try {
23
+ await lstatWithoutSymlink(root);
24
+ } catch (error) {
25
+ if (error?.code === 'ENOENT') return resolved;
26
+ throw error;
27
+ }
28
+ let current = root;
29
+ for (const component of path.relative(root, resolved).split(path.sep).filter(Boolean)) {
30
+ current = path.join(current, component);
31
+ try {
32
+ await lstatWithoutSymlink(current);
33
+ } catch (error) {
34
+ if (error?.code === 'ENOENT') break;
35
+ throw error;
36
+ }
37
+ }
38
+ return resolved;
39
+ }
package/src/project.js CHANGED
@@ -1,8 +1,9 @@
1
- import { access, cp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
1
+ import { access, cp, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
2
2
  import { createHash } from 'node:crypto';
3
3
  import { homedir } from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { globalCommandRootFor, globalSkillRootFor, NATIVE_TARGETS, opencodeCommandRoot, resolveTargets, skillRootFor } from './adapters.js';
6
+ import { assertSafeManagedPath, lstatWithoutSymlink, safeOwnedPath } from './path-safety.js';
6
7
 
7
8
  const PROJECT_MANIFEST = '.showdar.json';
8
9
  const START = '<!-- showdar-skills:start -->';
@@ -19,7 +20,7 @@ async function exists(target) {
19
20
  async function hashTree(target) {
20
21
  const h = createHash('sha256');
21
22
  async function walk(current, relative = '') {
22
- const info = await stat(current);
23
+ const info = await lstatWithoutSymlink(current);
23
24
  if (info.isDirectory()) {
24
25
  const entries = await readdir(current, { withFileTypes: true });
25
26
  entries.sort((a, b) => a.name.localeCompare(b.name));
@@ -35,15 +36,20 @@ async function hashTree(target) {
35
36
  return h.digest('hex');
36
37
  }
37
38
 
38
- async function readManifest(manifestPath) {
39
+ async function readManifest(manifestPath, baseRoot) {
40
+ await assertSafeManagedPath(baseRoot, manifestPath);
39
41
  if (!(await exists(manifestPath))) return null;
40
42
  try { return JSON.parse(await readFile(manifestPath, 'utf8')); }
41
43
  catch (error) { throw new Error(`Invalid Showdar manifest: ${error.message}`); }
42
44
  }
43
45
 
44
46
  async function writeJsonAtomic(target, value) {
47
+ await writeTextAtomic(target, `${JSON.stringify(value, null, 2)}\n`);
48
+ }
49
+
50
+ async function writeTextAtomic(target, value) {
45
51
  const tmp = `${target}.tmp-${process.pid}-${Date.now()}`;
46
- await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`);
52
+ await writeFile(tmp, value, { flag: 'wx' });
47
53
  await rename(tmp, target);
48
54
  }
49
55
 
@@ -81,18 +87,20 @@ function stripManagedBlock(content) {
81
87
 
82
88
  async function writeAgentsBlock(projectRoot, skillIds) {
83
89
  const target = path.join(projectRoot, 'AGENTS.md');
90
+ await assertSafeManagedPath(projectRoot, target);
84
91
  const current = (await exists(target)) ? await readFile(target, 'utf8') : '';
85
92
  const clean = stripManagedBlock(current).trimEnd();
86
93
  const block = managedBlock(skillIds);
87
- await writeFile(target, clean ? `${clean}\n\n${block}\n` : `${block}\n`);
94
+ await writeTextAtomic(target, clean ? `${clean}\n\n${block}\n` : `${block}\n`);
88
95
  }
89
96
 
90
97
  async function removeAgentsBlock(projectRoot) {
91
98
  const target = path.join(projectRoot, 'AGENTS.md');
99
+ await assertSafeManagedPath(projectRoot, target);
92
100
  if (!(await exists(target))) return;
93
101
  const current = await readFile(target, 'utf8');
94
102
  const clean = stripManagedBlock(current).trim();
95
- if (clean) await writeFile(target, `${clean}\n`);
103
+ if (clean) await writeTextAtomic(target, `${clean}\n`);
96
104
  else await rm(target, { force: true });
97
105
  }
98
106
 
@@ -100,12 +108,13 @@ function ownedPathSet(manifest) {
100
108
  return new Set((manifest?.files ?? []).map((entry) => entry.path));
101
109
  }
102
110
 
103
- function safeOwnedPath(baseRoot, relative, allowedRoots = []) {
104
- if (typeof relative !== 'string' || !relative) return null;
105
- const roots = [baseRoot, ...allowedRoots].map((root) => path.resolve(root));
106
- const target = path.resolve(relative.startsWith(path.sep) ? relative : path.join(roots[0], relative));
107
- if (!roots.some((root) => target === root || target.startsWith(`${root}${path.sep}`))) return null;
108
- return target;
111
+ function uniqueRoots(targets, resolveRoot) {
112
+ const roots = new Map();
113
+ for (const target of targets) {
114
+ const root = resolveRoot(target);
115
+ if (!roots.has(path.resolve(root))) roots.set(path.resolve(root), { root, target });
116
+ }
117
+ return [...roots.values()];
109
118
  }
110
119
 
111
120
  function manifestPathFor(baseRoot, destination) {
@@ -113,7 +122,8 @@ function manifestPathFor(baseRoot, destination) {
113
122
  return relative.replaceAll(path.sep, '/');
114
123
  }
115
124
 
116
- async function copyOwned({ baseRoot, source, destination, priorOwned, newFiles }) {
125
+ async function copyOwned({ baseRoot, source, destination, priorOwned, newFiles, managedRoots = [] }) {
126
+ await assertSafeManagedPath(baseRoot, destination, managedRoots);
117
127
  const relative = manifestPathFor(baseRoot, destination);
118
128
  if ((await exists(destination)) && !priorOwned.has(relative)) {
119
129
  throw new Error(`Refusing to overwrite existing non-Showdar-managed skill or command: ${destination}`);
@@ -124,37 +134,75 @@ async function copyOwned({ baseRoot, source, destination, priorOwned, newFiles }
124
134
  newFiles.push({ path: relative, hash: await hashTree(destination) });
125
135
  }
126
136
 
127
- async function initInstallation({ baseRoot, manifestPath, agentsRoot, packageRoot, profile, ai, skillIds, commandNames = [], packageVersion = '0.2.0', scope, skillRootForTarget, commandRoot, managedRoots = [] }) {
137
+ async function initInstallation({ baseRoot, manifestPath, agentsRoot, packageRoot, profile, ai, skillIds, commandNames = [], packageVersion = '0.2.0', scope, skillRootForTarget, commandRoot, managedRoots = [], homeRoot = homedir(), globalSkillRootForTarget = null }) {
128
138
  await mkdir(baseRoot, { recursive: true });
139
+ await assertSafeManagedPath(baseRoot, baseRoot);
140
+ await assertSafeManagedPath(baseRoot, manifestPath);
141
+ if (agentsRoot) await assertSafeManagedPath(agentsRoot, path.join(agentsRoot, 'AGENTS.md'));
129
142
  const targets = resolveTargets(ai);
130
- const prior = await readManifest(manifestPath);
143
+ const prior = await readManifest(manifestPath, baseRoot);
131
144
  const priorOwned = ownedPathSet(prior);
132
145
  const desiredPaths = new Set();
133
146
 
134
- const skillRoots = new Map();
135
- for (const target of targets) {
136
- const root = skillRootForTarget(target);
137
- skillRoots.set(path.resolve(root), root);
147
+ const skillRoots = uniqueRoots(targets, skillRootForTarget);
148
+ const globalManifest = scope === 'project' && globalSkillRootForTarget
149
+ ? await readManifest(globalManifestPath(homeRoot), homeRoot)
150
+ : null;
151
+ const globalOwned = ownedPathSet(globalManifest);
152
+ const globalSatisfaction = [];
153
+ const installedSkillIds = new Set();
154
+ const globallySatisfiedSkillIds = new Set();
155
+ let skippedDuplicates = 0;
156
+ const skillDestinations = [];
157
+
158
+ for (const skillId of skillIds) {
159
+ if (!(await exists(path.join(packageRoot, 'skills', skillId, 'SKILL.md')))) throw new Error(`Skill asset not found: ${skillId}`);
138
160
  }
139
- for (const root of skillRoots.values()) {
140
- for (const skillId of skillIds) desiredPaths.add(manifestPathFor(baseRoot, path.join(root, skillId)));
161
+
162
+ for (const { root, target } of skillRoots) {
163
+ for (const skillId of skillIds) {
164
+ const destination = path.join(root, skillId);
165
+ await assertSafeManagedPath(baseRoot, destination, managedRoots);
166
+ const relative = manifestPathFor(baseRoot, destination);
167
+ const destinationExists = await exists(destination);
168
+ const globalPath = globalSkillRootForTarget ? path.join(globalSkillRootForTarget(target), skillId) : null;
169
+ if (globalPath) await assertSafeManagedPath(homeRoot, globalPath);
170
+ const globalRelative = globalPath ? manifestPathFor(homeRoot, globalPath) : null;
171
+ const globalAvailable = Boolean(globalPath && globalOwned.has(globalRelative) && await exists(globalPath));
172
+ if (scope === 'project' && globalAvailable && !destinationExists) {
173
+ skippedDuplicates += 1;
174
+ globallySatisfiedSkillIds.add(skillId);
175
+ globalSatisfaction.push({ skill: skillId, target, path: globalRelative });
176
+ } else {
177
+ desiredPaths.add(relative);
178
+ skillDestinations.push({ destination, skillId });
179
+ }
180
+ }
141
181
  }
142
182
  if (targets.includes('opencode')) {
143
- for (const name of commandNames) desiredPaths.add(manifestPathFor(baseRoot, path.join(commandRoot(), `${name}.md`)));
183
+ const root = commandRoot();
184
+ for (const name of commandNames) {
185
+ const destination = path.join(root, `${name}.md`);
186
+ await assertSafeManagedPath(baseRoot, destination, managedRoots);
187
+ desiredPaths.add(manifestPathFor(baseRoot, destination));
188
+ }
144
189
  }
145
190
 
191
+ const staleTargets = [];
146
192
  for (const entry of prior?.files ?? []) {
147
193
  const target = safeOwnedPath(baseRoot, entry.path, managedRoots);
148
- if (target && !desiredPaths.has(entry.path)) await rm(target, { recursive: true, force: true });
194
+ if (target && !desiredPaths.has(entry.path)) {
195
+ await assertSafeManagedPath(baseRoot, target, managedRoots);
196
+ staleTargets.push(target);
197
+ }
149
198
  }
199
+ for (const target of staleTargets) await rm(target, { recursive: true, force: true });
150
200
 
151
201
  const files = [];
152
- for (const root of skillRoots.values()) {
153
- for (const skillId of skillIds) {
154
- const source = path.join(packageRoot, 'skills', skillId);
155
- if (!(await exists(path.join(source, 'SKILL.md')))) throw new Error(`Skill asset not found: ${skillId}`);
156
- await copyOwned({ baseRoot, source, destination: path.join(root, skillId), priorOwned, newFiles: files });
157
- }
202
+ for (const { destination, skillId } of skillDestinations) {
203
+ const source = path.join(packageRoot, 'skills', skillId);
204
+ await copyOwned({ baseRoot, source, destination, priorOwned, newFiles: files, managedRoots });
205
+ installedSkillIds.add(skillId);
158
206
  }
159
207
 
160
208
  if (targets.includes('opencode')) {
@@ -162,7 +210,7 @@ async function initInstallation({ baseRoot, manifestPath, agentsRoot, packageRoo
162
210
  for (const name of commandNames) {
163
211
  const source = path.join(packageRoot, 'commands', 'opencode', 'showdar', `${name}.md`);
164
212
  if (!(await exists(source))) throw new Error(`OpenCode command asset not found: ${name}`);
165
- await copyOwned({ baseRoot, source, destination: path.join(root, `${name}.md`), priorOwned, newFiles: files });
213
+ await copyOwned({ baseRoot, source, destination: path.join(root, `${name}.md`), priorOwned, newFiles: files, managedRoots });
166
214
  }
167
215
  }
168
216
 
@@ -174,16 +222,28 @@ async function initInstallation({ baseRoot, manifestPath, agentsRoot, packageRoo
174
222
  ai,
175
223
  targets,
176
224
  skills: [...skillIds],
225
+ satisfiedByGlobal: globalSatisfaction,
177
226
  commands: targets.includes('opencode') ? [...commandNames] : [],
178
227
  files,
179
228
  };
229
+ await assertSafeManagedPath(baseRoot, manifestPath);
180
230
  await mkdir(path.dirname(manifestPath), { recursive: true });
181
231
  await writeJsonAtomic(manifestPath, manifest);
182
232
  if (agentsRoot) await writeAgentsBlock(agentsRoot, skillIds);
183
- return inspectInstallation({ baseRoot, manifestPath, agentsRoot, scope });
233
+ const result = await inspectInstallation({
234
+ baseRoot, manifestPath, agentsRoot, scope, homeRoot,
235
+ globalSkillRootForTarget,
236
+ });
237
+ return {
238
+ ...result,
239
+ requestedSkills: skillIds.length,
240
+ installedSkills: installedSkillIds.size,
241
+ satisfiedByGlobal: globallySatisfiedSkillIds.size,
242
+ skippedDuplicates,
243
+ };
184
244
  }
185
245
 
186
- export async function initProject({ projectRoot, packageRoot, profile, ai, skillIds, commandNames = [], packageVersion = '0.2.0' }) {
246
+ export async function initProject({ projectRoot, homeRoot = homedir(), packageRoot, profile, ai, skillIds, commandNames = [], packageVersion = '0.2.0' }) {
187
247
  return initInstallation({
188
248
  baseRoot: projectRoot,
189
249
  manifestPath: path.join(projectRoot, PROJECT_MANIFEST),
@@ -195,7 +255,9 @@ export async function initProject({ projectRoot, packageRoot, profile, ai, skill
195
255
  commandNames,
196
256
  packageVersion,
197
257
  scope: 'project',
258
+ homeRoot,
198
259
  skillRootForTarget: (target) => skillRootFor(target, projectRoot),
260
+ globalSkillRootForTarget: (target) => globalSkillRootFor(target, { homeRoot }),
199
261
  commandRoot: () => opencodeCommandRoot(projectRoot),
200
262
  });
201
263
  }
@@ -219,23 +281,93 @@ export async function initGlobal({ homeRoot = homedir(), packageRoot, profile, a
219
281
  });
220
282
  }
221
283
 
222
- async function inspectInstallation({ baseRoot, manifestPath, agentsRoot, scope, managedRoots = [] }) {
284
+ async function inspectInstallation({ baseRoot, manifestPath, agentsRoot, scope, managedRoots = [], homeRoot = homedir(), globalSkillRootForTarget = null }) {
223
285
  let manifest;
224
- try { manifest = await readManifest(manifestPath); }
225
- catch (error) { return { installed: true, healthy: false, scope, profile: null, ai: null, targets: [], skills: 0, commands: 0, issues: [error.message] }; }
226
- if (!manifest) return { installed: false, healthy: false, scope, profile: null, ai: null, targets: [], skills: 0, commands: 0, issues: ['Showdar is not installed.'] };
286
+ try { manifest = await readManifest(manifestPath, baseRoot); }
287
+ catch (error) { return { installed: true, healthy: false, scope, profile: null, ai: null, targets: [], skills: 0, requestedSkills: 0, installedSkills: 0, satisfiedByGlobal: 0, commands: 0, issues: [error.message], warnings: [] }; }
288
+ if (!manifest) return { installed: false, healthy: false, scope, profile: null, ai: null, targets: [], skills: 0, requestedSkills: 0, installedSkills: 0, satisfiedByGlobal: 0, commands: 0, issues: ['Showdar is not installed.'], warnings: [] };
227
289
 
228
290
  const issues = [];
291
+ const warnings = [];
292
+ const projectOwned = ownedPathSet(manifest);
293
+ const targets = manifest.targets?.length ? manifest.targets : manifest.ai ? resolveTargets(manifest.ai) : [];
294
+ const skillIds = manifest.skills ?? [];
295
+ const globalManifest = scope === 'project' && globalSkillRootForTarget
296
+ ? await readManifest(globalManifestPath(homeRoot), homeRoot)
297
+ : null;
298
+ const globalOwned = ownedPathSet(globalManifest);
299
+ const globalSatisfiedPaths = new Set();
300
+ const installedSkillIds = new Set();
301
+ const globallySatisfiedSkillIds = new Set();
302
+ const recordedGlobalSkills = new Set((manifest.satisfiedByGlobal ?? []).map((entry) => entry?.skill).filter(Boolean));
303
+
304
+ if (scope === 'project' && globalSkillRootForTarget) {
305
+ const projectRoots = uniqueRoots(targets, (target) => skillRootFor(target, baseRoot));
306
+ const globalRoots = uniqueRoots(targets, globalSkillRootForTarget);
307
+ const globalSkills = new Set();
308
+ for (const { root } of globalRoots) {
309
+ const prefix = `${manifestPathFor(homeRoot, root)}/`;
310
+ for (const entry of globalManifest?.files ?? []) {
311
+ if (!entry.path.startsWith(prefix)) continue;
312
+ const skillId = entry.path.slice(prefix.length).split('/')[0];
313
+ if (!skillId.startsWith('showdar-')) continue;
314
+ const globalTarget = safeOwnedPath(homeRoot, entry.path);
315
+ if (globalTarget && globalTarget.startsWith(`${root}${path.sep}`) && globalOwned.has(entry.path)) {
316
+ await assertSafeManagedPath(homeRoot, globalTarget);
317
+ if (await exists(globalTarget)) globalSkills.add(skillId);
318
+ }
319
+ }
320
+ }
321
+ const extra = [...globalSkills].filter((skillId) => !skillIds.includes(skillId)).sort();
322
+ if (extra.length) warnings.push(`Global Showdar installation exposes skills outside project profile "${manifest.profile ?? 'unknown'}": ${extra.join(', ')}. Project deduplication prevents duplicate copies but cannot hide globally installed skills. For strict project profile isolation: showdar remove --scope global`);
323
+
324
+ for (const { root, target } of projectRoots) {
325
+ for (const skillId of skillIds) {
326
+ const projectPath = path.join(root, skillId);
327
+ const projectRelative = manifestPathFor(baseRoot, projectPath);
328
+ const projectExists = await exists(projectPath);
329
+ const projectIsOwned = projectOwned.has(projectRelative);
330
+ const globalPath = path.join(globalSkillRootForTarget(target), skillId);
331
+ const globalRelative = manifestPathFor(homeRoot, globalPath);
332
+ await assertSafeManagedPath(baseRoot, projectPath);
333
+ await assertSafeManagedPath(homeRoot, globalPath);
334
+ const globalExists = await exists(globalPath);
335
+ const globalIsOwned = globalOwned.has(globalRelative);
336
+
337
+ if (projectExists && projectIsOwned) installedSkillIds.add(skillId);
338
+ if (projectExists && projectIsOwned && globalExists && globalIsOwned) {
339
+ warnings.push(`Duplicate Showdar skill discovery:\n ${skillId}\n project: ${projectPath}\n global: ${globalPath}\n To prefer project isolation: showdar remove --scope global`);
340
+ } else if (!projectExists && globalExists && globalIsOwned) {
341
+ globalSatisfiedPaths.add(projectRelative);
342
+ globallySatisfiedSkillIds.add(skillId);
343
+ } else if (!projectExists && !globalExists) {
344
+ issues.push(recordedGlobalSkills.has(skillId)
345
+ ? `Globally satisfied skill is missing: ${skillId} (${globalPath})`
346
+ : `Missing requested skill: ${projectPath}`);
347
+ } else if (projectExists && !projectIsOwned) {
348
+ issues.push(`Project skill path is not Showdar-owned: ${projectPath}`);
349
+ } else if (globalExists && !globalIsOwned) {
350
+ warnings.push(`Global skill path is not Showdar-owned: ${globalPath}`);
351
+ }
352
+ }
353
+ }
354
+ }
355
+
229
356
  for (const entry of manifest.files ?? []) {
230
357
  const target = safeOwnedPath(baseRoot, entry.path, managedRoots);
231
358
  if (!target) { issues.push(`Invalid managed path: ${entry.path}`); continue; }
232
- if (!(await exists(target))) { issues.push(`Missing managed path: ${entry.path}`); continue; }
359
+ await assertSafeManagedPath(baseRoot, target, managedRoots);
360
+ if (!(await exists(target))) {
361
+ if (!globalSatisfiedPaths.has(entry.path)) issues.push(`Missing managed path: ${entry.path}`);
362
+ continue;
363
+ }
233
364
  const actual = await hashTree(target);
234
365
  if (actual !== entry.hash) issues.push(`Managed path drift detected: ${entry.path}`);
235
366
  }
236
367
 
237
368
  if (agentsRoot) {
238
369
  const agentsFile = path.join(agentsRoot, 'AGENTS.md');
370
+ await assertSafeManagedPath(agentsRoot, agentsFile);
239
371
  if (!(await exists(agentsFile))) issues.push('Missing AGENTS.md routing block');
240
372
  else {
241
373
  const text = await readFile(agentsFile, 'utf8');
@@ -250,26 +382,42 @@ async function inspectInstallation({ baseRoot, manifestPath, agentsRoot, scope,
250
382
  profile: manifest.profile ?? null,
251
383
  ai: manifest.ai ?? null,
252
384
  targets: manifest.targets ?? [],
253
- skills: (manifest.skills ?? []).length,
385
+ skills: skillIds.length,
386
+ requestedSkills: skillIds.length,
387
+ installedSkills: scope === 'project' && globalSkillRootForTarget ? installedSkillIds.size : skillIds.length,
388
+ satisfiedByGlobal: scope === 'project' && globalSkillRootForTarget ? globallySatisfiedSkillIds.size : 0,
254
389
  commands: (manifest.commands ?? []).length,
255
390
  issues,
391
+ warnings,
256
392
  };
257
393
  }
258
394
 
259
395
  async function removeInstallation({ baseRoot, manifestPath, agentsRoot, managedRoots = [] }) {
396
+ await assertSafeManagedPath(baseRoot, manifestPath);
260
397
  let manifest;
261
- try { manifest = await readManifest(manifestPath); }
398
+ try { manifest = await readManifest(manifestPath, baseRoot); }
262
399
  catch { manifest = null; }
263
400
  for (const entry of manifest?.files ?? []) {
264
401
  const target = safeOwnedPath(baseRoot, entry.path, managedRoots);
265
- if (target) await rm(target, { recursive: true, force: true });
402
+ if (target) {
403
+ await assertSafeManagedPath(baseRoot, target, managedRoots);
404
+ await rm(target, { recursive: true, force: true });
405
+ }
266
406
  }
407
+ await assertSafeManagedPath(baseRoot, manifestPath);
267
408
  await rm(manifestPath, { force: true });
268
409
  if (agentsRoot) await removeAgentsBlock(agentsRoot);
269
410
  }
270
411
 
271
- export async function inspectProject(projectRoot) {
272
- return inspectInstallation({ baseRoot: projectRoot, manifestPath: path.join(projectRoot, PROJECT_MANIFEST), agentsRoot: projectRoot, scope: 'project' });
412
+ export async function inspectProject(projectRoot, { homeRoot = homedir() } = {}) {
413
+ return inspectInstallation({
414
+ baseRoot: projectRoot,
415
+ manifestPath: path.join(projectRoot, PROJECT_MANIFEST),
416
+ agentsRoot: projectRoot,
417
+ scope: 'project',
418
+ homeRoot,
419
+ globalSkillRootForTarget: (target) => globalSkillRootFor(target, { homeRoot }),
420
+ });
273
421
  }
274
422
 
275
423
  export async function inspectGlobal({ homeRoot = homedir() } = {}) {