@maccesar/aiskills 1.16.0 → 1.16.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.
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Detection of the maccesar-aiskills Claude Code marketplace plugin.
3
+ *
4
+ * When the plugin is installed, Claude Code already lists our skills and slash
5
+ * commands from its plugin cache, so the CLI must not also install its own copy
6
+ * into ~/.claude/ — that produces duplicate entries in the autocomplete.
7
+ *
8
+ * The subtlety, and the reason this lives in its own module: **the cache on disk
9
+ * does not mean the plugin is installed.** Uninstalling a plugin removes it from
10
+ * `enabledPlugins` in settings.json but leaves the cache directory behind.
11
+ * Treating that leftover directory as proof of installation makes the CLI skip
12
+ * work it should do, which leaves Claude Code with no skills at all and no way
13
+ * for the user to repair it by re-running install. So the question we answer here
14
+ * is "is the plugin enabled AND does it carry this file", never just the latter.
15
+ */
16
+
17
+ import { existsSync, readFileSync, readdirSync } from 'fs';
18
+ import { join } from 'path';
19
+ import {
20
+ CLAUDE_PLUGIN_KEY,
21
+ getClaudePluginSkillsPath,
22
+ getClaudeSettingsPaths,
23
+ } from './config.js';
24
+
25
+ /**
26
+ * Whether Claude Code currently has the aiskills plugin enabled.
27
+ * @param {string} baseDir - Optional base directory (defaults to homedir via config)
28
+ * @returns {boolean} True only when a settings file explicitly enables the plugin
29
+ */
30
+ export function isClaudePluginEnabled(baseDir) {
31
+ for (const settingsPath of getClaudeSettingsPaths(baseDir)) {
32
+ try {
33
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
34
+ if (settings?.enabledPlugins?.[CLAUDE_PLUGIN_KEY] === true) {
35
+ return true;
36
+ }
37
+ } catch {
38
+ // Missing or malformed settings: nothing here says the plugin is enabled.
39
+ // Falling through to `false` is the safe direction — the cost of a wrong
40
+ // `false` is a duplicate entry, the cost of a wrong `true` is a user with
41
+ // no skills installed.
42
+ }
43
+ }
44
+ return false;
45
+ }
46
+
47
+ /**
48
+ * Whether a plugin cache directory exists at all, regardless of whether the
49
+ * plugin is enabled. An enabled plugin implies a cache; a cache implies nothing,
50
+ * because uninstalling leaves it behind. Diagnostics use this to tell "never
51
+ * installed" apart from "uninstalled, leftovers on disk".
52
+ * @param {string} baseDir - Optional base directory
53
+ * @returns {boolean}
54
+ */
55
+ export function hasClaudePluginCache(baseDir) {
56
+ return existsSync(getClaudePluginSkillsPath(baseDir));
57
+ }
58
+
59
+ /**
60
+ * Whether any cached version of the plugin carries the given file.
61
+ * @param {string} kind - Subdirectory inside the plugin ('skills' or 'commands')
62
+ * @param {string} entry - Entry to look for (skill directory or command file)
63
+ * @param {string} baseDir - Optional base directory
64
+ * @returns {boolean} True if a cached version contains it
65
+ */
66
+ function pluginCacheContains(kind, entry, baseDir) {
67
+ const pluginBase = getClaudePluginSkillsPath(baseDir);
68
+ if (!existsSync(pluginBase)) return false;
69
+ try {
70
+ return readdirSync(pluginBase).some((version) =>
71
+ existsSync(join(pluginBase, version, kind, entry))
72
+ );
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Whether the installed plugin already provides this skill to Claude Code.
80
+ * @param {string} skillName - Skill name (e.g. 'session-log')
81
+ * @param {string} baseDir - Optional base directory
82
+ * @returns {boolean}
83
+ */
84
+ export function pluginProvidesSkill(skillName, baseDir) {
85
+ return isClaudePluginEnabled(baseDir) && pluginCacheContains('skills', skillName, baseDir);
86
+ }
87
+
88
+ /**
89
+ * Whether the installed plugin already provides this slash command.
90
+ * @param {string} commandName - Command name without the .md extension
91
+ * @param {string} baseDir - Optional base directory
92
+ * @returns {boolean}
93
+ */
94
+ export function pluginProvidesCommand(commandName, baseDir) {
95
+ return (
96
+ isClaudePluginEnabled(baseDir) && pluginCacheContains('commands', `${commandName}.md`, baseDir)
97
+ );
98
+ }
99
+
100
+ export default {
101
+ isClaudePluginEnabled,
102
+ hasClaudePluginCache,
103
+ pluginProvidesSkill,
104
+ pluginProvidesCommand,
105
+ };
@@ -16,6 +16,11 @@ import {
16
16
  } from '../config.js';
17
17
  import { hasHook } from '../hooks.js';
18
18
  import { readLastCheck } from '../cache.js';
19
+ import {
20
+ isClaudePluginEnabled,
21
+ hasClaudePluginCache,
22
+ pluginProvidesSkill,
23
+ } from '../claude-plugin.js';
19
24
 
20
25
  const CHECK = chalk.green('✓');
21
26
  const CROSS = chalk.red('✗');
@@ -79,8 +84,18 @@ export async function doctorCommand() {
79
84
  for (const platform of platforms) {
80
85
  const missing = [];
81
86
  const broken = [];
87
+ const servedByPlugin = [];
82
88
 
83
89
  for (const skill of SKILLS) {
90
+ // A skill the marketplace plugin provides is *supposed* to have no mirror
91
+ // here — the CLI removes it on purpose to avoid a duplicate entry. Counting
92
+ // it as missing turns a healthy marketplace install into a wall of errors
93
+ // telling the user to run a command that will correctly do nothing.
94
+ if (platform.name === 'claude' && pluginProvidesSkill(skill, homeDir)) {
95
+ servedByPlugin.push(skill);
96
+ continue;
97
+ }
98
+
84
99
  const linkPath = join(platform.skillsDir, skill);
85
100
  try {
86
101
  const stat = lstatSync(linkPath);
@@ -96,15 +111,26 @@ export async function doctorCommand() {
96
111
  }
97
112
  }
98
113
 
99
- const linkedCount = SKILLS.length - missing.length - broken.length;
114
+ const expected = SKILLS.length - servedByPlugin.length;
115
+ const linkedCount = expected - missing.length - broken.length;
116
+ const pluginNote =
117
+ servedByPlugin.length > 0
118
+ ? chalk.dim(` (+${servedByPlugin.length} served by the marketplace plugin)`)
119
+ : '';
100
120
 
101
121
  if (missing.length === 0 && broken.length === 0) {
102
- console.log(` ${CHECK} ${platform.displayName}: ${SKILLS.length}/${SKILLS.length} skills linked`);
122
+ const summary =
123
+ expected === 0
124
+ ? `all ${servedByPlugin.length} skills served by the marketplace plugin`
125
+ : `${linkedCount}/${expected} skills linked${pluginNote}`;
126
+ console.log(` ${CHECK} ${platform.displayName}: ${summary}`);
103
127
  } else {
104
128
  const problems = [];
105
129
  if (missing.length > 0) problems.push(`missing: ${missing.join(', ')}`);
106
130
  if (broken.length > 0) problems.push(`broken: ${broken.join(', ')}`);
107
- console.log(` ${CROSS} ${platform.displayName}: ${linkedCount}/${SKILLS.length} skills linked (${problems.join('; ')})`);
131
+ console.log(
132
+ ` ${CROSS} ${platform.displayName}: ${linkedCount}/${expected} skills linked${pluginNote} (${problems.join('; ')})`
133
+ );
108
134
  issues += missing.length + broken.length;
109
135
 
110
136
  // Collect symlink issues for detailed report
@@ -117,6 +143,27 @@ export async function doctorCommand() {
117
143
  }
118
144
  }
119
145
 
146
+ // Marketplace plugin
147
+ //
148
+ // Worth its own section because the two channels look identical from the
149
+ // outside and produce opposite expectations: with the plugin enabled, absent
150
+ // mirrors are correct; without it, absent mirrors mean no skills at all.
151
+ console.log('');
152
+ console.log(' Marketplace plugin:');
153
+ const pluginEnabled = isClaudePluginEnabled(homeDir);
154
+ const pluginCached = hasClaudePluginCache(homeDir);
155
+
156
+ if (pluginEnabled) {
157
+ console.log(` ${CHECK} Enabled — Claude Code is served by the plugin, mirrors intentionally absent`);
158
+ } else if (pluginCached) {
159
+ console.log(` ${WARN} Not enabled, but a cache directory remains from a previous install`);
160
+ console.log(` ${chalk.dim('Harmless on this version. On aiskills < 1.16.1 it made install skip')}`);
161
+ console.log(` ${chalk.dim('every symlink, leaving Claude Code with no skills. Remove it with:')}`);
162
+ console.log(` ${chalk.cyan('rm -rf ~/.claude/plugins/cache/maccesar-aiskills')}`);
163
+ } else {
164
+ console.log(` ${CHECK} Not installed — skills reach Claude Code through npm mirrors`);
165
+ }
166
+
120
167
  // Symlink issues detail
121
168
  if (symlinkIssues.length > 0) {
122
169
  console.log('');
@@ -79,10 +79,12 @@ export async function skillsCommand(options) {
79
79
  chalk.dim('(agentskills.io standard)')
80
80
  );
81
81
  console.log(
82
- ' ' + chalk.dim('Read directly by Gemini, Codex, Cursor, Cline, Amp, GitHub Copilot, +more.')
82
+ ' ' + chalk.green(''),
83
+ chalk.dim('Gemini, Codex, Cursor, Cline, Amp, GitHub Copilot +more read it directly —')
83
84
  );
85
+ console.log(' ' + chalk.dim('nothing else to configure for them.'));
84
86
  console.log(
85
- ' ' + chalk.dim('Below: extra symlink mirrors for Claude Code.')
87
+ ' ' + chalk.dim('Claude Code needs symlink mirrors, created below.')
86
88
  );
87
89
  console.log('');
88
90
 
@@ -112,10 +114,19 @@ export async function skillsCommand(options) {
112
114
  process.exit(1);
113
115
  }
114
116
 
115
- // Show detected platforms
117
+ // Show detected platforms.
118
+ //
119
+ // Only assistants that need aiskills-managed mirrors appear here, so a bare
120
+ // "Claude Code detected" reads as if the other assistants were not found at
121
+ // all. They were never looked for: Gemini, Codex and the rest read
122
+ // ~/.agents/skills/ directly and are already served by the install itself.
116
123
  if (detectedPlatforms.length > 0) {
117
124
  for (const platform of detectedPlatforms) {
118
- console.log(chalk.green('✓'), `${platform.displayName} detected`);
125
+ console.log(
126
+ chalk.green('✓'),
127
+ `${platform.displayName} detected`,
128
+ chalk.dim('— needs mirrors, linked below')
129
+ );
119
130
  }
120
131
  console.log('');
121
132
  } else if (isLocal) {
@@ -269,6 +280,10 @@ export async function skillsCommand(options) {
269
280
  spinner.succeed(
270
281
  `${commandsResult.installed.length} slash command${commandsResult.installed.length !== 1 ? 's' : ''} installed`
271
282
  );
283
+ } else if (commandsResult.skipped.length > 0) {
284
+ spinner.info(
285
+ `${commandsResult.skipped.length} slash command${commandsResult.skipped.length !== 1 ? 's' : ''} already provided by the marketplace plugin`
286
+ );
272
287
  } else {
273
288
  spinner.info('No slash commands to install');
274
289
  }
@@ -67,6 +67,10 @@ async function performUpdate(baseDir, repoDir, spinner) {
67
67
  spinner.succeed(
68
68
  `${commandsResult.installed.length} slash command${commandsResult.installed.length !== 1 ? 's' : ''} synced`
69
69
  );
70
+ } else if (commandsResult.skipped.length > 0) {
71
+ spinner.info(
72
+ `${commandsResult.skipped.length} slash command${commandsResult.skipped.length !== 1 ? 's' : ''} already provided by the marketplace plugin`
73
+ );
70
74
  } else {
71
75
  spinner.info('No slash commands to sync');
72
76
  }
package/lib/config.js CHANGED
@@ -64,6 +64,17 @@ export const CLAUDE_PLUGIN_NAME = 'aiskills';
64
64
  export const getClaudePluginSkillsPath = (baseDir = os.homedir()) =>
65
65
  path.join(baseDir, '.claude', 'plugins', 'cache', CLAUDE_PLUGIN_MARKETPLACE, CLAUDE_PLUGIN_NAME);
66
66
 
67
+ // The key Claude Code writes under "enabledPlugins" when the plugin is installed.
68
+ export const CLAUDE_PLUGIN_KEY = `${CLAUDE_PLUGIN_NAME}@${CLAUDE_PLUGIN_MARKETPLACE}`;
69
+
70
+ // Where Claude Code records which plugins are enabled. Both files are consulted
71
+ // because the local variant overrides the shared one, and either may carry the
72
+ // entry depending on how the plugin was installed.
73
+ export const getClaudeSettingsPaths = (baseDir = os.homedir()) => [
74
+ path.join(baseDir, '.claude', 'settings.json'),
75
+ path.join(baseDir, '.claude', 'settings.local.json'),
76
+ ];
77
+
67
78
  // AI platform detection
68
79
  //
69
80
  // Only platforms that need aiskills-managed symlinks appear here.
package/lib/installer.js CHANGED
@@ -135,6 +135,7 @@ export async function installCommands(repoDir, baseDir = os.homedir()) {
135
135
  installed: [],
136
136
  failed: [],
137
137
  removed: [],
138
+ skipped: [],
138
139
  };
139
140
 
140
141
  const legacyLocal = removeCommands(baseDir, { legacyOnly: true });
@@ -147,7 +148,24 @@ export async function installCommands(repoDir, baseDir = os.homedir()) {
147
148
  results.failed.push(...legacyGlobal.failed);
148
149
  }
149
150
 
151
+ const { pluginProvidesCommand } = await import('./claude-plugin.js');
152
+ const commandsDir = getClaudeCommandsDir(baseDir);
153
+
150
154
  for (const cmd of COMMANDS) {
155
+ // Same rule the symlink step applies to skills: when the marketplace plugin
156
+ // already provides the command, installing our own copy makes it show up
157
+ // twice in the autocomplete. Clean up any copy left from before the plugin
158
+ // was installed.
159
+ if (pluginProvidesCommand(cmd, baseDir)) {
160
+ const stalePath = join(commandsDir, `${cmd}.md`);
161
+ if (existsSync(stalePath)) {
162
+ await remove(stalePath);
163
+ results.removed.push(cmd);
164
+ }
165
+ results.skipped.push(cmd);
166
+ continue;
167
+ }
168
+
151
169
  if (await installCommand(repoDir, cmd, baseDir)) {
152
170
  results.installed.push(cmd);
153
171
  } else {
package/lib/symlink.js CHANGED
@@ -75,21 +75,16 @@ async function removePath(path) {
75
75
  * and an additional symlink at ~/.claude/skills/<skill> produces a duplicate
76
76
  * entry in the slash-command autocomplete. This helper lets the symlink step
77
77
  * skip Claude when the plugin already covers it.
78
+ *
79
+ * Requires the plugin to be *enabled*, not merely cached — see `claude-plugin.js`
80
+ * for why the distinction matters.
78
81
  * @param {string} skillName - Skill name (e.g. 'stitch-showcase')
79
82
  * @param {string} baseDir - Optional base directory (defaults to homedir via config)
80
83
  * @returns {Promise<boolean>} True if the plugin provides this skill
81
84
  */
82
85
  export async function isClaudePluginSkillInstalled(skillName, baseDir) {
83
- const { readdir } = await import('fs/promises');
84
- const { getClaudePluginSkillsPath } = await import('./config.js');
85
- const pluginBase = getClaudePluginSkillsPath(baseDir);
86
- if (!existsSync(pluginBase)) return false;
87
- try {
88
- const versions = await readdir(pluginBase);
89
- return versions.some((v) => existsSync(join(pluginBase, v, 'skills', skillName)));
90
- } catch {
91
- return false;
92
- }
86
+ const { pluginProvidesSkill } = await import('./claude-plugin.js');
87
+ return pluginProvidesSkill(skillName, baseDir);
93
88
  }
94
89
 
95
90
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maccesar/aiskills",
3
- "version": "1.16.0",
3
+ "version": "1.16.1",
4
4
  "description": "AI coding assistant skills for Claude Code, Gemini CLI, and Codex CLI",
5
5
  "type": "module",
6
6
  "bin": {