Compare commits

..
Author SHA1 Message Date
Paul Bakaus 53cb5c8cf7 Centralize live path glob matching
AI-assisted change prepared by Codex under scheduled architecture-simplification authorization from maintainer pbakaus.
2026-08-21 12:23:55 -07:00
6 changed files with 78 additions and 199 deletions
+7 -36
View File
@@ -699,7 +699,7 @@ function deduplicateProviders(root, providers, scope) {
* SKILL.md, so script-only fixes and removed files are detected.
* Returns true if every bundle skill matches the local copy.
*/
function isUpToDate(root, providers, bundleDir, scope, agentScope = scope) {
function isUpToDate(root, providers, bundleDir, scope) {
const unique = deduplicateProviders(root, providers, scope);
if (unique.length === 0) return false;
@@ -724,8 +724,6 @@ function isUpToDate(root, providers, bundleDir, scope, agentScope = scope) {
if (bundleHash !== localHash) return false;
}
}
if (!providerAgentsUpToDate(bundleDir, root, provider, agentScope)) return false;
}
return true;
}
@@ -747,8 +745,7 @@ async function check() {
console.log('Checking for updates...\n');
try {
const bundleDir = await downloadAndExtractBundle();
const agentScope = isHomeDir(root) ? 'user' : undefined;
const upToDate = isUpToDate(root, providers, bundleDir, undefined, agentScope);
const upToDate = isUpToDate(root, providers, bundleDir);
rmSync(bundleDir, { recursive: true, force: true });
if (upToDate) {
@@ -1254,9 +1251,7 @@ function copyProviderSkills(bundleDir, root, targets, { scope } = {}) {
}
// Native subagent definitions that ship in the bundle next to a provider's
// skills. Claude Code's live at `.claude/agents/impeccable-*.md`; project
// agents take precedence over user agents. GitHub Copilot's live at
// `.github/agents/impeccable-*.agent.md`:
// skills. GitHub Copilot's live at `.github/agents/impeccable-*.agent.md`:
// project installs commit them at `<repo>/.github/agents/`, user-level
// installs go to `~/.copilot/agents/` (Copilot's user-scope dir, NOT
// `~/.github/`). On a name conflict Copilot lets the user-level file shadow
@@ -1266,11 +1261,6 @@ function copyProviderSkills(bundleDir, root, targets, { scope } = {}) {
// `~/.cursor/agents/`; project agents take precedence there, so no shadow
// warning is needed.
const PROVIDER_AGENT_ARTIFACTS = {
'.claude': {
ext: '.md',
userDir: home => join(home, '.claude', 'agents'),
userShadowsProject: false,
},
'.github': {
ext: '.agent.md',
userDir: home => join(home, '.copilot', 'agents'),
@@ -1283,23 +1273,6 @@ const PROVIDER_AGENT_ARTIFACTS = {
},
};
function providerAgentsUpToDate(bundleDir, root, provider, scope) {
const artifact = PROVIDER_AGENT_ARTIFACTS[provider];
if (!artifact) return true;
const srcDir = join(bundleDir, provider, 'agents');
if (!existsSync(srcDir)) return true;
const destDir = scope === 'user'
? artifact.userDir(root)
: join(root, provider, 'agents');
const agentFiles = readdirSync(srcDir).filter(name => name.endsWith(artifact.ext));
return agentFiles.every(name => {
const localPath = join(destDir, name);
return existsSync(localPath)
&& hashSkillFile(join(srcDir, name)) === hashSkillFile(localPath);
});
}
function copyProviderAgents(bundleDir, root, providers, { scope, home = homedir() } = {}) {
const targets = Array.isArray(providers) ? providers : [providers];
const results = [];
@@ -2099,9 +2072,7 @@ function resolveUpdateTarget({ projectRoot, home, explicitScope }) {
const homeRooted = isHomeDir(projectRoot);
if (homeRooted && !explicitScope) {
const providers = findInstalledProviders(home);
return providers.length
? { root: home, scope: undefined, agentScope: 'user', providers, scopeLabel: 'user level' }
: null;
return providers.length ? { root: home, scope: undefined, providers, scopeLabel: 'user level' } : null;
}
const projectProviders = homeRooted ? [] : findImpeccableProviders(projectRoot, 'project');
@@ -2232,7 +2203,7 @@ async function update(flags = []) {
: { root: projectRoot, scope: 'project', providers: target.projectProviders, scopeLabel: 'this project' };
}
const { root, scope, agentScope = scope } = target;
const { root, scope } = target;
console.log(`Updating the ${target.scopeLabel} install: ${formatPathForDisplay(root)} (${target.providers.join(', ')})`);
const providers = target.providers;
const linkedProviders = findLinkedProviders(root, providers, scope);
@@ -2256,7 +2227,7 @@ async function update(flags = []) {
}
// Compare local vs remote -- skip if already up to date
if (isUpToDate(root, copyProviders, tmpDir, scope, agentScope)) {
if (isUpToDate(root, copyProviders, tmpDir, scope)) {
try {
const wantHooks = installHooks && await decideHookInstall(root, copyProviders, { yes });
const hookTargets = wantHooks ? copyProviderHooks(tmpDir, root, copyProviders, { force }) : [];
@@ -2292,7 +2263,7 @@ async function update(flags = []) {
if (migrated > 0) console.log('Migrated a prefixed install back to /impeccable (the i- prefix is no longer used).');
const updated = refreshProviderSkills(tmpDir, root, copyProviders, scope);
reportProviderAgents(copyProviderAgents(tmpDir, root, copyProviders, { scope: agentScope }));
reportProviderAgents(copyProviderAgents(tmpDir, root, copyProviders, { scope }));
const wantHooks = installHooks && await decideHookInstall(root, providers, { yes });
const hookTargets = wantHooks ? copyProviderHooks(tmpDir, root, providers, { force }) : [];
+37
View File
@@ -0,0 +1,37 @@
/**
* Convert a live-config glob pattern to a RegExp.
*
* Supports `**` across path segments, `*` within one segment, and `?` for one
* character. Callers normalize project-relative paths to forward slashes.
*/
export function livePathGlobToRegex(pattern) {
let re = '';
let i = 0;
while (i < pattern.length) {
const c = pattern[i];
if (c === '*') {
if (pattern[i + 1] === '*') {
if (pattern[i + 2] === '/') {
re += '(?:.*/)?';
i += 3;
} else {
re += '.*';
i += 2;
}
} else {
re += '[^/]*';
i += 1;
}
} else if (c === '?') {
re += '[^/]';
i += 1;
} else if (/[.+^${}()|[\]\\]/.test(c)) {
re += `\\${c}`;
i += 1;
} else {
re += c;
i += 1;
}
}
return new RegExp(`^${re}$`);
}
+2 -42
View File
@@ -27,6 +27,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs';
import { livePathGlobToRegex } from './lib/live-path-globs.mjs';
import {
describeInjectArtifacts,
frameworkIgnorePatterns,
@@ -364,7 +365,7 @@ export function resolveFiles(rootDir, config) {
const patterns = config.files;
const userExcludes = Array.isArray(config.exclude) ? config.exclude : [];
const allExcludes = [...HARD_EXCLUDES, ...userExcludes];
const excludeRegexes = allExcludes.map(globToRegex);
const excludeRegexes = allExcludes.map(livePathGlobToRegex);
const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath));
const isGlob = (s) => /[*?[]/.test(s);
@@ -401,47 +402,6 @@ export function resolveFiles(rootDir, config) {
return out;
}
/**
* Convert a glob pattern to a RegExp. Supports:
* ** → any number of path segments (including zero)
* * → any chars except `/`
* ? → any single char except `/`
* Paths are normalized to forward slashes before matching.
*/
function globToRegex(pattern) {
let re = '';
let i = 0;
while (i < pattern.length) {
const c = pattern[i];
if (c === '*') {
if (pattern[i + 1] === '*') {
// ** — any number of segments, including zero. Handle the common
// **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`.
if (pattern[i + 2] === '/') {
re += '(?:.*/)?';
i += 3;
} else {
re += '.*';
i += 2;
}
} else {
re += '[^/]*';
i += 1;
}
} else if (c === '?') {
re += '[^/]';
i += 1;
} else if (/[.+^${}()|[\]\\]/.test(c)) {
re += '\\' + c;
i += 1;
} else {
re += c;
i += 1;
}
}
return new RegExp('^' + re + '$');
}
// ---------------------------------------------------------------------------
// Core operations
// ---------------------------------------------------------------------------
+2 -33
View File
@@ -24,6 +24,7 @@ import { fileURLToPath } from 'node:url';
import { resolveTargetSelection } from './context.mjs';
import { resolveFiles } from './live-inject.mjs';
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
import { livePathGlobToRegex } from './lib/live-path-globs.mjs';
import { resolveSurfaceBrief } from './lib/surface-briefs.mjs';
import { resolveLiveTarget } from './live-target.mjs';
import { bootInstructions } from './live/instructions.mjs';
@@ -240,7 +241,7 @@ function scanForDrift(rootDir, resolvedFiles, config) {
// Files matching the user's `exclude` globs are intentional omissions,
// not drift. Compile them to regexes so the orphan list stays signal.
const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : [])
.map((p) => globToRegex(p));
.map(livePathGlobToRegex);
const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel));
const orphans = [];
@@ -278,38 +279,6 @@ function scanForDrift(rootDir, resolvedFiles, config) {
};
}
/**
* Same glob-to-regex mapping used by live-inject.mjs. Kept inline here
* to avoid a circular import (live-inject.mjs already imports nothing
* from live.mjs). The two must stay in sync.
*/
function globToRegex(pattern) {
let re = '';
let i = 0;
while (i < pattern.length) {
const c = pattern[i];
if (c === '*') {
if (pattern[i + 1] === '*') {
if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; }
else { re += '.*'; i += 2; }
} else {
re += '[^/]*';
i += 1;
}
} else if (c === '?') {
re += '[^/]';
i += 1;
} else if (/[.+^${}()|[\]\\]/.test(c)) {
re += '\\' + c;
i += 1;
} else {
re += c;
i += 1;
}
}
return new RegExp('^' + re + '$');
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
+28
View File
@@ -10,10 +10,38 @@ import { dirname, join, relative, resolve } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import { livePathGlobToRegex } from '../skill/scripts/lib/live-path-globs.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const INJECT = resolve(__dirname, '..', 'skill/scripts/live-inject.mjs');
describe('live path globs', () => {
it('matches recursive segments, including zero segments', () => {
const anywhere = livePathGlobToRegex('**/index.html');
assert.equal(anywhere.test('index.html'), true);
assert.equal(anywhere.test('public/index.html'), true);
assert.equal(anywhere.test('apps/web/public/index.html'), true);
const underPublic = livePathGlobToRegex('public/**/*.html');
assert.equal(underPublic.test('public/index.html'), true);
assert.equal(underPublic.test('public/docs/index.html'), true);
assert.equal(underPublic.test('src/index.html'), false);
});
it('keeps single-star and question-mark matches inside one segment', () => {
const pattern = livePathGlobToRegex('pages/*/item?.html');
assert.equal(pattern.test('pages/docs/item1.html'), true);
assert.equal(pattern.test('pages/docs/deep/item1.html'), false);
assert.equal(pattern.test('pages/docs/item12.html'), false);
});
it('treats regular-expression punctuation as literal path text', () => {
const pattern = livePathGlobToRegex('pages/[draft]/item+.html');
assert.equal(pattern.test('pages/[draft]/item+.html'), true);
assert.equal(pattern.test('pages/d/itemm.html'), false);
});
});
function runInject(cwd, configPath, args) {
try {
const out = execFileSync('node', [INJECT, ...args], {
+2 -88
View File
@@ -84,13 +84,11 @@ function createFakeUniversalBundle(root, providers = ['.claude', '.agents', '.cu
writeFileSync(join(skillDir, 'scripts', 'context.mjs'), 'console.log("local bundle context");\n');
}
if (providers.includes('.claude')) {
mkdirSync(join(bundleRoot, '.claude', 'agents'), { recursive: true });
mkdirSync(join(bundleRoot, '.claude'), { recursive: true });
writeFileSync(join(bundleRoot, '.claude', 'settings.json'), JSON.stringify({
description: 'fresh claude hook',
hooks: { PostToolUse: [{ matcher: 'Edit', hooks: [{ type: 'command', command: 'node ".claude/skills/impeccable/scripts/hook.mjs"' }] }] },
}, null, 2));
writeFileSync(join(bundleRoot, '.claude', 'agents', 'impeccable-finish-reviewer.md'),
'---\nname: impeccable-finish-reviewer\ndescription: Reviews a finished build.\n---\nClaude reviewer body.\n');
}
if (providers.includes('.cursor')) {
mkdirSync(join(bundleRoot, '.cursor'), { recursive: true });
@@ -230,51 +228,7 @@ describe('copyProviderSkills: symlink handling', () => {
});
});
describe('copyProviderAgents: Claude, Copilot, and Cursor subagents', () => {
test('Claude project and user scopes use .claude/agents, with project copies taking precedence', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-agents-claude-'));
const home = mkdtempSync(join(tmpdir(), 'imp-agents-claude-home-'));
const bundle = createFakeUniversalBundle(tmp, ['.claude']);
mkdirSync(join(home, '.claude', 'agents'), { recursive: true });
writeFileSync(join(home, '.claude', 'agents', 'impeccable-finish-reviewer.md'), 'stale copy\n');
const projectResults = copyProviderAgents(bundle, tmp, ['.claude'], { scope: 'project', home });
const userResults = copyProviderAgents(bundle, home, ['.claude'], { scope: 'user' });
expect(projectResults).toHaveLength(1);
expect(projectResults[0].shadowed).toEqual([]);
expect(userResults).toHaveLength(1);
expect(readFileSync(join(tmp, '.claude', 'agents', 'impeccable-finish-reviewer.md'), 'utf8'))
.toContain('Claude reviewer body.');
expect(readFileSync(join(home, '.claude', 'agents', 'impeccable-finish-reviewer.md'), 'utf8'))
.toContain('Claude reviewer body.');
rmSync(tmp, { recursive: true, force: true });
rmSync(home, { recursive: true, force: true });
});
test('Claude install and update backfill bundled agents beside an unchanged skill', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-agents-claude-install-'));
const home = mkdtempSync(join(tmpdir(), 'imp-agents-claude-install-home-'));
execSync('git init', { cwd: tmp });
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']);
const env = { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot };
const agentPath = join(tmp, '.claude', 'agents', 'impeccable-finish-reviewer.md');
const installOutput = run('skills install -y --no-hooks --providers=claude', { cwd: tmp, env });
expect(installOutput).toContain('Installed Claude Code agents into:');
expect(existsSync(agentPath)).toBe(true);
rmSync(agentPath);
const updateOutput = run('skills update -y --no-hooks', { cwd: tmp, env });
expect(updateOutput).toContain('Updated');
expect(updateOutput).toContain('Installed Claude Code agents into:');
expect(existsSync(agentPath)).toBe(true);
rmSync(tmp, { recursive: true, force: true });
rmSync(home, { recursive: true, force: true });
}, 15000);
describe('copyProviderAgents: Copilot and Cursor subagents', () => {
test('project scope places agents at .github/agents/ and .cursor/agents/', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-agents-project-'));
const bundle = createFakeUniversalBundle(tmp, ['.github', '.cursor']);
@@ -308,46 +262,6 @@ describe('copyProviderAgents: Claude, Copilot, and Cursor subagents', () => {
rmSync(home, { recursive: true, force: true });
});
test('skills check accepts current Copilot user agents in a home-rooted checkout', () => {
const home = mkdtempSync(join(tmpdir(), 'imp-agents-check-home-'));
execSync('git init', { cwd: home });
const bundleRoot = createFakeUniversalBundle(home, ['.github']);
const env = { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot };
run('skills install -y --scope=global --no-hooks --providers=github', { cwd: home, env });
expect(existsSync(join(home, '.copilot', 'agents', 'impeccable-finish-reviewer.agent.md'))).toBe(true);
expect(existsSync(join(home, '.github', 'agents'))).toBe(false);
const output = run('skills check', { cwd: home, env });
expect(output).toContain('Skills are up to date');
expect(output).not.toContain('Updates available');
rmSync(home, { recursive: true, force: true });
}, 15000);
test('inferred home-rooted updates refresh stale or missing Copilot user agents', () => {
const home = mkdtempSync(join(tmpdir(), 'imp-agents-update-home-'));
execSync('git init', { cwd: home });
const bundleRoot = createFakeUniversalBundle(home, ['.github']);
const env = { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot };
const userAgent = join(home, '.copilot', 'agents', 'impeccable-finish-reviewer.agent.md');
const projectAgent = join(home, '.github', 'agents', 'impeccable-finish-reviewer.agent.md');
run('skills install -y --scope=global --no-hooks --providers=github', { cwd: home, env });
writeFileSync(userAgent, 'stale copy\n');
run('skills update -y --no-hooks', { cwd: home, env });
expect(readFileSync(userAgent, 'utf8')).toContain('Copilot reviewer body.');
expect(existsSync(projectAgent)).toBe(false);
rmSync(userAgent);
run('skills update -y --no-hooks', { cwd: home, env });
expect(readFileSync(userAgent, 'utf8')).toContain('Copilot reviewer body.');
expect(existsSync(projectAgent)).toBe(false);
rmSync(home, { recursive: true, force: true });
}, 20000);
test('project scope reports user-level Copilot agents that shadow the installed ones; Cursor never does (project wins there)', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-agents-shadow-'));
const home = mkdtempSync(join(tmpdir(), 'imp-agents-shadow-home-'));