Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ ${BOLD}Experimental Sync Options:${RESET}
--dry-run Show what would change without changing anything
--no-cleanup Keep skills whose package no longer ships them
--no-remote Skip git sources listed in package.json skills fields
-r, --recursive Also sync the dependencies of each workspace package
--include <patterns> Only sync matching packages (<pkg>) or skills (<pkg>#<skill>)
--exclude <patterns> Skip matching packages (<pkg>) or skills (<pkg>#<skill>)

Expand Down
78 changes: 65 additions & 13 deletions src/sync.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import * as p from '@clack/prompts';
import pc from 'picocolors';
import { existsSync } from 'fs';
import { lstat, readdir, readFile, readlink, realpath, rm } from 'fs/promises';
import { basename, dirname, join, posix, resolve, sep } from 'path';
import { glob, lstat, readdir, readFile, readlink, realpath, rm } from 'fs/promises';
import { basename, dirname, join, posix, relative, resolve, sep } from 'path';
import { parse as parseYaml } from 'yaml';
import { homedir } from 'os';
import { hasSkillMd, parseSkillMd } from './skills.ts';
import {
Expand Down Expand Up @@ -47,6 +48,7 @@ export interface SyncOptions {
include?: string[];
exclude?: string[];
remote?: boolean;
recursive?: boolean;
}

/**
Expand Down Expand Up @@ -91,7 +93,9 @@ interface PackageSkill extends Skill {
}

interface PackageJson {
name?: string;
version?: string;
workspaces?: string[] | { packages?: string[] };
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
Expand Down Expand Up @@ -176,7 +180,37 @@ const DEPENDENCY_FIELDS = [
* reaches the agent only when a direct dependency names it, and the package
* manager's version resolution decides which copy of a package is seen.
*/
async function discoverNodeModuleSkills(cwd: string): Promise<{
/** Directories of the workspace packages declared in pnpm-workspace.yaml or package.json. */
async function findWorkspacePackages(cwd: string, pkg: PackageJson): Promise<string[]> {
const patterns = [
...(Array.isArray(pkg.workspaces) ? pkg.workspaces : (pkg.workspaces?.packages ?? [])),
];
try {
const pnpm: unknown = parseYaml(await readFile(join(cwd, 'pnpm-workspace.yaml'), 'utf-8'));
if (pnpm && typeof pnpm === 'object' && 'packages' in pnpm && Array.isArray(pnpm.packages)) {
patterns.push(...pnpm.packages.filter((p) => typeof p === 'string'));
}
} catch {
// no pnpm workspace
}

const negated = patterns.filter((p) => p.startsWith('!')).map((p) => p.slice(1));
const included = patterns.filter((p) => !p.startsWith('!'));
const dirs = new Set<string>();
for await (const file of glob(
included.map((p) => posix.join(p, 'package.json')),
{ cwd, exclude: (path) => basename(path) === 'node_modules' }
)) {
const dir = dirname(file).split(sep).join('/');
if (dir !== '.' && !negated.some((n) => posix.matchesGlob(dir, n))) dirs.add(join(cwd, dir));
}
return [...dirs].sort();
}

async function discoverNodeModuleSkills(
cwd: string,
recursive: boolean
): Promise<{
skills: PackageSkill[];
remote: FieldRemoteRequest[];
warnings: string[];
Expand All @@ -200,17 +234,33 @@ async function discoverNodeModuleSkills(cwd: string): Promise<{
}
};

const deps = [...new Set(DEPENDENCY_FIELDS.flatMap((field) => Object.keys(pkg[field] ?? {})))];
const shipped = await Promise.all(
deps.map((name) => discoverPackageSkills(join(cwd, 'node_modules', name), name, 0))
);
await add(shipped.flat());
// the project, then with --recursive each workspace package, one level further away
const projects = [{ name: '.', dir: cwd, pkg, depth: 0 }];
for (const dir of recursive ? await findWorkspacePackages(cwd, pkg) : []) {
const workspacePkg = await readPackageJson(dir);
const name = workspacePkg?.name ?? relative(cwd, dir).split(sep).join('/');
if (workspacePkg) projects.push({ name, dir, pkg: workspacePkg, depth: 1 });
}

// `depth` is what this declarer's npm: targets get
const declarers = [
{ name: '.', dir: cwd, depth: 0 },
...deps.map((name) => ({ name, dir: join(cwd, 'node_modules', name), depth: 1 })),
];
const declarers: Array<{ name: string; dir: string; depth: number }> = [];
for (const project of projects) {
const deps = new Set(
DEPENDENCY_FIELDS.flatMap((field) => Object.keys(project.pkg[field] ?? {}))
);
const installed = [...deps].flatMap((name) => {
const dir = findInstalledPackage(project.dir, name);
return dir ? [{ name, dir }] : [];
});
const shipped = await Promise.all(
installed.map(({ name, dir }) => discoverPackageSkills(dir, name, project.depth))
);
await add(shipped.flat());
declarers.push(
{ name: project.name, dir: project.dir, depth: project.depth },
...installed.map(({ name, dir }) => ({ name, dir, depth: project.depth + 1 }))
);
}
const visited = new Set<string>();
for (const declarer of declarers) {
const dir = await realpath(declarer.dir).catch(() => null);
Expand Down Expand Up @@ -533,7 +583,7 @@ export async function runSync(args: string[], options: SyncOptions = {}): Promis

// 1. Discover skills from node_modules
spinner.start('Scanning node_modules for skills…');
const discovery = await discoverNodeModuleSkills(cwd);
const discovery = await discoverNodeModuleSkills(cwd, options.recursive ?? false);
if (discovery.errors.length > 0) {
spinner.stop(pc.red('Invalid skills field'));
for (const error of discovery.errors) p.log.error(error);
Expand Down Expand Up @@ -862,6 +912,8 @@ export function parseSyncOptions(args: string[]): { options: SyncOptions } {
options.cleanup = false;
} else if (arg === '--no-remote') {
options.remote = false;
} else if (arg === '-r' || arg === '--recursive') {
options.recursive = true;
} else if (arg === '-a' || arg === '--agent') {
options.agent = [...(options.agent ?? []), ...takeValues()];
} else if (arg === '--include') {
Expand Down
116 changes: 116 additions & 0 deletions tests/sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -895,6 +895,122 @@ describe('experimental_sync command', () => {
});
});

describe('--recursive', () => {
const sync = (...flags: string[]) =>
runCli(['experimental_sync', '-y', '-a', 'claude-code', ...flags], testDir);
const installed = (name: string) => existsSync(join(testDir, '.agents', 'skills', name));
const readLock = () => JSON.parse(readFileSync(join(testDir, 'skills-lock.json'), 'utf-8'));

function writeJson(path: string, data: Record<string, unknown>): void {
mkdirSync(join(path, '..'), { recursive: true });
writeFileSync(path, JSON.stringify(data));
}

/** A package at `dir` that ships one skill. */
function createSkillPackage(dir: string, name: string, skill: string): void {
writeJson(join(dir, 'package.json'), { name, version: '1.0.0' });
writeSkill(join(dir, 'skills', skill), skill);
}

it('reads the dependencies of npm workspace packages (hoisted)', () => {
writeJson(join(testDir, 'package.json'), { name: 'root', workspaces: ['packages/*'] });
writeJson(join(testDir, 'packages', 'app', 'package.json'), {
name: '@acme/app',
dependencies: { 'lib-a': '*' },
});
createSkillPackage(join(testDir, 'node_modules', 'lib-a'), 'lib-a', 'skill-a');

sync();
expect(installed('skill-a')).toBe(false);

sync('--recursive');
expect(installed('skill-a')).toBe(true);
});

it('reads pnpm workspace packages and their own node_modules', () => {
writeJson(join(testDir, 'package.json'), { name: 'root' });
writeFileSync(
join(testDir, 'pnpm-workspace.yaml'),
"packages:\n - 'apps/*'\n - '!apps/legacy'\n"
);
writeJson(join(testDir, 'apps', 'web', 'package.json'), {
name: 'web',
dependencies: { 'lib-b': '*' },
});
createSkillPackage(join(testDir, 'apps', 'web', 'node_modules', 'lib-b'), 'lib-b', 'skill-b');
writeJson(join(testDir, 'apps', 'legacy', 'package.json'), {
name: 'legacy',
dependencies: { 'lib-c': '*' },
});
createSkillPackage(
join(testDir, 'apps', 'legacy', 'node_modules', 'lib-c'),
'lib-c',
'skill-c'
);

sync('-r');

expect(installed('skill-b')).toBe(true);
expect(installed('skill-c')).toBe(false);
});

it('prefers a root dependency over a workspace dependency with the same skill', () => {
writeJson(join(testDir, 'package.json'), {
name: 'root',
workspaces: ['packages/*'],
dependencies: { 'root-lib': '*' },
});
createSkillPackage(join(testDir, 'node_modules', 'root-lib'), 'root-lib', 'shared');
writeJson(join(testDir, 'packages', 'app', 'package.json'), {
name: 'app',
dependencies: { 'app-lib': '*' },
});
createSkillPackage(
join(testDir, 'packages', 'app', 'node_modules', 'app-lib'),
'app-lib',
'shared'
);

const result = sync('-r');

expect(result.stdout).toContain('closer to the project');
expect(readLock().skills.shared.source).toBe('root-lib');
});

it('installs neither when two workspaces ship different copies of a skill', () => {
writeJson(join(testDir, 'package.json'), { name: 'root', workspaces: ['packages/*'] });
for (const app of ['one', 'two']) {
writeJson(join(testDir, 'packages', app, 'package.json'), {
name: app,
dependencies: { 'shared-lib': '*' },
});
createSkillPackage(
join(testDir, 'packages', app, 'node_modules', 'shared-lib'),
'shared-lib',
'shared'
);
}

const result = sync('-r');

expect(result.stdout).toContain('--exclude shared-lib#shared');
expect(installed('shared')).toBe(false);
});

it('reads the skills field of a workspace package', () => {
writeJson(join(testDir, 'package.json'), { name: 'root', workspaces: ['packages/*'] });
writeJson(join(testDir, 'packages', 'app', 'package.json'), {
name: 'app',
skills: ['npm:far-lib'],
});
createSkillPackage(join(testDir, 'node_modules', 'far-lib'), 'far-lib', 'far');

sync('-r');

expect(readLock().skills.far).toMatchObject({ source: 'far-lib', via: 'app' });
});
});

describe('CLI routing', () => {
it('shows experimental_sync in help output', () => {
const result = runCli(['--help']);
Expand Down
Loading