diff --git a/README.md b/README.md index 16d3b247..5f38afb9 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ See [Quick Start guide](https://dyoshikawa.github.io/rulesync/getting-started/qu | Windsurf | windsurf | ✅ | ✅ | | | | ✅ 🌏 | | | | Warp | warp | ✅ | | | | | | | | | Replit | replit | ✅ | | | | | ✅ | | | +| Pi Coding Agent | pi | ✅ 🌏 | | | ✅ 🌏 | | ✅ 🌏 | | | | Zed | zed | | ✅ | | | | | | | - ✅: Supports project mode diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index 31a88622..988ed1ce 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -187,6 +187,8 @@ antigravity: # antigravity specific parameters turbo: true # (Optional, default: true) Append // turbo for auto-execution takt: # takt specific parameters (optional; emitted under .takt/facets/instructions/) name: "renamed-stem" # (optional) override the emitted filename stem (no path separators or "..") +pi: # pi coding agent specific parameters (optional) + argument-hint: "[message]" # Hint shown in Pi's command palette --- target_pr = $ARGUMENTS diff --git a/docs/reference/supported-tools.md b/docs/reference/supported-tools.md index 275f6982..46549708 100644 --- a/docs/reference/supported-tools.md +++ b/docs/reference/supported-tools.md @@ -28,6 +28,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod | Windsurf | windsurf | ✅ | ✅ | | | | ✅ 🌏 | | | | Warp | warp | ✅ | | | | | | | | | Replit | replit | ✅ | | | | | ✅ | | | +| Pi Coding Agent | pi | ✅ 🌏 | | | ✅ 🌏 | | ✅ 🌏 | | | | Zed | zed | | ✅ | | | | | | | - ✅: Supports project mode diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index 31a88622..988ed1ce 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -187,6 +187,8 @@ antigravity: # antigravity specific parameters turbo: true # (Optional, default: true) Append // turbo for auto-execution takt: # takt specific parameters (optional; emitted under .takt/facets/instructions/) name: "renamed-stem" # (optional) override the emitted filename stem (no path separators or "..") +pi: # pi coding agent specific parameters (optional) + argument-hint: "[message]" # Hint shown in Pi's command palette --- target_pr = $ARGUMENTS diff --git a/skills/rulesync/supported-tools.md b/skills/rulesync/supported-tools.md index 275f6982..46549708 100644 --- a/skills/rulesync/supported-tools.md +++ b/skills/rulesync/supported-tools.md @@ -28,6 +28,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod | Windsurf | windsurf | ✅ | ✅ | | | | ✅ 🌏 | | | | Warp | warp | ✅ | | | | | | | | | Replit | replit | ✅ | | | | | ✅ | | | +| Pi Coding Agent | pi | ✅ 🌏 | | | ✅ 🌏 | | ✅ 🌏 | | | | Zed | zed | | ✅ | | | | | | | - ✅: Supports project mode diff --git a/src/cli/commands/gitignore-entries.ts b/src/cli/commands/gitignore-entries.ts index ff5b5450..d0e5bc6c 100644 --- a/src/cli/commands/gitignore-entries.ts +++ b/src/cli/commands/gitignore-entries.ts @@ -38,7 +38,7 @@ export const GITIGNORE_ENTRY_REGISTRY: ReadonlyArray = [ { target: "common", feature: "general", entry: "**/AGENTS.local.md" }, // AGENTS.md - { target: "agentsmd", feature: "rules", entry: "**/AGENTS.md" }, + { target: ["agentsmd", "pi"], feature: "rules", entry: "**/AGENTS.md" }, { target: "agentsmd", feature: "rules", entry: "**/.agents/" }, // Augment Code @@ -194,6 +194,11 @@ export const GITIGNORE_ENTRY_REGISTRY: ReadonlyArray = [ entry: "**/.opencode/package-lock.json", }, + // Pi Coding Agent + { target: "pi", feature: "rules", entry: "**/.agents/memories/" }, + { target: "pi", feature: "commands", entry: "**/.pi/prompts/" }, + { target: "pi", feature: "skills", entry: "**/.pi/skills/" }, + // Qwen Code { target: "qwencode", feature: "rules", entry: "**/QWEN.md" }, { target: "qwencode", feature: "general", entry: "**/.qwen/memories/" }, diff --git a/src/e2e/e2e-commands.spec.ts b/src/e2e/e2e-commands.spec.ts index 92ca6dba..ab0ea1bc 100644 --- a/src/e2e/e2e-commands.spec.ts +++ b/src/e2e/e2e-commands.spec.ts @@ -27,6 +27,7 @@ describe("E2E: commands", () => { { target: "antigravity", outputPath: join(".agent", "workflows", "review-pr.md") }, { target: "junie", outputPath: join(".junie", "commands", "review-pr.md") }, { target: "takt", outputPath: join(".takt", "facets", "instructions", "review-pr.md") }, + { target: "pi", outputPath: join(".pi", "prompts", "review-pr.md") }, ])("should generate $target commands", async ({ target, outputPath }) => { const testDir = getTestDir(); @@ -90,6 +91,7 @@ Check the PR diff and provide feedback. { target: "kiro", orphanPath: join(".kiro", "prompts", "orphan.md") }, { target: "antigravity", orphanPath: join(".agent", "workflows", "orphan.md") }, { target: "junie", orphanPath: join(".junie", "commands", "orphan.md") }, + { target: "pi", orphanPath: join(".pi", "prompts", "orphan.md") }, ])( "should fail in check mode when delete would remove an orphan $target command file", async ({ target, orphanPath }) => { @@ -132,6 +134,7 @@ describe("E2E: commands (import)", () => { { target: "kiro", sourcePath: join(".kiro", "prompts", "review-pr.md") }, { target: "antigravity", sourcePath: join(".agent", "workflows", "review-pr.md") }, { target: "junie", sourcePath: join(".junie", "commands", "review-pr.md") }, + { target: "pi", sourcePath: join(".pi", "prompts", "review-pr.md") }, ])("should import $target commands", async ({ target, sourcePath }) => { const testDir = getTestDir(); @@ -163,6 +166,7 @@ describe("E2E: commands (global mode)", () => { target: "takt", outputPath: join(".takt", "facets", "instructions", "review-pr.md"), }, + { target: "pi", outputPath: join(".pi", "agent", "prompts", "review-pr.md") }, ])("should generate $target commands in home directory", async ({ target, outputPath }) => { const projectDir = getProjectDir(); const homeDir = getHomeDir(); diff --git a/src/e2e/e2e-rules.spec.ts b/src/e2e/e2e-rules.spec.ts index 78f9b998..2a15ad05 100644 --- a/src/e2e/e2e-rules.spec.ts +++ b/src/e2e/e2e-rules.spec.ts @@ -36,6 +36,7 @@ describe("E2E: rules", () => { { target: "junie", outputPath: join(".junie", "guidelines.md") }, { target: "warp", outputPath: "WARP.md" }, { target: "replit", outputPath: "replit.md" }, + { target: "pi", outputPath: "AGENTS.md" }, ])("should generate $target rules", async ({ target, outputPath }) => { const testDir = getTestDir(); @@ -202,6 +203,7 @@ describe("E2E: rules (import)", () => { }, { target: "warp", sourcePath: "WARP.md", importedFileName: "overview.md" }, { target: "replit", sourcePath: "replit.md", importedFileName: "overview.md" }, + { target: "pi", sourcePath: "AGENTS.md", importedFileName: "overview.md" }, { target: "cline", sourcePath: join(".clinerules", "overview.md"), @@ -264,6 +266,7 @@ describe("E2E: rules (global mode)", () => { { target: "kilo", outputPath: join(".config", "kilo", "AGENTS.md") }, { target: "rovodev", outputPath: join(".rovodev", "AGENTS.md") }, { target: "takt", outputPath: join(".takt", "facets", "policies", "overview.md") }, + { target: "pi", outputPath: join(".pi", "agent", "AGENTS.md") }, ])("should generate $target rules in home directory", async ({ target, outputPath }) => { const projectDir = getProjectDir(); const homeDir = getHomeDir(); diff --git a/src/e2e/e2e-skills.spec.ts b/src/e2e/e2e-skills.spec.ts index 6a994867..2ad38fd7 100644 --- a/src/e2e/e2e-skills.spec.ts +++ b/src/e2e/e2e-skills.spec.ts @@ -83,6 +83,10 @@ describe("E2E: skills", () => { target: "takt", outputPath: join(".takt", "facets", "knowledge", "test-skill.md"), }, + { + target: "pi", + outputPath: join(".pi", "skills", "test-skill", "SKILL.md"), + }, ])("should generate $target skills", async ({ target, outputPath }) => { const testDir = getTestDir(); @@ -154,6 +158,7 @@ This is the test skill body content. { target: "junie", orphanPath: join(".junie", "skills", "orphan-skill", "SKILL.md") }, { target: "replit", orphanPath: join(".agents", "skills", "orphan-skill", "SKILL.md") }, { target: "agentsskills", orphanPath: join(".agents", "skills", "orphan-skill", "SKILL.md") }, + { target: "pi", orphanPath: join(".pi", "skills", "orphan-skill", "SKILL.md") }, ])( "should fail in check mode when delete would remove an orphan $target skill file", async ({ target, orphanPath }) => { @@ -202,6 +207,7 @@ describe("E2E: skills (import)", () => { { target: "antigravity", sourcePath: join(".agent", "skills", "test-skill", "SKILL.md") }, { target: "junie", sourcePath: join(".junie", "skills", "test-skill", "SKILL.md") }, { target: "replit", sourcePath: join(".agents", "skills", "test-skill", "SKILL.md") }, + { target: "pi", sourcePath: join(".pi", "skills", "test-skill", "SKILL.md") }, ])("should import $target skills", async ({ target, sourcePath }) => { const testDir = getTestDir(); @@ -273,6 +279,10 @@ describe("E2E: skills (global mode)", () => { target: "takt", outputPath: join(".takt", "facets", "knowledge", "test-skill.md"), }, + { + target: "pi", + outputPath: join(".pi", "agent", "skills", "test-skill", "SKILL.md"), + }, ])("should generate $target skills in home directory", async ({ target, outputPath }) => { const projectDir = getProjectDir(); const homeDir = getHomeDir(); diff --git a/src/features/commands/commands-processor.test.ts b/src/features/commands/commands-processor.test.ts index 24e588ab..541a541c 100644 --- a/src/features/commands/commands-processor.test.ts +++ b/src/features/commands/commands-processor.test.ts @@ -1135,6 +1135,7 @@ describe("CommandsProcessor", () => { "kilo", "kiro", "opencode", + "pi", "roo", "takt", ]), @@ -1158,6 +1159,7 @@ describe("CommandsProcessor", () => { "kilo", "kiro", "opencode", + "pi", "roo", "takt", ]), @@ -1179,6 +1181,7 @@ describe("CommandsProcessor", () => { "codexcli", "kilo", "opencode", + "pi", "takt", ]), ); diff --git a/src/features/commands/commands-processor.ts b/src/features/commands/commands-processor.ts index ec82cff7..927211ac 100644 --- a/src/features/commands/commands-processor.ts +++ b/src/features/commands/commands-processor.ts @@ -22,6 +22,7 @@ import { JunieCommand } from "./junie-command.js"; import { KiloCommand } from "./kilo-command.js"; import { KiroCommand } from "./kiro-command.js"; import { OpenCodeCommand } from "./opencode-command.js"; +import { PiCommand } from "./pi-command.js"; import { RooCommand } from "./roo-command.js"; import { RulesyncCommand } from "./rulesync-command.js"; import { TaktCommand } from "./takt-command.js"; @@ -78,6 +79,7 @@ const commandsProcessorToolTargetTuple = [ "kilo", "kiro", "opencode", + "pi", "roo", "takt", ] as const; @@ -274,6 +276,19 @@ const toolCommandFactories = new Map { + let testDir: string; + let cleanup: () => Promise; + + beforeEach(async () => { + ({ testDir, cleanup } = await setupTestDirectory()); + vi.spyOn(process, "cwd").mockReturnValue(testDir); + }); + + afterEach(async () => { + await cleanup(); + vi.restoreAllMocks(); + }); + + describe("getSettablePaths", () => { + it("should return project prompts directory by default", () => { + const paths = PiCommand.getSettablePaths(); + expect(paths.relativeDirPath).toBe(join(".pi", "prompts")); + }); + + it("should return global prompts directory when global is true", () => { + const paths = PiCommand.getSettablePaths({ global: true }); + expect(paths.relativeDirPath).toBe(join(".pi", "agent", "prompts")); + }); + }); + + describe("constructor", () => { + it("should create a PiCommand with frontmatter", () => { + const command = new PiCommand({ + baseDir: testDir, + relativeDirPath: join(".pi", "prompts"), + relativeFilePath: "test.md", + frontmatter: { description: "Test" }, + body: "Body", + }); + + expect(command).toBeInstanceOf(PiCommand); + expect(command.getBody()).toBe("Body"); + expect(command.getFrontmatter()).toEqual({ description: "Test" }); + }); + + it("should emit body-only content when frontmatter is empty", () => { + const command = new PiCommand({ + baseDir: testDir, + relativeDirPath: join(".pi", "prompts"), + relativeFilePath: "test.md", + frontmatter: {}, + body: "Body only", + }); + + expect(command.getFileContent()).toBe("Body only"); + }); + + it("should emit frontmatter block when frontmatter has fields", () => { + const command = new PiCommand({ + baseDir: testDir, + relativeDirPath: join(".pi", "prompts"), + relativeFilePath: "test.md", + frontmatter: { description: "Test", "argument-hint": "[args]" }, + body: "Body", + }); + + const content = command.getFileContent(); + expect(content).toContain("---"); + expect(content).toContain("description: Test"); + expect(content).toContain("argument-hint"); + }); + + it("should throw when validating invalid frontmatter", () => { + expect(() => { + new PiCommand({ + baseDir: testDir, + relativeDirPath: join(".pi", "prompts"), + relativeFilePath: "test.md", + frontmatter: { description: 42 as any }, + body: "Body", + validate: true, + }); + }).toThrow(/Invalid frontmatter/); + }); + }); + + describe("validate", () => { + it("should succeed for valid frontmatter", () => { + const command = new PiCommand({ + baseDir: testDir, + relativeDirPath: join(".pi", "prompts"), + relativeFilePath: "test.md", + frontmatter: { description: "Valid" }, + body: "Body", + }); + + const result = command.validate(); + expect(result.success).toBe(true); + expect(result.error).toBeNull(); + }); + + it("should fail for invalid frontmatter when validation deferred", () => { + const command = new PiCommand({ + baseDir: testDir, + relativeDirPath: join(".pi", "prompts"), + relativeFilePath: "test.md", + frontmatter: { description: 123 as any }, + body: "Body", + validate: false, + }); + + const result = command.validate(); + expect(result.success).toBe(false); + expect(result.error).toBeInstanceOf(Error); + }); + }); + + describe("fromFile", () => { + it("should load a command from the project prompts directory", async () => { + const promptsDir = join(testDir, ".pi", "prompts"); + await ensureDir(promptsDir); + await writeFileContent( + join(promptsDir, "test.md"), + `--- +description: Test command +argument-hint: "[name]" +--- +Body`, + ); + + const command = await PiCommand.fromFile({ + baseDir: testDir, + relativeFilePath: "test.md", + }); + + expect(command.getBody()).toBe("Body"); + expect(command.getFrontmatter()).toEqual({ + description: "Test command", + "argument-hint": "[name]", + }); + expect(command.getRelativeDirPath()).toBe(join(".pi", "prompts")); + }); + + it("should load a command from the global prompts directory", async () => { + const promptsDir = join(testDir, ".pi", "agent", "prompts"); + await ensureDir(promptsDir); + await writeFileContent( + join(promptsDir, "test.md"), + `--- +description: Global command +--- +Body`, + ); + + const command = await PiCommand.fromFile({ + baseDir: testDir, + relativeFilePath: "test.md", + global: true, + }); + + expect(command.getBody()).toBe("Body"); + expect(command.getRelativeDirPath()).toBe(join(".pi", "agent", "prompts")); + }); + + it("should throw on invalid frontmatter", async () => { + const promptsDir = join(testDir, ".pi", "prompts"); + await ensureDir(promptsDir); + await writeFileContent( + join(promptsDir, "bad.md"), + `--- +description: 123 +--- +Body`, + ); + + await expect( + PiCommand.fromFile({ + baseDir: testDir, + relativeFilePath: "bad.md", + }), + ).rejects.toThrow(/Invalid frontmatter/); + }); + }); + + describe("fromRulesyncCommand", () => { + it("should copy description from rulesync frontmatter", () => { + const rulesyncCommand = new RulesyncCommand({ + baseDir: testDir, + relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, + relativeFilePath: "test.md", + frontmatter: { + targets: ["*"], + description: "Desc", + }, + body: "Body", + fileContent: "", + }); + + const command = PiCommand.fromRulesyncCommand({ + baseDir: testDir, + rulesyncCommand, + }); + + expect(command.getFrontmatter()).toEqual({ description: "Desc" }); + expect(command.getBody()).toBe("Body"); + expect(command.getRelativeDirPath()).toBe(join(".pi", "prompts")); + }); + + it("should propagate argument-hint from rulesync pi section", () => { + const rulesyncCommand = new RulesyncCommand({ + baseDir: testDir, + relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, + relativeFilePath: "test.md", + frontmatter: { + targets: ["pi"], + description: "Desc", + pi: { + "argument-hint": "[message]", + }, + }, + body: "Body", + fileContent: "", + }); + + const command = PiCommand.fromRulesyncCommand({ + baseDir: testDir, + rulesyncCommand, + }); + + expect(command.getFrontmatter()).toEqual({ + description: "Desc", + "argument-hint": "[message]", + }); + }); + + it("should emit to the global path when global is true", () => { + const rulesyncCommand = new RulesyncCommand({ + baseDir: testDir, + relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, + relativeFilePath: "test.md", + frontmatter: { + targets: ["pi"], + description: "Desc", + }, + body: "Body", + fileContent: "", + }); + + const command = PiCommand.fromRulesyncCommand({ + baseDir: testDir, + rulesyncCommand, + global: true, + }); + + expect(command.getRelativeDirPath()).toBe(join(".pi", "agent", "prompts")); + }); + }); + + describe("toRulesyncCommand", () => { + it("should produce rulesync frontmatter with wildcard targets", () => { + const command = new PiCommand({ + baseDir: testDir, + relativeDirPath: join(".pi", "prompts"), + relativeFilePath: "test.md", + frontmatter: { description: "Desc" }, + body: "Body", + }); + + const rulesyncCommand = command.toRulesyncCommand(); + + expect(rulesyncCommand.getFrontmatter().targets).toEqual(["*"]); + expect(rulesyncCommand.getFrontmatter().description).toBe("Desc"); + expect(rulesyncCommand.getFrontmatter().pi).toBeUndefined(); + }); + + it("should preserve argument-hint in the pi section on round-trip", () => { + const command = new PiCommand({ + baseDir: testDir, + relativeDirPath: join(".pi", "prompts"), + relativeFilePath: "test.md", + frontmatter: { description: "Desc", "argument-hint": "[message]" }, + body: "Body", + }); + + const rulesyncCommand = command.toRulesyncCommand(); + + expect(rulesyncCommand.getFrontmatter().pi).toEqual({ + "argument-hint": "[message]", + }); + }); + + it("should preserve arbitrary extra fields in the pi section", () => { + const command = new PiCommand({ + baseDir: testDir, + relativeDirPath: join(".pi", "prompts"), + relativeFilePath: "test.md", + frontmatter: { + description: "Desc", + "argument-hint": "[arg]", + "custom-field": "x", + }, + body: "Body", + validate: false, + }); + + const rulesyncCommand = command.toRulesyncCommand(); + + expect(rulesyncCommand.getFrontmatter().pi).toEqual({ + "argument-hint": "[arg]", + "custom-field": "x", + }); + }); + + it("should round-trip argument-hint through pi section", () => { + const original = new PiCommand({ + baseDir: testDir, + relativeDirPath: join(".pi", "prompts"), + relativeFilePath: "roundtrip.md", + frontmatter: { description: "Desc", "argument-hint": "[arg]" }, + body: "Body", + }); + + const rulesyncCommand = original.toRulesyncCommand(); + + // Re-parse the serialized file content to guard against regressions in + // frontmatter serialization (the in-memory getter alone could miss them). + const { frontmatter: serialized } = parseFrontmatter(rulesyncCommand.getFileContent()); + expect(serialized).toMatchObject({ + targets: ["*"], + description: "Desc", + pi: { "argument-hint": "[arg]" }, + }); + + const restored = PiCommand.fromRulesyncCommand({ + baseDir: testDir, + rulesyncCommand, + }); + + expect(restored.getFrontmatter()).toEqual({ + description: "Desc", + "argument-hint": "[arg]", + }); + }); + }); + + describe("forDeletion", () => { + it("should create a deletion stub", () => { + const command = PiCommand.forDeletion({ + baseDir: testDir, + relativeDirPath: join(".pi", "prompts"), + relativeFilePath: "to-delete.md", + }); + + expect(command).toBeInstanceOf(PiCommand); + expect(command.getBody()).toBe(""); + expect(command.getFrontmatter()).toEqual({}); + }); + }); + + describe("isTargetedByRulesyncCommand", () => { + it("should return true for pi target", () => { + const rulesyncCommand = new RulesyncCommand({ + relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, + relativeFilePath: "test.md", + frontmatter: { targets: ["pi"], description: "D" }, + body: "Body", + fileContent: "", + }); + + expect(PiCommand.isTargetedByRulesyncCommand(rulesyncCommand)).toBe(true); + }); + + it("should return true for wildcard target", () => { + const rulesyncCommand = new RulesyncCommand({ + relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, + relativeFilePath: "test.md", + frontmatter: { targets: ["*"], description: "D" }, + body: "Body", + fileContent: "", + }); + + expect(PiCommand.isTargetedByRulesyncCommand(rulesyncCommand)).toBe(true); + }); + + it("should return false for unrelated targets", () => { + const rulesyncCommand = new RulesyncCommand({ + relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, + relativeFilePath: "test.md", + frontmatter: { targets: ["cursor"], description: "D" }, + body: "Body", + fileContent: "", + }); + + expect(PiCommand.isTargetedByRulesyncCommand(rulesyncCommand)).toBe(false); + }); + }); + + describe("PiCommandFrontmatterSchema", () => { + it("should accept empty frontmatter", () => { + const result = PiCommandFrontmatterSchema.safeParse({}); + expect(result.success).toBe(true); + }); + + it("should accept description and argument-hint", () => { + const result = PiCommandFrontmatterSchema.safeParse({ + description: "Desc", + "argument-hint": "[a]", + }); + expect(result.success).toBe(true); + }); + + it("should accept unknown keys via looseObject", () => { + const result = PiCommandFrontmatterSchema.safeParse({ + description: "Desc", + customField: "value", + }); + expect(result.success).toBe(true); + }); + + it("should reject non-string description", () => { + const result = PiCommandFrontmatterSchema.safeParse({ description: 42 }); + expect(result.success).toBe(false); + }); + }); +}); diff --git a/src/features/commands/pi-command.ts b/src/features/commands/pi-command.ts new file mode 100644 index 00000000..8719f5e6 --- /dev/null +++ b/src/features/commands/pi-command.ts @@ -0,0 +1,212 @@ +import { join } from "node:path"; + +import { z } from "zod/mini"; + +import { AiFileParams, ValidationResult } from "../../types/ai-file.js"; +import { formatError } from "../../utils/error.js"; +import { readFileContent } from "../../utils/file.js"; +import { parseFrontmatter, stringifyFrontmatter } from "../../utils/frontmatter.js"; +import { RulesyncCommand, RulesyncCommandFrontmatter } from "./rulesync-command.js"; +import { + ToolCommand, + ToolCommandForDeletionParams, + ToolCommandFromFileParams, + ToolCommandFromRulesyncCommandParams, + ToolCommandSettablePaths, +} from "./tool-command.js"; + +/** + * Frontmatter schema for Pi Coding Agent commands. + * + * Pi reads Markdown commands from `.pi/prompts/` with an optional minimal + * frontmatter. Unknown keys are preserved via `looseObject` so the schema + * stays tolerant to Pi's evolving command metadata. + */ +export const PiCommandFrontmatterSchema = z.looseObject({ + description: z.optional(z.string()), + "argument-hint": z.optional(z.string()), +}); + +export type PiCommandFrontmatter = z.infer; + +export type PiCommandParams = { + frontmatter: PiCommandFrontmatter; + body: string; +} & Omit; + +/** + * Command generator for Pi Coding Agent. + * + * - Project scope: `.pi/prompts/.md` + * - Global scope: `~/.pi/agent/prompts/.md` + * + * Pi's argument placeholders (`$1`, `$2`, `$@`, `$ARGUMENTS`) are compatible + * with rulesync command bodies, so the body is passed through verbatim. + */ +export class PiCommand extends ToolCommand { + private readonly frontmatter: PiCommandFrontmatter; + private readonly body: string; + + constructor({ frontmatter, body, ...rest }: PiCommandParams) { + if (rest.validate) { + const result = PiCommandFrontmatterSchema.safeParse(frontmatter); + if (!result.success) { + throw new Error( + `Invalid frontmatter in ${join(rest.relativeDirPath, rest.relativeFilePath)}: ${formatError(result.error)}`, + ); + } + } + + super({ + ...rest, + fileContent: PiCommand.generateFileContent(body, frontmatter), + }); + + this.frontmatter = frontmatter; + this.body = body; + } + + private static generateFileContent(body: string, frontmatter: PiCommandFrontmatter): string { + // Emit frontmatter only when there is at least one defined field. + const hasContent = Object.values(frontmatter).some((value) => value !== undefined); + if (!hasContent) { + return body; + } + return stringifyFrontmatter(body, frontmatter); + } + + static getSettablePaths({ global }: { global?: boolean } = {}): ToolCommandSettablePaths { + if (global) { + return { + relativeDirPath: join(".pi", "agent", "prompts"), + }; + } + return { + relativeDirPath: join(".pi", "prompts"), + }; + } + + getBody(): string { + return this.body; + } + + getFrontmatter(): Record { + return this.frontmatter; + } + + toRulesyncCommand(): RulesyncCommand { + const { description, ...restFields } = this.frontmatter; + + const rulesyncFrontmatter: RulesyncCommandFrontmatter = { + targets: ["*"], + description, + // Preserve Pi-specific fields (e.g. `argument-hint`) under a `pi:` + // section so round-trips retain tool-specific metadata. + ...(Object.keys(restFields).length > 0 && { pi: restFields }), + }; + + const fileContent = stringifyFrontmatter(this.body, rulesyncFrontmatter); + + return new RulesyncCommand({ + baseDir: ".", + frontmatter: rulesyncFrontmatter, + body: this.body, + relativeDirPath: RulesyncCommand.getSettablePaths().relativeDirPath, + relativeFilePath: this.relativeFilePath, + fileContent, + validate: true, + }); + } + + static fromRulesyncCommand({ + baseDir = process.cwd(), + rulesyncCommand, + validate = true, + global = false, + }: ToolCommandFromRulesyncCommandParams): PiCommand { + const rulesyncFrontmatter = rulesyncCommand.getFrontmatter(); + const piFields = rulesyncFrontmatter.pi ?? {}; + + const piFrontmatter: PiCommandFrontmatter = { + ...(rulesyncFrontmatter.description !== undefined && { + description: rulesyncFrontmatter.description, + }), + ...piFields, + }; + + const paths = this.getSettablePaths({ global }); + + return new PiCommand({ + baseDir, + frontmatter: piFrontmatter, + body: rulesyncCommand.getBody(), + relativeDirPath: paths.relativeDirPath, + relativeFilePath: rulesyncCommand.getRelativeFilePath(), + validate, + }); + } + + validate(): ValidationResult { + if (!this.frontmatter) { + return { success: true, error: null }; + } + const result = PiCommandFrontmatterSchema.safeParse(this.frontmatter); + if (result.success) { + return { success: true, error: null }; + } + return { + success: false, + error: new Error( + `Invalid frontmatter in ${join(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`, + ), + }; + } + + static isTargetedByRulesyncCommand(rulesyncCommand: RulesyncCommand): boolean { + return this.isTargetedByRulesyncCommandDefault({ + rulesyncCommand, + toolTarget: "pi", + }); + } + + static async fromFile({ + baseDir = process.cwd(), + relativeFilePath, + validate = true, + global = false, + }: ToolCommandFromFileParams): Promise { + const paths = this.getSettablePaths({ global }); + const filePath = join(baseDir, paths.relativeDirPath, relativeFilePath); + const fileContent = await readFileContent(filePath); + const { frontmatter, body: content } = parseFrontmatter(fileContent, filePath); + + const result = PiCommandFrontmatterSchema.safeParse(frontmatter); + if (!result.success) { + throw new Error(`Invalid frontmatter in ${filePath}: ${formatError(result.error)}`); + } + + return new PiCommand({ + baseDir, + relativeDirPath: paths.relativeDirPath, + relativeFilePath, + frontmatter: result.data, + body: content.trim(), + validate, + }); + } + + static forDeletion({ + baseDir = process.cwd(), + relativeDirPath, + relativeFilePath, + }: ToolCommandForDeletionParams): PiCommand { + return new PiCommand({ + baseDir, + relativeDirPath, + relativeFilePath, + frontmatter: {}, + body: "", + validate: false, + }); + } +} diff --git a/src/features/rules/pi-rule.test.ts b/src/features/rules/pi-rule.test.ts new file mode 100644 index 00000000..57ae4d63 --- /dev/null +++ b/src/features/rules/pi-rule.test.ts @@ -0,0 +1,326 @@ +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { setupTestDirectory } from "../../test-utils/test-directories.js"; +import { ensureDir, writeFileContent } from "../../utils/file.js"; +import { PiRule } from "./pi-rule.js"; +import { RulesyncRule } from "./rulesync-rule.js"; + +describe("PiRule", () => { + let testDir: string; + let cleanup: () => Promise; + + beforeEach(async () => { + ({ testDir, cleanup } = await setupTestDirectory()); + vi.spyOn(process, "cwd").mockReturnValue(testDir); + }); + + afterEach(async () => { + await cleanup(); + vi.restoreAllMocks(); + }); + + describe("getSettablePaths", () => { + it("should return project paths by default", () => { + const paths = PiRule.getSettablePaths(); + + expect(paths.root).toEqual({ + relativeDirPath: ".", + relativeFilePath: "AGENTS.md", + }); + expect(paths.nonRoot).toEqual({ + relativeDirPath: join(".agents", "memories"), + }); + }); + + it("should return global paths when global is true", () => { + const paths = PiRule.getSettablePaths({ global: true }); + + expect(paths.root).toEqual({ + relativeDirPath: join(".pi", "agent"), + relativeFilePath: "AGENTS.md", + }); + expect("nonRoot" in paths).toBe(false); + }); + + it("should honor excludeToolDir for global paths", () => { + const paths = PiRule.getSettablePaths({ global: true, excludeToolDir: true }); + + expect(paths.root).toEqual({ + relativeDirPath: "agent", + relativeFilePath: "AGENTS.md", + }); + }); + + it("should honor excludeToolDir for project non-root paths", () => { + const paths = PiRule.getSettablePaths({ excludeToolDir: true }); + + expect(paths.nonRoot).toEqual({ + relativeDirPath: "memories", + }); + }); + }); + + describe("fromFile", () => { + it("should load the root AGENTS.md file", async () => { + const content = "# Root Pi Agent\n\nContent."; + await writeFileContent(join(testDir, "AGENTS.md"), content); + + const rule = await PiRule.fromFile({ + baseDir: testDir, + relativeFilePath: "AGENTS.md", + }); + + expect(rule.getFileContent()).toBe(content); + expect(rule.isRoot()).toBe(true); + expect(rule.getRelativeDirPath()).toBe("."); + expect(rule.getRelativeFilePath()).toBe("AGENTS.md"); + }); + + it("should load a non-root memories file", async () => { + const memoriesDir = join(testDir, ".agents", "memories"); + await ensureDir(memoriesDir); + const content = "# Memory\nBody."; + await writeFileContent(join(memoriesDir, "memory.md"), content); + + const rule = await PiRule.fromFile({ + baseDir: testDir, + relativeFilePath: "memory.md", + }); + + expect(rule.getFileContent()).toBe(content); + expect(rule.isRoot()).toBe(false); + expect(rule.getRelativeDirPath()).toBe(join(".agents", "memories")); + expect(rule.getRelativeFilePath()).toBe("memory.md"); + }); + + it("should load root AGENTS.md in global mode", async () => { + const globalDir = join(testDir, ".pi", "agent"); + await ensureDir(globalDir); + const content = "# Global Pi Agent"; + await writeFileContent(join(globalDir, "AGENTS.md"), content); + + const rule = await PiRule.fromFile({ + baseDir: testDir, + relativeFilePath: "AGENTS.md", + global: true, + }); + + expect(rule.getFileContent()).toBe(content); + expect(rule.isRoot()).toBe(true); + expect(rule.getRelativeDirPath()).toBe(join(".pi", "agent")); + }); + + it("should throw when asked for non-root file in global mode", async () => { + await expect( + PiRule.fromFile({ + baseDir: testDir, + relativeFilePath: "memory.md", + global: true, + }), + ).rejects.toThrow(/global mode/i); + }); + }); + + describe("fromRulesyncRule", () => { + it("should produce a root rule from a root rulesync rule", () => { + const rulesyncRule = new RulesyncRule({ + baseDir: testDir, + relativeDirPath: ".rulesync/rules", + relativeFilePath: "overview.md", + frontmatter: { + root: true, + targets: ["pi"], + }, + body: "# Root\nBody.", + }); + + const rule = PiRule.fromRulesyncRule({ + baseDir: testDir, + rulesyncRule, + }); + + expect(rule.isRoot()).toBe(true); + expect(rule.getRelativeDirPath()).toBe("."); + expect(rule.getRelativeFilePath()).toBe("AGENTS.md"); + expect(rule.getFileContent()).toContain("# Root"); + }); + + it("should produce a non-root rule under .agents/memories", () => { + const rulesyncRule = new RulesyncRule({ + baseDir: testDir, + relativeDirPath: ".rulesync/rules", + relativeFilePath: "memory.md", + frontmatter: { + root: false, + targets: ["pi"], + }, + body: "# Memory\nBody.", + }); + + const rule = PiRule.fromRulesyncRule({ + baseDir: testDir, + rulesyncRule, + }); + + expect(rule.isRoot()).toBe(false); + expect(rule.getRelativeDirPath()).toBe(join(".agents", "memories")); + expect(rule.getRelativeFilePath()).toBe("memory.md"); + }); + + it("should throw for non-root rule in global mode", () => { + const rulesyncRule = new RulesyncRule({ + baseDir: testDir, + relativeDirPath: ".rulesync/rules", + relativeFilePath: "memory.md", + frontmatter: { + root: false, + targets: ["pi"], + }, + body: "# Memory\nBody.", + }); + + expect(() => + PiRule.fromRulesyncRule({ + baseDir: testDir, + rulesyncRule, + global: true, + }), + ).toThrow(/global mode/i); + }); + + it("should use global root paths when global is true", () => { + const rulesyncRule = new RulesyncRule({ + baseDir: testDir, + relativeDirPath: ".rulesync/rules", + relativeFilePath: "overview.md", + frontmatter: { + root: true, + targets: ["pi"], + }, + body: "# Global\nBody.", + }); + + const rule = PiRule.fromRulesyncRule({ + baseDir: testDir, + rulesyncRule, + global: true, + }); + + expect(rule.isRoot()).toBe(true); + expect(rule.getRelativeDirPath()).toBe(join(".pi", "agent")); + expect(rule.getRelativeFilePath()).toBe("AGENTS.md"); + }); + }); + + describe("toRulesyncRule", () => { + it("should convert a root rule", () => { + const rule = new PiRule({ + baseDir: testDir, + relativeDirPath: ".", + relativeFilePath: "AGENTS.md", + fileContent: "# Root\nBody.", + root: true, + }); + + const rulesyncRule = rule.toRulesyncRule(); + + expect(rulesyncRule.getBody()).toBe("# Root\nBody."); + expect(rulesyncRule.getFrontmatter().root).toBe(true); + }); + + it("should convert a non-root rule", () => { + const rule = new PiRule({ + baseDir: testDir, + relativeDirPath: join(".agents", "memories"), + relativeFilePath: "memory.md", + fileContent: "# Memory\nBody.", + root: false, + }); + + const rulesyncRule = rule.toRulesyncRule(); + + expect(rulesyncRule.getBody()).toBe("# Memory\nBody."); + expect(rulesyncRule.getFrontmatter().root).toBe(false); + }); + }); + + describe("validate", () => { + it("should always succeed", () => { + const rule = new PiRule({ + baseDir: testDir, + relativeDirPath: ".", + relativeFilePath: "AGENTS.md", + fileContent: "", + root: true, + }); + + const result = rule.validate(); + expect(result.success).toBe(true); + expect(result.error).toBeNull(); + }); + }); + + describe("forDeletion", () => { + it("should create a root deletion stub when path matches root", () => { + const rule = PiRule.forDeletion({ + baseDir: testDir, + relativeDirPath: ".", + relativeFilePath: "AGENTS.md", + }); + + expect(rule.isRoot()).toBe(true); + expect(rule.getFileContent()).toBe(""); + }); + + it("should create a non-root deletion stub when path does not match root", () => { + const rule = PiRule.forDeletion({ + baseDir: testDir, + relativeDirPath: join(".agents", "memories"), + relativeFilePath: "memory.md", + }); + + expect(rule.isRoot()).toBe(false); + expect(rule.getFileContent()).toBe(""); + }); + }); + + describe("isTargetedByRulesyncRule", () => { + it("should return true for pi target", () => { + const rulesyncRule = new RulesyncRule({ + baseDir: testDir, + relativeDirPath: ".rulesync/rules", + relativeFilePath: "test.md", + frontmatter: { targets: ["pi"] }, + body: "Body", + }); + + expect(PiRule.isTargetedByRulesyncRule(rulesyncRule)).toBe(true); + }); + + it("should return true for wildcard targets", () => { + const rulesyncRule = new RulesyncRule({ + baseDir: testDir, + relativeDirPath: ".rulesync/rules", + relativeFilePath: "test.md", + frontmatter: { targets: ["*"] }, + body: "Body", + }); + + expect(PiRule.isTargetedByRulesyncRule(rulesyncRule)).toBe(true); + }); + + it("should return false for non-pi targets", () => { + const rulesyncRule = new RulesyncRule({ + baseDir: testDir, + relativeDirPath: ".rulesync/rules", + relativeFilePath: "test.md", + frontmatter: { targets: ["cursor"] }, + body: "Body", + }); + + expect(PiRule.isTargetedByRulesyncRule(rulesyncRule)).toBe(false); + }); + }); +}); diff --git a/src/features/rules/pi-rule.ts b/src/features/rules/pi-rule.ts new file mode 100644 index 00000000..28d83635 --- /dev/null +++ b/src/features/rules/pi-rule.ts @@ -0,0 +1,168 @@ +import { join } from "node:path"; + +import { ValidationResult } from "../../types/ai-file.js"; +import { readFileContent } from "../../utils/file.js"; +import { RulesyncRule } from "./rulesync-rule.js"; +import { + ToolRule, + ToolRuleForDeletionParams, + ToolRuleFromFileParams, + ToolRuleFromRulesyncRuleParams, + ToolRuleSettablePaths, + ToolRuleSettablePathsGlobal, + buildToolPath, +} from "./tool-rule.js"; + +export type PiRuleSettablePaths = ToolRuleSettablePaths & { + root: { + relativeDirPath: string; + relativeFilePath: string; + }; +}; + +export type PiRuleSettablePathsGlobal = ToolRuleSettablePathsGlobal; + +/** + * Rule generator for Pi Coding Agent. + * + * Pi uses a single `AGENTS.md` file at the project root for the overview, + * plus optional `.agents/memories/*.md` files referenced from the root via + * TOON format (`ruleDiscoveryMode: "toon"`). + * + * In global mode, only the root `~/.pi/agent/AGENTS.md` is emitted; non-root + * rules are not supported. + */ +export class PiRule extends ToolRule { + static getSettablePaths({ + global, + excludeToolDir, + }: { + global?: boolean; + excludeToolDir?: boolean; + } = {}): PiRuleSettablePaths | PiRuleSettablePathsGlobal { + if (global) { + // When excludeToolDir is true the caller drops the `.pi` prefix and only + // the `agent` directory remains (e.g. when emitting into a pre-scoped + // global directory). Pi has no non-root memories in global mode, so we + // return only the root path entry. + return { + root: { + relativeDirPath: buildToolPath(".pi", "agent", excludeToolDir), + relativeFilePath: "AGENTS.md", + }, + }; + } + return { + root: { + relativeDirPath: ".", + relativeFilePath: "AGENTS.md", + }, + nonRoot: { + relativeDirPath: buildToolPath(".agents", "memories", excludeToolDir), + }, + }; + } + + static async fromFile({ + baseDir = process.cwd(), + relativeFilePath, + validate = true, + global = false, + }: ToolRuleFromFileParams): Promise { + const paths = this.getSettablePaths({ global }); + const isRoot = relativeFilePath === paths.root.relativeFilePath; + + if (isRoot) { + const fileContent = await readFileContent( + join(baseDir, paths.root.relativeDirPath, paths.root.relativeFilePath), + ); + + return new PiRule({ + baseDir, + relativeDirPath: paths.root.relativeDirPath, + relativeFilePath: paths.root.relativeFilePath, + fileContent, + validate, + root: true, + }); + } + + if (!paths.nonRoot) { + throw new Error( + `PiRule does not support non-root rules in global mode; expected '${paths.root.relativeFilePath}' but got '${relativeFilePath}'`, + ); + } + + const relativePath = join(paths.nonRoot.relativeDirPath, relativeFilePath); + const fileContent = await readFileContent(join(baseDir, relativePath)); + return new PiRule({ + baseDir, + relativeDirPath: paths.nonRoot.relativeDirPath, + relativeFilePath, + fileContent, + validate, + root: false, + }); + } + + static fromRulesyncRule({ + baseDir = process.cwd(), + rulesyncRule, + validate = true, + global = false, + }: ToolRuleFromRulesyncRuleParams): PiRule { + const paths = this.getSettablePaths({ global }); + + if (global && !rulesyncRule.getFrontmatter().root) { + throw new Error( + `PiRule does not support non-root rules in global mode; expected a root rule but got '${rulesyncRule.getRelativeFilePath()}'`, + ); + } + + return new PiRule( + this.buildToolRuleParamsAgentsmd({ + baseDir, + rulesyncRule, + validate, + rootPath: paths.root, + nonRootPath: paths.nonRoot, + }), + ); + } + + toRulesyncRule(): RulesyncRule { + return this.toRulesyncRuleDefault(); + } + + validate(): ValidationResult { + // Pi rules are plain markdown files without complex frontmatter, + // so any body content is considered valid. + return { success: true, error: null }; + } + + static forDeletion({ + baseDir = process.cwd(), + relativeDirPath, + relativeFilePath, + global = false, + }: ToolRuleForDeletionParams): PiRule { + const paths = this.getSettablePaths({ global }); + const isRoot = relativeFilePath === paths.root.relativeFilePath; + + return new PiRule({ + baseDir, + relativeDirPath, + relativeFilePath, + fileContent: "", + validate: false, + root: isRoot, + }); + } + + static isTargetedByRulesyncRule(rulesyncRule: RulesyncRule): boolean { + return this.isTargetedByRulesyncRuleDefault({ + rulesyncRule, + toolTarget: "pi", + }); + } +} diff --git a/src/features/rules/rules-processor.test.ts b/src/features/rules/rules-processor.test.ts index b3389041..18febdd0 100644 --- a/src/features/rules/rules-processor.test.ts +++ b/src/features/rules/rules-processor.test.ts @@ -872,6 +872,7 @@ Content that would fail parsing`; "goose", "kilo", "opencode", + "pi", "rovodev", "takt", ]); @@ -904,9 +905,10 @@ Content that would fail parsing`; expect(globalTargets).toContain("kilo"); expect(globalTargets).toContain("goose"); expect(globalTargets).toContain("opencode"); + expect(globalTargets).toContain("pi"); expect(globalTargets).toContain("rovodev"); expect(globalTargets).toContain("takt"); - expect(globalTargets.length).toBe(12); + expect(globalTargets.length).toBe(13); // These targets should NOT be in global mode expect(globalTargets).not.toContain("cursor"); diff --git a/src/features/rules/rules-processor.ts b/src/features/rules/rules-processor.ts index 6d646569..8e48f38a 100644 --- a/src/features/rules/rules-processor.ts +++ b/src/features/rules/rules-processor.ts @@ -50,6 +50,7 @@ import { JunieRule } from "./junie-rule.js"; import { KiloRule } from "./kilo-rule.js"; import { KiroRule } from "./kiro-rule.js"; import { OpenCodeRule } from "./opencode-rule.js"; +import { PiRule } from "./pi-rule.js"; import { QwencodeRule } from "./qwencode-rule.js"; import { ReplitRule } from "./replit-rule.js"; import { RooRule } from "./roo-rule.js"; @@ -87,6 +88,7 @@ const rulesProcessorToolTargets: ToolTarget[] = [ "kilo", "kiro", "opencode", + "pi", "qwencode", "replit", "roo", @@ -457,6 +459,17 @@ const toolRuleFactories = new Map([ }, }, ], + [ + "pi", + { + class: PiRule, + meta: { + extension: "md", + supportsGlobal: true, + ruleDiscoveryMode: "toon", + }, + }, + ], [ "qwencode", { diff --git a/src/features/skills/pi-skill.test.ts b/src/features/skills/pi-skill.test.ts new file mode 100644 index 00000000..73c97d87 --- /dev/null +++ b/src/features/skills/pi-skill.test.ts @@ -0,0 +1,320 @@ +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { SKILL_FILE_NAME } from "../../constants/general.js"; +import { RULESYNC_SKILLS_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; +import { setupTestDirectory } from "../../test-utils/test-directories.js"; +import { ensureDir, writeFileBuffer, writeFileContent } from "../../utils/file.js"; +import { PiSkill } from "./pi-skill.js"; +import { RulesyncSkill } from "./rulesync-skill.js"; + +describe("PiSkill", () => { + let testDir: string; + let cleanup: () => Promise; + + beforeEach(async () => { + ({ testDir, cleanup } = await setupTestDirectory()); + vi.spyOn(process, "cwd").mockReturnValue(testDir); + }); + + afterEach(async () => { + await cleanup(); + vi.restoreAllMocks(); + }); + + describe("getSettablePaths", () => { + it("should return project skills directory by default", () => { + const paths = PiSkill.getSettablePaths(); + expect(paths.relativeDirPath).toBe(join(".pi", "skills")); + }); + + it("should return global skills directory when global is true", () => { + const paths = PiSkill.getSettablePaths({ global: true }); + expect(paths.relativeDirPath).toBe(join(".pi", "agent", "skills")); + }); + }); + + describe("constructor", () => { + it("should create a PiSkill with valid frontmatter", () => { + const skill = new PiSkill({ + baseDir: testDir, + relativeDirPath: join(".pi", "skills"), + dirName: "test-skill", + frontmatter: { name: "Test Skill", description: "Desc" }, + body: "Body", + }); + + expect(skill).toBeInstanceOf(PiSkill); + expect(skill.getBody()).toBe("Body"); + expect(skill.getFrontmatter()).toEqual({ + name: "Test Skill", + description: "Desc", + }); + }); + + it("should throw on invalid frontmatter when validating", () => { + expect(() => { + new PiSkill({ + baseDir: testDir, + relativeDirPath: join(".pi", "skills"), + dirName: "bad", + frontmatter: { name: 123 as any, description: "Desc" }, + body: "Body", + validate: true, + }); + }).toThrow(); + }); + }); + + describe("fromDir", () => { + it("should load a PiSkill from a project skill directory", async () => { + const skillDir = join(testDir, ".pi", "skills", "demo"); + await ensureDir(skillDir); + await writeFileContent( + join(skillDir, SKILL_FILE_NAME), + `--- +name: demo +description: Demo skill +--- + +Body content`, + ); + + const skill = await PiSkill.fromDir({ + baseDir: testDir, + dirName: "demo", + }); + + expect(skill).toBeInstanceOf(PiSkill); + expect(skill.getBody()).toBe("Body content"); + expect(skill.getFrontmatter()).toEqual({ + name: "demo", + description: "Demo skill", + }); + }); + + it("should load a PiSkill from the global skills directory", async () => { + const skillDir = join(testDir, ".pi", "agent", "skills", "demo"); + await ensureDir(skillDir); + await writeFileContent( + join(skillDir, SKILL_FILE_NAME), + `--- +name: demo +description: Global demo +--- + +Body content`, + ); + + const skill = await PiSkill.fromDir({ + baseDir: testDir, + dirName: "demo", + global: true, + }); + + expect(skill.getFrontmatter()).toEqual({ + name: "demo", + description: "Global demo", + }); + expect(skill.getRelativeDirPath()).toBe(join(".pi", "agent", "skills")); + }); + + it("should preserve other files through fromDir and round-trip", async () => { + const skillDir = join(testDir, ".pi", "skills", "demo"); + await ensureDir(skillDir); + await writeFileContent( + join(skillDir, SKILL_FILE_NAME), + `--- +name: demo +description: Demo skill +--- + +Body content`, + ); + await writeFileBuffer(join(skillDir, "ref.md"), Buffer.from("# Reference\nAuxiliary file.")); + + const skill = await PiSkill.fromDir({ + baseDir: testDir, + dirName: "demo", + }); + + const otherFiles = skill.getOtherFiles(); + expect(otherFiles).toHaveLength(1); + expect(otherFiles[0]?.relativeFilePathToDirPath).toBe("ref.md"); + expect(otherFiles[0]?.fileBuffer.toString()).toBe("# Reference\nAuxiliary file."); + + const rulesyncSkill = skill.toRulesyncSkill(); + expect(rulesyncSkill.getOtherFiles()).toEqual(otherFiles); + + const restored = PiSkill.fromRulesyncSkill({ + baseDir: testDir, + rulesyncSkill, + }); + expect(restored.getOtherFiles()).toEqual(otherFiles); + }); + + it("should throw when the frontmatter is invalid", async () => { + const skillDir = join(testDir, ".pi", "skills", "bad"); + await ensureDir(skillDir); + await writeFileContent( + join(skillDir, SKILL_FILE_NAME), + `--- +name: 123 +description: Bad +--- + +Body`, + ); + + await expect( + PiSkill.fromDir({ + baseDir: testDir, + dirName: "bad", + }), + ).rejects.toThrow(/Invalid frontmatter/); + }); + }); + + describe("fromRulesyncSkill", () => { + it("should create a PiSkill from a RulesyncSkill", () => { + const rulesyncSkill = new RulesyncSkill({ + baseDir: testDir, + relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, + dirName: "demo", + frontmatter: { + name: "demo", + description: "Demo", + targets: ["*"], + }, + body: "Body", + validate: true, + }); + + const skill = PiSkill.fromRulesyncSkill({ + baseDir: testDir, + rulesyncSkill, + }); + + expect(skill).toBeInstanceOf(PiSkill); + expect(skill.getFrontmatter()).toEqual({ + name: "demo", + description: "Demo", + }); + expect(skill.getRelativeDirPath()).toBe(join(".pi", "skills")); + }); + + it("should emit to the global path when global is true", () => { + const rulesyncSkill = new RulesyncSkill({ + baseDir: testDir, + relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, + dirName: "demo", + frontmatter: { + name: "demo", + description: "Demo", + targets: ["*"], + }, + body: "Body", + validate: true, + }); + + const skill = PiSkill.fromRulesyncSkill({ + baseDir: testDir, + rulesyncSkill, + global: true, + }); + + expect(skill.getRelativeDirPath()).toBe(join(".pi", "agent", "skills")); + }); + }); + + describe("toRulesyncSkill", () => { + it("should convert a PiSkill to a RulesyncSkill with wildcard targets", () => { + const skill = new PiSkill({ + baseDir: testDir, + relativeDirPath: join(".pi", "skills"), + dirName: "demo", + frontmatter: { name: "demo", description: "Demo" }, + body: "Body", + }); + + const rulesyncSkill = skill.toRulesyncSkill(); + expect(rulesyncSkill.getFrontmatter()).toEqual({ + name: "demo", + description: "Demo", + targets: ["*"], + }); + expect(rulesyncSkill.getBody()).toBe("Body"); + }); + }); + + describe("validate", () => { + it("should succeed for valid frontmatter", () => { + const skill = new PiSkill({ + baseDir: testDir, + relativeDirPath: join(".pi", "skills"), + dirName: "demo", + frontmatter: { name: "demo", description: "Demo" }, + body: "Body", + }); + + const result = skill.validate(); + expect(result.success).toBe(true); + expect(result.error).toBeNull(); + }); + }); + + describe("forDeletion", () => { + it("should produce a deletion stub", () => { + const skill = PiSkill.forDeletion({ + dirName: "stale", + relativeDirPath: join(".pi", "skills"), + }); + + expect(skill.getDirName()).toBe("stale"); + expect(skill.getRelativeDirPath()).toBe(join(".pi", "skills")); + expect(skill.getBody()).toBe(""); + }); + }); + + describe("isTargetedByRulesyncSkill", () => { + it("should return true for wildcard", () => { + const rulesyncSkill = new RulesyncSkill({ + baseDir: testDir, + relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, + dirName: "demo", + frontmatter: { name: "demo", description: "Demo", targets: ["*"] }, + body: "Body", + validate: true, + }); + + expect(PiSkill.isTargetedByRulesyncSkill(rulesyncSkill)).toBe(true); + }); + + it("should return true for pi target", () => { + const rulesyncSkill = new RulesyncSkill({ + baseDir: testDir, + relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, + dirName: "demo", + frontmatter: { name: "demo", description: "Demo", targets: ["pi"] }, + body: "Body", + validate: true, + }); + + expect(PiSkill.isTargetedByRulesyncSkill(rulesyncSkill)).toBe(true); + }); + + it("should return false for unrelated targets", () => { + const rulesyncSkill = new RulesyncSkill({ + baseDir: testDir, + relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, + dirName: "demo", + frontmatter: { name: "demo", description: "Demo", targets: ["cursor"] }, + body: "Body", + validate: true, + }); + + expect(PiSkill.isTargetedByRulesyncSkill(rulesyncSkill)).toBe(false); + }); + }); +}); diff --git a/src/features/skills/pi-skill.ts b/src/features/skills/pi-skill.ts new file mode 100644 index 00000000..a86da73d --- /dev/null +++ b/src/features/skills/pi-skill.ts @@ -0,0 +1,218 @@ +import { join } from "node:path"; + +import { z } from "zod/mini"; + +import { SKILL_FILE_NAME } from "../../constants/general.js"; +import { RULESYNC_SKILLS_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; +import { ValidationResult } from "../../types/ai-dir.js"; +import { formatError } from "../../utils/error.js"; +import { RulesyncSkill, RulesyncSkillFrontmatterInput, SkillFile } from "./rulesync-skill.js"; +import { + ToolSkill, + ToolSkillForDeletionParams, + ToolSkillFromDirParams, + ToolSkillFromRulesyncSkillParams, + ToolSkillSettablePaths, +} from "./tool-skill.js"; + +/** + * Frontmatter schema for Pi Coding Agent skills. + * + * Pi follows the Agent Skills standard (SKILL.md with `name` and `description`). + * Additional fields are preserved via `looseObject` so Pi-specific metadata + * passes through unchanged. + */ +export const PiSkillFrontmatterSchema = z.looseObject({ + name: z.string(), + description: z.string(), +}); + +export type PiSkillFrontmatter = z.infer; + +export type PiSkillParams = { + baseDir?: string; + relativeDirPath?: string; + dirName: string; + frontmatter: PiSkillFrontmatter; + body: string; + otherFiles?: SkillFile[]; + validate?: boolean; + global?: boolean; +}; + +/** + * Skill generator for Pi Coding Agent. + * + * - Project scope: `.pi/skills//SKILL.md` + * - Global scope: `~/.pi/agent/skills//SKILL.md` + */ +export class PiSkill extends ToolSkill { + constructor({ + baseDir = process.cwd(), + relativeDirPath, + dirName, + frontmatter, + body, + otherFiles = [], + validate = true, + global = false, + }: PiSkillParams) { + const resolvedDirPath = relativeDirPath ?? PiSkill.getSettablePaths({ global }).relativeDirPath; + + super({ + baseDir, + relativeDirPath: resolvedDirPath, + dirName, + mainFile: { + name: SKILL_FILE_NAME, + body, + frontmatter: { ...frontmatter }, + }, + otherFiles, + global, + }); + + if (validate) { + const result = this.validate(); + if (!result.success) { + throw result.error; + } + } + } + + static getSettablePaths({ global }: { global?: boolean } = {}): ToolSkillSettablePaths { + if (global) { + return { + relativeDirPath: join(".pi", "agent", "skills"), + }; + } + return { + relativeDirPath: join(".pi", "skills"), + }; + } + + getFrontmatter(): PiSkillFrontmatter { + return PiSkillFrontmatterSchema.parse(this.requireMainFileFrontmatter()); + } + + getBody(): string { + return this.mainFile?.body ?? ""; + } + + validate(): ValidationResult { + if (!this.mainFile) { + return { + success: false, + error: new Error(`${this.getDirPath()}: ${SKILL_FILE_NAME} file does not exist`), + }; + } + + const result = PiSkillFrontmatterSchema.safeParse(this.mainFile.frontmatter); + if (!result.success) { + return { + success: false, + error: new Error( + `Invalid frontmatter in ${this.getDirPath()}: ${formatError(result.error)}`, + ), + }; + } + + return { success: true, error: null }; + } + + toRulesyncSkill(): RulesyncSkill { + const frontmatter = this.getFrontmatter(); + const rulesyncFrontmatter: RulesyncSkillFrontmatterInput = { + name: frontmatter.name, + description: frontmatter.description, + targets: ["*"], + }; + + return new RulesyncSkill({ + baseDir: this.baseDir, + relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, + dirName: this.getDirName(), + frontmatter: rulesyncFrontmatter, + body: this.getBody(), + otherFiles: this.getOtherFiles(), + validate: true, + global: this.global, + }); + } + + static fromRulesyncSkill({ + baseDir = process.cwd(), + rulesyncSkill, + validate = true, + global = false, + }: ToolSkillFromRulesyncSkillParams): PiSkill { + const settablePaths = PiSkill.getSettablePaths({ global }); + const rulesyncFrontmatter = rulesyncSkill.getFrontmatter(); + + const piFrontmatter: PiSkillFrontmatter = { + name: rulesyncFrontmatter.name, + description: rulesyncFrontmatter.description, + }; + + return new PiSkill({ + baseDir, + relativeDirPath: settablePaths.relativeDirPath, + dirName: rulesyncSkill.getDirName(), + frontmatter: piFrontmatter, + body: rulesyncSkill.getBody(), + otherFiles: rulesyncSkill.getOtherFiles(), + validate, + global, + }); + } + + static isTargetedByRulesyncSkill(rulesyncSkill: RulesyncSkill): boolean { + const targets = rulesyncSkill.getFrontmatter().targets; + return targets.includes("*") || targets.includes("pi"); + } + + static async fromDir(params: ToolSkillFromDirParams): Promise { + const loaded = await this.loadSkillDirContent({ + ...params, + getSettablePaths: PiSkill.getSettablePaths, + }); + + const result = PiSkillFrontmatterSchema.safeParse(loaded.frontmatter); + if (!result.success) { + const skillDirPath = join(loaded.baseDir, loaded.relativeDirPath, loaded.dirName); + throw new Error( + `Invalid frontmatter in ${join(skillDirPath, SKILL_FILE_NAME)}: ${formatError(result.error)}`, + ); + } + + return new PiSkill({ + baseDir: loaded.baseDir, + relativeDirPath: loaded.relativeDirPath, + dirName: loaded.dirName, + frontmatter: result.data, + body: loaded.body, + otherFiles: loaded.otherFiles, + validate: true, + global: loaded.global, + }); + } + + static forDeletion({ + baseDir = process.cwd(), + relativeDirPath, + dirName, + global = false, + }: ToolSkillForDeletionParams): PiSkill { + const settablePaths = PiSkill.getSettablePaths({ global }); + return new PiSkill({ + baseDir, + relativeDirPath: relativeDirPath ?? settablePaths.relativeDirPath, + dirName, + frontmatter: { name: "", description: "" }, + body: "", + otherFiles: [], + validate: false, + global, + }); + } +} diff --git a/src/features/skills/skills-processor.test.ts b/src/features/skills/skills-processor.test.ts index da98a247..f302618a 100644 --- a/src/features/skills/skills-processor.test.ts +++ b/src/features/skills/skills-processor.test.ts @@ -771,6 +771,7 @@ Content that would fail parsing`; "kilo", "kiro", "opencode", + "pi", "replit", "roo", "rovodev", @@ -800,6 +801,7 @@ Content that would fail parsing`; "kilo", "kiro", "opencode", + "pi", "replit", "roo", "rovodev", @@ -827,6 +829,7 @@ Content that would fail parsing`; "kilo", "kiro", "opencode", + "pi", "replit", "roo", "rovodev", @@ -861,6 +864,7 @@ Content that would fail parsing`; "geminicli", "kilo", "opencode", + "pi", "roo", "rovodev", "takt", @@ -883,6 +887,7 @@ Content that would fail parsing`; "geminicli", "kilo", "opencode", + "pi", "roo", "rovodev", "takt", diff --git a/src/features/skills/skills-processor.ts b/src/features/skills/skills-processor.ts index a890e244..504dab39 100644 --- a/src/features/skills/skills-processor.ts +++ b/src/features/skills/skills-processor.ts @@ -24,6 +24,7 @@ import { JunieSkill } from "./junie-skill.js"; import { KiloSkill } from "./kilo-skill.js"; import { KiroSkill } from "./kiro-skill.js"; import { OpenCodeSkill } from "./opencode-skill.js"; +import { PiSkill } from "./pi-skill.js"; import { ReplitSkill } from "./replit-skill.js"; import { RooSkill } from "./roo-skill.js"; import { RovodevSkill } from "./rovodev-skill.js"; @@ -84,6 +85,7 @@ const skillsProcessorToolTargetTuple = [ "kilo", "kiro", "opencode", + "pi", "replit", "roo", "rovodev", @@ -213,6 +215,13 @@ const toolSkillFactories = new Map( meta: { supportsProject: true, supportsSimulated: false, supportsGlobal: true }, }, ], + [ + "pi", + { + class: PiSkill, + meta: { supportsProject: true, supportsSimulated: false, supportsGlobal: true }, + }, + ], [ "replit", { diff --git a/src/types/tool-targets.test.ts b/src/types/tool-targets.test.ts index bd9a1cb2..4a98bdf3 100644 --- a/src/types/tool-targets.test.ts +++ b/src/types/tool-targets.test.ts @@ -32,6 +32,7 @@ describe("tool targets", () => { "kilo", "kiro", "opencode", + "pi", "qwencode", "replit", "roo", diff --git a/src/types/tool-targets.ts b/src/types/tool-targets.ts index 94f91799..8f7688ca 100644 --- a/src/types/tool-targets.ts +++ b/src/types/tool-targets.ts @@ -24,6 +24,7 @@ export const ALL_TOOL_TARGETS = [ "kilo", "kiro", "opencode", + "pi", "qwencode", "replit", "roo",