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
5 changes: 3 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,13 +166,14 @@ npx patchlane workspace remove

`workspace create` pins the source and every configured lane in local metadata under the Git common directory. Landing requires clean, linear history, checks that all pinned lane refs are fresh, replays commits onto exactly one lane, recomposes every lane, and requires an exact tree match. Use `--config-ref <ref>` when the current branch does not contain `.patchlane.yml`, and `workspace remove --force` only when intentionally discarding unlanded work.

### Install agent skills
### Version and agent skills

```bash
npx patchlane --version
npx patchlane agents
```

The installer fetches skills matching the installed Patchlane version. Use `--ref=<git-ref>` only when intentionally testing skills from another Patchlane revision.
`agents` installs the skills bundled with the installed Patchlane package, so local package development does not require a matching GitHub tag or network access. Use `--ref=<git-ref>` only when intentionally testing skills from another Patchlane revision; `PATCHLANE_SKILLS_BASE_URL` remains available for a custom skill source.

## Sync environment overrides

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"patchlane": "./dist/cli.js"
},
"files": [
"dist/"
"dist/",
"skills/"
],
"engines": {
"node": ">=22"
Expand Down
59 changes: 51 additions & 8 deletions src/agents-install.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { getPackageVersion } from './package-version.js';

const DEFAULT_INSTALL_DIR = '.agents/skills';
const INSTALL_STATE_FILE = '.patchlane-install.json';
const BUNDLED_SKILLS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'skills');

type SkillManifest = {
version: 1;
Expand Down Expand Up @@ -127,8 +129,7 @@ async function fetchText(url: string) {
return response.text();
}

async function fetchManifest(sourceBaseUrl: string) {
const raw = await fetchText(buildUrl(sourceBaseUrl, 'manifest.json'));
function parseManifestText(raw: string) {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
Expand All @@ -138,6 +139,36 @@ async function fetchManifest(sourceBaseUrl: string) {
return parseManifest(parsed);
}

async function fetchManifest(sourceBaseUrl: string) {
return parseManifestText(await fetchText(buildUrl(sourceBaseUrl, 'manifest.json')));
}

function readBundledManifest() {
let raw: string;
try {
raw = readFileSync(path.join(BUNDLED_SKILLS_DIR, 'manifest.json'), 'utf8');
} catch (error) {
fail(`Failed to read bundled Patchlane skills: ${error instanceof Error ? error.message : String(error)}`);
}
return parseManifestText(raw);
}

function readBundledSkills(manifest: SkillManifest): FetchedSkill[] {
return manifest.skills.map((skill) => ({
name: skill.name,
files: skill.files.map((relativePath) => {
const filePath = path.join(BUNDLED_SKILLS_DIR, skill.name, relativePath);
try {
return { relativePath, contents: readFileSync(filePath, 'utf8') };
} catch (error) {
fail(
`Failed to read bundled skill file '${filePath}': ${error instanceof Error ? error.message : String(error)}`,
);
}
}),
}));
}

async function fetchSkill(sourceBaseUrl: string, skill: SkillDefinition): Promise<FetchedSkill> {
const files = await Promise.all(
skill.files.map(async (relativePath) => ({
Expand Down Expand Up @@ -191,12 +222,24 @@ function writeSkillFiles(installDir: string, skill: FetchedSkill) {

export async function installPatchlaneAgents(options: InstallPatchlaneAgentsOptions = {}) {
const installDir = path.resolve(process.cwd(), options.installDir ?? DEFAULT_INSTALL_DIR);
const ref = options.ref ?? `v${getPackageVersion()}`;
const sourceBaseUrl = resolveSourceBaseUrl(ref);

log(`Fetching Patchlane agent skills from ${sourceBaseUrl}`);
const manifest = await fetchManifest(sourceBaseUrl);
const fetchedSkills = await Promise.all(manifest.skills.map((skill) => fetchSkill(sourceBaseUrl, skill)));
const configuredRef = options.ref ?? env('PATCHLANE_SKILLS_REF');
const hasExplicitRemoteSource = configuredRef !== undefined || Boolean(env('PATCHLANE_SKILLS_BASE_URL'));
const useBundledSkills = !hasExplicitRemoteSource && existsSync(path.join(BUNDLED_SKILLS_DIR, 'manifest.json'));
let sourceBaseUrl: string;
let fetchedSkills: FetchedSkill[];

if (useBundledSkills) {
sourceBaseUrl = pathToFileURL(BUNDLED_SKILLS_DIR).toString();
log(`Using bundled Patchlane agent skills from ${BUNDLED_SKILLS_DIR}`);
const manifest = readBundledManifest();
fetchedSkills = readBundledSkills(manifest);
} else {
const ref = configuredRef ?? `v${getPackageVersion()}`;
sourceBaseUrl = resolveSourceBaseUrl(ref);
log(`Fetching Patchlane agent skills from ${sourceBaseUrl}`);
const manifest = await fetchManifest(sourceBaseUrl);
fetchedSkills = await Promise.all(manifest.skills.map((skill) => fetchSkill(sourceBaseUrl, skill)));
}

mkdirSync(installDir, { recursive: true });
const installStatePath = path.join(installDir, INSTALL_STATE_FILE);
Expand Down
3 changes: 2 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { formatWorkspaceLand, formatWorkspaceLandJson, landWorkspace, WorkspaceL
import { formatWorkspaceRemove, formatWorkspaceRemoveJson, removeWorkspace } from './workspace-remove.js';

const cli = cac('patchlane');
cli.version(getPackageVersion());

let config: ReturnType<typeof loadPatchlaneConfig>;
if (['sync', 'promote', 'notify'].includes(process.argv[2] ?? '')) {
Expand Down Expand Up @@ -51,7 +52,7 @@ cli.command('agents', 'Install or update Patchlane agent skills')
default: env('PATCHLANE_AGENTS_DIR', '.agents/skills'),
})
.option('--ref <git-ref>', 'Patchlane git ref to pull skills from', {
default: env('PATCHLANE_SKILLS_REF', `v${getPackageVersion()}`),
default: env('PATCHLANE_SKILLS_REF'),
})
.action((args) => {
void installPatchlaneAgents({
Expand Down
30 changes: 29 additions & 1 deletion tests/integration/cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect, test } from 'vitest';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
Expand Down Expand Up @@ -28,6 +28,34 @@ function configureUser(repo: string) {
git(['config', 'user.email', 'patchlane@example.test'], repo);
}

test('reports the installed package version', () => {
const packageJson = JSON.parse(readFileSync(path.join(repoRoot, 'package.json'), 'utf8')) as { version: string };
const result = run('node', [cliPath, '--version'], repoRoot);

expect(result.status, result.stderr).toBe(0);
expect(result.stdout.trim()).toContain(`patchlane/${packageJson.version}`);
});

test('agents installs bundled skills without fetching GitHub', () => {
const tempRoot = mkdtempSync(path.join(tmpdir(), 'patchlane-agents-'));
try {
const installDir = path.join(tempRoot, 'skills');
const env = { ...process.env };
delete env.PATCHLANE_SKILLS_BASE_URL;
delete env.PATCHLANE_SKILLS_REF;

const result = run('node', [cliPath, 'agents', '--dir', installDir], tempRoot, env);

expect(result.status, [result.stderr, result.stdout].filter(Boolean).join('\\n')).toBe(0);
expect(result.stdout).toContain('Using bundled Patchlane agent skills');
expect(readFileSync(path.join(installDir, 'patchlane-workspace', 'SKILL.md'), 'utf8')).toContain(
'Patchlane Composed Workspace',
);
} finally {
rmSync(tempRoot, { force: true, recursive: true });
}
});

test('sync skip-push flags do not publish the generated branch', () => {
const tempRoot = mkdtempSync(path.join(tmpdir(), 'patchlane-cli-'));
try {
Expand Down