mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-14 15:16:35 +03:00
Add Git submodule skill linking
This commit is contained in:
@@ -105,11 +105,31 @@ This auto-detects your harness and writes the build compiled for it to the right
|
||||
|
||||
Claude Code users can alternatively install the plugin with `/plugin marketplace add pbakaus/impeccable`. The general-purpose `npx skills add pbakaus/impeccable` also works, though it installs one shared build for all harnesses rather than the one compiled for yours.
|
||||
|
||||
### Option 2: Download from Website
|
||||
### Option 2: Git Submodule
|
||||
|
||||
For teams that want to keep Impeccable vendored and updated through Git, add this repo as a submodule and link the compiled provider build into your harness folders:
|
||||
|
||||
```bash
|
||||
git submodule add https://github.com/pbakaus/impeccable .impeccable
|
||||
npx impeccable skills link --source=.impeccable --providers=claude,cursor
|
||||
git add .gitmodules .impeccable .claude .cursor
|
||||
git commit -m "Add Impeccable skills"
|
||||
```
|
||||
|
||||
Use the providers your project needs, for example `claude`, `cursor`, `gemini`, `codex`, `github`, `opencode`, `pi`, `qoder`, `trae`, `trae-cn`, or `rovo-dev`. The command links individual skill folders from `.impeccable/dist/universal/` and leaves existing real skill directories untouched unless you pass `--force`.
|
||||
|
||||
To update later:
|
||||
|
||||
```bash
|
||||
git submodule update --remote .impeccable
|
||||
npx impeccable skills link --source=.impeccable --providers=claude,cursor
|
||||
```
|
||||
|
||||
### Option 3: Download from Website
|
||||
|
||||
Visit [impeccable.style](https://impeccable.style), download the ZIP for your tool, and extract to your project.
|
||||
|
||||
### Option 3: Copy from Repository
|
||||
### Option 4: Copy from Repository
|
||||
|
||||
**Cursor:**
|
||||
```bash
|
||||
|
||||
@@ -11,6 +11,9 @@ npx impeccable skills install
|
||||
# Update skills to the latest version
|
||||
npx impeccable skills update
|
||||
|
||||
# Link skills from a Git submodule checkout
|
||||
npx impeccable skills link --source=.impeccable --providers=claude,cursor
|
||||
|
||||
# List all available commands
|
||||
npx impeccable skills help
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ Commands:
|
||||
detect [file-or-dir-or-url...] Scan for UI anti-patterns and design quality issues
|
||||
skills help List all available skills and commands
|
||||
skills install Install impeccable skills into your project
|
||||
skills link Symlink skills from a local checkout or submodule
|
||||
skills update Update skills to the latest version
|
||||
skills check Check if skill updates are available
|
||||
|
||||
|
||||
+208
-12
@@ -4,12 +4,13 @@
|
||||
* Usage:
|
||||
* impeccable skills help Show all available skills and commands
|
||||
* impeccable skills install Install compiled skills from the universal bundle
|
||||
* impeccable skills link Symlink compiled skills from a local checkout
|
||||
* impeccable skills update Update skills to latest version
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync, readdirSync, statSync, lstatSync, unlinkSync, mkdirSync, writeFileSync, rmSync, renameSync, createWriteStream, realpathSync } from 'node:fs';
|
||||
import { join, resolve, dirname } from 'node:path';
|
||||
import { existsSync, readFileSync, readdirSync, statSync, lstatSync, unlinkSync, mkdirSync, writeFileSync, rmSync, renameSync, createWriteStream, realpathSync, symlinkSync, readlinkSync } from 'node:fs';
|
||||
import { join, resolve, dirname, relative, isAbsolute } from 'node:path';
|
||||
import { createInterface } from 'node:readline';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { get } from 'node:https';
|
||||
@@ -21,7 +22,25 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const API_BASE = 'https://impeccable.style';
|
||||
|
||||
// Provider folder names in project roots
|
||||
const PROVIDER_DIRS = ['.claude', '.cursor', '.gemini', '.agents', '.github', '.kiro', '.opencode', '.pi', '.qoder', '.trae', '.trae-cn'];
|
||||
const PROVIDER_DIRS = ['.claude', '.cursor', '.gemini', '.agents', '.github', '.kiro', '.opencode', '.pi', '.qoder', '.trae', '.trae-cn', '.rovodev'];
|
||||
const PROVIDER_ALIASES = {
|
||||
agents: '.agents',
|
||||
claude: '.claude',
|
||||
'claude-code': '.claude',
|
||||
codex: '.agents',
|
||||
copilot: '.github',
|
||||
cursor: '.cursor',
|
||||
gemini: '.gemini',
|
||||
github: '.github',
|
||||
kiro: '.kiro',
|
||||
opencode: '.opencode',
|
||||
pi: '.pi',
|
||||
qoder: '.qoder',
|
||||
'rovo-dev': '.rovodev',
|
||||
rovodev: '.rovodev',
|
||||
trae: '.trae',
|
||||
'trae-cn': '.trae-cn',
|
||||
};
|
||||
|
||||
// When a project has no harness folder yet, infer the target from globally
|
||||
// installed harnesses (~/.claude, ~/.codex, ...). Codex reads skills from
|
||||
@@ -34,6 +53,7 @@ const GLOBAL_HARNESS_HINTS = [
|
||||
{ home: '.kiro', provider: '.kiro' },
|
||||
{ home: '.opencode', provider: '.opencode' },
|
||||
{ home: '.qoder', provider: '.qoder' },
|
||||
{ home: '.rovodev', provider: '.rovodev' },
|
||||
];
|
||||
|
||||
// Last-resort default when nothing is detected: Claude Code + the universal
|
||||
@@ -61,6 +81,7 @@ async function showHelp() {
|
||||
|
||||
console.log('\n Impeccable Skills & Commands\n');
|
||||
console.log(' Install: npx impeccable skills install');
|
||||
console.log(' Link: npx impeccable skills link --source=.impeccable');
|
||||
console.log(' Update: npx impeccable skills update');
|
||||
console.log(' Docs: https://impeccable.style/cheatsheet\n');
|
||||
console.log(` ${pad('Command', 22)} Description`);
|
||||
@@ -291,6 +312,25 @@ function migrateUnprefixImpeccable(root) {
|
||||
return migrated;
|
||||
}
|
||||
|
||||
function getFlagValue(flags, name) {
|
||||
const prefix = `${name}=`;
|
||||
const inline = flags.find(f => f.startsWith(prefix));
|
||||
if (inline) return inline.slice(prefix.length);
|
||||
const index = flags.indexOf(name);
|
||||
if (index !== -1 && flags[index + 1] && !flags[index + 1].startsWith('-')) {
|
||||
return flags[index + 1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeProviderName(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) return null;
|
||||
if (PROVIDER_DIRS.includes(raw)) return raw;
|
||||
const key = raw.replace(/^\./, '').toLowerCase();
|
||||
return PROVIDER_ALIASES[key] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide which provider folders to install into.
|
||||
* 1. An explicit --providers=.claude,.cursor list wins.
|
||||
@@ -304,8 +344,8 @@ function resolveInstallTargets(root, providersValue) {
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean)
|
||||
.map(s => (s.startsWith('.') ? s : `.${s}`))
|
||||
.filter(p => PROVIDER_DIRS.includes(p));
|
||||
.map(normalizeProviderName)
|
||||
.filter(Boolean);
|
||||
return [...new Set(wanted)];
|
||||
}
|
||||
|
||||
@@ -351,10 +391,144 @@ function copyProviderSkills(bundleDir, root, targets) {
|
||||
return written;
|
||||
}
|
||||
|
||||
function resolveLinkSource(sourceValue, root) {
|
||||
const sourcePath = sourceValue || '.impeccable';
|
||||
const checkoutRoot = isAbsolute(sourcePath) ? sourcePath : resolve(root, sourcePath);
|
||||
const universalRoot = join(checkoutRoot, 'dist', 'universal');
|
||||
if (existsSync(universalRoot)) {
|
||||
return { checkoutRoot, bundleRoot: universalRoot };
|
||||
}
|
||||
if (PROVIDER_DIRS.some(provider => existsSync(join(checkoutRoot, provider, 'skills')))) {
|
||||
return { checkoutRoot, bundleRoot: checkoutRoot };
|
||||
}
|
||||
throw new Error(`Could not find compiled skills in ${sourcePath}. Expected dist/universal/ or provider skill folders.`);
|
||||
}
|
||||
|
||||
function pathExistsOrLink(path) {
|
||||
try {
|
||||
lstatSync(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isSymlinkTo(dest, expectedSource) {
|
||||
try {
|
||||
if (!lstatSync(dest).isSymbolicLink()) return false;
|
||||
const target = readlinkSync(dest);
|
||||
const resolvedTarget = resolve(dirname(dest), target);
|
||||
return realpathSync(resolvedTarget) === realpathSync(expectedSource);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveUniqueLinkTargets(root, targets) {
|
||||
const seen = new Set();
|
||||
const unique = [];
|
||||
for (const provider of targets) {
|
||||
const localSkillsDir = join(root, provider, 'skills');
|
||||
mkdirSync(localSkillsDir, { recursive: true });
|
||||
const real = realpathSync(localSkillsDir);
|
||||
if (seen.has(real)) continue;
|
||||
seen.add(real);
|
||||
unique.push({ provider, localSkillsDir });
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
|
||||
function linkProviderSkills(bundleRoot, root, targets, { force = false } = {}) {
|
||||
let linked = 0;
|
||||
let already = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const { provider, localSkillsDir } of resolveUniqueLinkTargets(root, targets)) {
|
||||
const srcDir = join(bundleRoot, provider, 'skills');
|
||||
if (!existsSync(srcDir)) continue;
|
||||
|
||||
for (const skill of readdirSync(srcDir, { withFileTypes: true })) {
|
||||
if (!skill.isDirectory()) continue;
|
||||
const src = join(srcDir, skill.name);
|
||||
const dest = join(localSkillsDir, skill.name);
|
||||
|
||||
if (pathExistsOrLink(dest)) {
|
||||
if (isSymlinkTo(dest, src)) {
|
||||
already++;
|
||||
continue;
|
||||
}
|
||||
if (!force) {
|
||||
console.warn(`Skipped existing ${provider}/skills/${skill.name}. Use --force to replace it with a link.`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
rmSync(dest, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const target = relative(dirname(dest), src) || '.';
|
||||
symlinkSync(target, dest, 'dir');
|
||||
linked++;
|
||||
}
|
||||
}
|
||||
|
||||
return { linked, already, skipped };
|
||||
}
|
||||
|
||||
async function link(flags) {
|
||||
const force = flags.includes('--force');
|
||||
const yes = flags.includes('-y') || flags.includes('--yes');
|
||||
const sourceValue = getFlagValue(flags, '--source');
|
||||
const providersValue = getFlagValue(flags, '--providers');
|
||||
const root = findProjectRoot();
|
||||
|
||||
let source;
|
||||
try {
|
||||
source = resolveLinkSource(sourceValue, root);
|
||||
} catch (e) {
|
||||
console.error(e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const targets = resolveInstallTargets(root, providersValue);
|
||||
if (targets.length === 0) {
|
||||
console.error('Could not determine a target harness folder.');
|
||||
console.error('Pass one explicitly, e.g. --providers=claude,cursor');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!yes) {
|
||||
console.log(`Source checkout: ${source.checkoutRoot}`);
|
||||
console.log(`Target harness folder(s): ${targets.join(', ')}`);
|
||||
const ans = await ask(`Link impeccable skills into ${targets.length} folder(s)? (Y/n) `);
|
||||
if (ans === 'n' || ans === 'no') {
|
||||
console.log('Aborted. Re-run with --providers=<names> to choose explicitly (e.g. --providers=claude,cursor).');
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
const result = linkProviderSkills(source.bundleRoot, root, targets, { force });
|
||||
if (result.linked === 0 && result.already === 0) {
|
||||
if (result.skipped > 0) {
|
||||
console.error('Nothing was linked because matching skill folders already exist.');
|
||||
console.error('Existing skills were left untouched. Re-run with --force to replace them with links.');
|
||||
} else {
|
||||
console.error(`Nothing was linked: ${source.bundleRoot} had no variants for ${targets.join(', ')}.`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const parts = [];
|
||||
if (result.linked > 0) parts.push(`${result.linked} linked`);
|
||||
if (result.already > 0) parts.push(`${result.already} already linked`);
|
||||
if (result.skipped > 0) parts.push(`${result.skipped} skipped`);
|
||||
console.log(`Linked impeccable into: ${targets.join(', ')} (${parts.join(', ')}).`);
|
||||
console.log('Update with `git submodule update --remote` from your project root, then rerun this command if new skills are added.\n');
|
||||
}
|
||||
|
||||
async function install(flags) {
|
||||
const force = flags.includes('--force');
|
||||
const yes = flags.includes('-y') || flags.includes('--yes');
|
||||
const providersFlag = flags.find(f => f.startsWith('--providers='));
|
||||
const providersValue = getFlagValue(flags, '--providers');
|
||||
const root = findProjectRoot();
|
||||
const existing = isAlreadyInstalled(root);
|
||||
|
||||
@@ -369,7 +543,7 @@ async function install(flags) {
|
||||
// to `npx skills add`: its name-based discovery can install the uncompiled
|
||||
// source, and its symlink default points every harness at one shared variant.
|
||||
// Copying per-provider variants is the only correct install for this skill.
|
||||
const targets = resolveInstallTargets(root, providersFlag ? providersFlag.split('=')[1] : null);
|
||||
const targets = resolveInstallTargets(root, providersValue);
|
||||
if (targets.length === 0) {
|
||||
console.error('Could not determine a target harness folder.');
|
||||
console.error('Pass one explicitly, e.g. --providers=.claude,.cursor');
|
||||
@@ -453,6 +627,17 @@ function findInstalledProviders(root) {
|
||||
return found;
|
||||
}
|
||||
|
||||
function findLinkedProviders(root, providers) {
|
||||
return providers.filter(provider => {
|
||||
const skillDir = join(root, provider, 'skills', 'impeccable');
|
||||
try {
|
||||
return lstatSync(skillDir).isSymbolicLink();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getModifiedSkillFiles(root, providerDirs) {
|
||||
// Use git to check if any skill files have local modifications
|
||||
const modified = [];
|
||||
@@ -517,6 +702,8 @@ async function update(flags = []) {
|
||||
// (vercel-labs/skills#775) where it can't find the lock file.
|
||||
const root = findProjectRoot();
|
||||
const providers = findInstalledProviders(root);
|
||||
const linkedProviders = findLinkedProviders(root, providers);
|
||||
const copyProviders = providers.filter(provider => !linkedProviders.includes(provider));
|
||||
|
||||
if (providers.length === 0) {
|
||||
console.log('No impeccable skill folders found in this project.');
|
||||
@@ -524,6 +711,13 @@ async function update(flags = []) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (linkedProviders.length > 0) {
|
||||
console.log(`Linked skills found in: ${linkedProviders.join(', ')}`);
|
||||
console.log('Update the source checkout with `git submodule update --remote`, then rerun `npx impeccable skills link --source=.impeccable` if new skills are added.');
|
||||
if (copyProviders.length === 0) process.exit(0);
|
||||
console.log(`Continuing with copied installs in: ${copyProviders.join(', ')}\n`);
|
||||
}
|
||||
|
||||
console.log('Checking for updates...');
|
||||
|
||||
let tmpDir;
|
||||
@@ -535,17 +729,17 @@ async function update(flags = []) {
|
||||
}
|
||||
|
||||
// Compare local vs remote -- skip if already up to date
|
||||
if (isUpToDate(root, providers, tmpDir)) {
|
||||
if (isUpToDate(root, copyProviders, tmpDir)) {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
const v = getSkillsVersion(root);
|
||||
console.log(`Skills are up to date${v ? ` (v${v})` : ''}. Nothing to do.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`Found skills in: ${providers.join(', ')}`);
|
||||
console.log(`Found skills in: ${copyProviders.join(', ')}`);
|
||||
|
||||
if (!yes) {
|
||||
const ans = await ask(`Update skills in ${providers.length} provider folder(s)? (Y/n) `);
|
||||
const ans = await ask(`Update skills in ${copyProviders.length} provider folder(s)? (Y/n) `);
|
||||
if (ans === 'n' || ans === 'no') {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
console.log('Aborted.');
|
||||
@@ -563,7 +757,7 @@ async function update(flags = []) {
|
||||
// Copy from the bundle to each unique provider folder.
|
||||
// Deduplicate so symlinked dirs (e.g. .claude/skills -> .agents/skills)
|
||||
// are only written once with the correct provider's content.
|
||||
const unique = deduplicateProviders(root, providers);
|
||||
const unique = deduplicateProviders(root, copyProviders);
|
||||
let updated = 0;
|
||||
for (const { provider, localSkillsDir } of unique) {
|
||||
const srcDir = join(tmpDir, provider, 'skills');
|
||||
@@ -616,7 +810,7 @@ function copyDirSync(src, dest) {
|
||||
// ─── Test surface ───────────────────────────────────────────────────────────
|
||||
// Exported so the test suite exercises the real implementation rather than a
|
||||
// reimplementation in a helper script (which is how bugs slip through).
|
||||
export { migrateUnprefixImpeccable };
|
||||
export { migrateUnprefixImpeccable, linkProviderSkills, resolveLinkSource };
|
||||
|
||||
// ─── Router ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -627,6 +821,8 @@ export async function run(args) {
|
||||
await showHelp();
|
||||
} else if (sub === 'install') {
|
||||
await install(args.slice(1));
|
||||
} else if (sub === 'link') {
|
||||
await link(args.slice(1));
|
||||
} else if (sub === 'update') {
|
||||
await update(args.slice(1));
|
||||
} else if (sub === 'check') {
|
||||
|
||||
+110
-1
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { execSync } from 'child_process';
|
||||
import { mkdtempSync, existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync, rmSync } from 'fs';
|
||||
import { mkdtempSync, existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync, rmSync, lstatSync, realpathSync, readlinkSync, symlinkSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { migrateUnprefixImpeccable } from '../cli/bin/commands/skills.mjs';
|
||||
@@ -51,6 +51,12 @@ function writeSkill(root, provider, name) {
|
||||
writeFileSync(join(dir, 'SKILL.md'), `---\nname: ${name}\n---\nRun /${name}.\n`);
|
||||
}
|
||||
|
||||
function createFakeLinkSource(root, providers = ['.claude']) {
|
||||
for (const provider of providers) {
|
||||
writeSkill(join(root, '.impeccable', 'dist', 'universal'), provider, 'impeccable');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate an install from the era when the CLI offered a command prefix: the
|
||||
* skill lives at `<prefix>impeccable`. Optionally drop in a third-party skill
|
||||
@@ -106,6 +112,109 @@ describe('skills install: already-installed detection', () => {
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
// ─── Submodule/link installs ────────────────────────────────────────────────
|
||||
|
||||
describe('skills link: submodule installs', () => {
|
||||
test('creates relative skill symlinks from dist/universal', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-link-'));
|
||||
execSync('git init', { cwd: tmp });
|
||||
createFakeLinkSource(tmp, ['.claude', '.cursor']);
|
||||
|
||||
const output = run('skills link --source=.impeccable --providers=claude,cursor -y', { cwd: tmp });
|
||||
expect(output).toContain('Linked impeccable into: .claude, .cursor');
|
||||
|
||||
for (const provider of ['.claude', '.cursor']) {
|
||||
const dest = join(tmp, provider, 'skills', 'impeccable');
|
||||
const src = join(tmp, '.impeccable', 'dist', 'universal', provider, 'skills', 'impeccable');
|
||||
expect(lstatSync(dest).isSymbolicLink()).toBe(true);
|
||||
expect(readlinkSync(dest).startsWith('/')).toBe(false);
|
||||
expect(realpathSync(dest)).toBe(realpathSync(src));
|
||||
}
|
||||
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}, 15000);
|
||||
|
||||
test('is idempotent when links already point at the same source', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-link-again-'));
|
||||
execSync('git init', { cwd: tmp });
|
||||
createFakeLinkSource(tmp);
|
||||
|
||||
run('skills link --source=.impeccable --providers=claude -y', { cwd: tmp });
|
||||
const before = readlinkSync(join(tmp, '.claude', 'skills', 'impeccable'));
|
||||
const output = run('skills link --source=.impeccable --providers=claude -y', { cwd: tmp });
|
||||
|
||||
expect(output).toContain('already linked');
|
||||
expect(readlinkSync(join(tmp, '.claude', 'skills', 'impeccable'))).toBe(before);
|
||||
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}, 15000);
|
||||
|
||||
test('does not overwrite an existing real skill unless forced', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-link-existing-'));
|
||||
execSync('git init', { cwd: tmp });
|
||||
createFakeLinkSource(tmp);
|
||||
writeSkill(tmp, '.claude', 'impeccable');
|
||||
|
||||
expect(() => run('skills link --source=.impeccable --providers=claude -y', { cwd: tmp })).toThrow();
|
||||
const dest = join(tmp, '.claude', 'skills', 'impeccable');
|
||||
expect(lstatSync(dest).isSymbolicLink()).toBe(false);
|
||||
|
||||
const output = run('skills link --source=.impeccable --providers=claude -y --force', { cwd: tmp });
|
||||
expect(output).toContain('1 linked');
|
||||
expect(lstatSync(dest).isSymbolicLink()).toBe(true);
|
||||
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}, 15000);
|
||||
|
||||
test('maps codex and rovo-dev provider aliases to their install folders', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-link-alias-'));
|
||||
execSync('git init', { cwd: tmp });
|
||||
createFakeLinkSource(tmp, ['.agents', '.rovodev']);
|
||||
|
||||
run('skills link --source=.impeccable --providers=codex,rovo-dev -y', { cwd: tmp });
|
||||
|
||||
expect(lstatSync(join(tmp, '.agents', 'skills', 'impeccable')).isSymbolicLink()).toBe(true);
|
||||
expect(lstatSync(join(tmp, '.rovodev', 'skills', 'impeccable')).isSymbolicLink()).toBe(true);
|
||||
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}, 15000);
|
||||
|
||||
test('skills update leaves linked installs on the submodule path', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-link-update-'));
|
||||
execSync('git init', { cwd: tmp });
|
||||
createFakeLinkSource(tmp);
|
||||
run('skills link --source=.impeccable --providers=claude -y', { cwd: tmp });
|
||||
|
||||
const dest = join(tmp, '.claude', 'skills', 'impeccable');
|
||||
const before = readlinkSync(dest);
|
||||
const output = run('skills update -y', { cwd: tmp });
|
||||
|
||||
expect(output).toContain('Linked skills found in: .claude');
|
||||
expect(readlinkSync(dest)).toBe(before);
|
||||
expect(lstatSync(dest).isSymbolicLink()).toBe(true);
|
||||
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}, 15000);
|
||||
|
||||
test('deduplicates providers that share one skills directory', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-link-shared-'));
|
||||
execSync('git init', { cwd: tmp });
|
||||
createFakeLinkSource(tmp, ['.claude', '.agents']);
|
||||
mkdirSync(join(tmp, '.agents', 'skills'), { recursive: true });
|
||||
mkdirSync(join(tmp, '.claude'), { recursive: true });
|
||||
symlinkSync('../.agents/skills', join(tmp, '.claude', 'skills'), 'dir');
|
||||
|
||||
run('skills link --source=.impeccable --providers=claude,codex -y', { cwd: tmp });
|
||||
|
||||
const dest = join(tmp, '.agents', 'skills', 'impeccable');
|
||||
const src = join(tmp, '.impeccable', 'dist', 'universal', '.claude', 'skills', 'impeccable');
|
||||
expect(lstatSync(dest).isSymbolicLink()).toBe(true);
|
||||
expect(realpathSync(dest)).toBe(realpathSync(src));
|
||||
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
// ─── Unprefix migration (real implementation, real filesystem) ───────────────
|
||||
//
|
||||
// The CLI no longer offers a command prefix (the `i-` rename only made sense
|
||||
|
||||
Reference in New Issue
Block a user