From 67ff754a16b8c55bb31ae2ce4d42ba11e7254998 Mon Sep 17 00:00:00 2001 From: Matias Perez Date: Sat, 3 Jan 2026 20:03:49 -0300 Subject: [PATCH] Implement 'agents' command in CLI to download agent instructions with strategy options --- .changeset/bumpy-kids-pull.md | 5 ++ blender/README.md | 1 + cli/README.md | 1 + cli/src/cli.ts | 2 + cli/src/commands/agents.ts | 119 ++++++++++++++++++++++++++++++++++ cli/src/core/agents.ts | 96 +++++++++++++++++++++++++++ cli/test/cli.test.ts | 12 ++++ cli/test/core.test.ts | 78 ++++++++++++++++++++++ 8 files changed, 314 insertions(+) create mode 100644 .changeset/bumpy-kids-pull.md create mode 100644 cli/src/commands/agents.ts create mode 100644 cli/src/core/agents.ts diff --git a/.changeset/bumpy-kids-pull.md b/.changeset/bumpy-kids-pull.md new file mode 100644 index 0000000..0a5f557 --- /dev/null +++ b/.changeset/bumpy-kids-pull.md @@ -0,0 +1,5 @@ +--- +"@joycostudio/scripts": patch +--- + +add new agents command diff --git a/blender/README.md b/blender/README.md index 0786416..6ac8a8a 100644 --- a/blender/README.md +++ b/blender/README.md @@ -10,3 +10,4 @@ Custom Blender plugins for exporting data to web-friendly formats. + diff --git a/cli/README.md b/cli/README.md index 3b4e97d..6c51bed 100644 --- a/cli/README.md +++ b/cli/README.md @@ -10,6 +10,7 @@ pnpx @joycostudio/scripts compress ./images ./output --quality 80 pnpx @joycostudio/scripts resize ./images ./output --width 1920 --height 1080 pnpx @joycostudio/scripts sequence -z 4 ./frames ./output/frame_%n.png pnpx @joycostudio/scripts fix-svg src --dry --print +pnpx @joycostudio/scripts agents -s codex ``` For local development, build the CLI before running it directly: diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 43f99de..d2a046d 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -4,6 +4,7 @@ import registerCompress from "./commands/compress"; import registerSequence from "./commands/sequence"; import registerResize from "./commands/resize"; import registerFixSvg from "./commands/fix-svg"; +import registerAgents from "./commands/agents"; const cliName = "scripts"; const cliDescription = "Joyco utility scripts bundled as a pnpx CLI."; @@ -12,6 +13,7 @@ const commandRegistrations = [ registerSequence, registerResize, registerFixSvg, + registerAgents, ]; export function buildProgram() { diff --git a/cli/src/commands/agents.ts b/cli/src/commands/agents.ts new file mode 100644 index 0000000..8445963 --- /dev/null +++ b/cli/src/commands/agents.ts @@ -0,0 +1,119 @@ +import path from "path"; +import fs from "fs/promises"; +import { createInterface } from "node:readline/promises"; +import { stdin as input, stdout as output } from "node:process"; +import type { Command } from "commander"; +import { addExamples, handleCommandError } from "./utils"; +import { + agentStrategies, + resolveAgentsPath, + parseAgentStrategy, + pullAgents, + type AgentStrategy, + type AgentsWriteMode, +} from "../core/agents"; + +const strategyChoices = Object.entries(agentStrategies) + .map(([key, value]) => `${key}=${value.defaultPath}`) + .join(", "); + +async function fileExists(filePath: string) { + try { + await fs.access(filePath); + return true; + } catch (error) { + if (error && typeof error === "object" && "code" in error) { + const errorCode = (error as { code?: string }).code; + if (errorCode === "ENOENT") { + return false; + } + } + throw error; + } +} + +async function promptExistingFile( + outputPath: string +): Promise { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + throw new Error( + "Output file already exists. Re-run in an interactive terminal to choose overwrite, append, or cancel." + ); + } + + const rl = createInterface({ input, output }); + try { + while (true) { + const answer = await rl.question( + `\nFile already exists at ${outputPath}. Overwrite (o), append (a), or cancel (c)? ` + ); + const normalized = answer.trim().toLowerCase(); + if (normalized === "o" || normalized === "overwrite") { + return "overwrite"; + } + if (normalized === "a" || normalized === "append") { + return "append"; + } + if (normalized === "c" || normalized === "cancel") { + return "cancel"; + } + } + } finally { + rl.close(); + } +} + +export default function register(program: Command) { + const command = program + .command("agents") + .description("Download the latest AGENTS.md for a selected agent tool strategy.") + .usage("[dest_path] [-s ]") + .argument( + "[dest_path]", + "Output path (defaults to the strategy's standard location)." + ) + .option( + "-s, --strategy ", + `Agent tool strategy (${strategyChoices}).`, + parseAgentStrategy, + "codex" + ) + .action(async (destPath: string | undefined, options: { strategy: AgentStrategy }, cmd) => { + try { + const strategySource = cmd.getOptionValueSource?.("strategy"); + if (destPath && strategySource === "cli") { + throw new Error("Choose either an output path or -s/--strategy, not both."); + } + const resolvedPath = resolveAgentsPath({ + strategy: options.strategy, + outputPath: destPath, + cwd: process.cwd(), + }); + const displayPath = path.relative(process.cwd(), resolvedPath); + let writeMode: AgentsWriteMode = "create"; + if (await fileExists(resolvedPath)) { + const choice = await promptExistingFile(displayPath); + if (choice === "cancel") { + console.log("Canceled."); + process.exit(1); + } + writeMode = choice; + } + await pullAgents({ + strategy: options.strategy, + outputPath: resolvedPath, + writeMode, + }); + console.log(`Saved agent instructions to ${displayPath}`); + } catch (error) { + handleCommandError(error); + } + }); + + addExamples(command, [ + "scripts agents", + "scripts agents -s claude", + "scripts agents -s cursor", + "scripts agents ./config/AGENTS.md", + ]); +} diff --git a/cli/src/core/agents.ts b/cli/src/core/agents.ts new file mode 100644 index 0000000..bd9dac9 --- /dev/null +++ b/cli/src/core/agents.ts @@ -0,0 +1,96 @@ +import fs from "fs/promises"; +import path from "path"; + +export const AGENTS_URL = "https://registry.joyco.studio/AGENTS.md"; + +export const agentStrategies = { + codex: { + defaultPath: "AGENTS.md", + description: "Codex reads AGENTS.md from the project root.", + }, + claude: { + defaultPath: "CLAUDE.md", + description: "Claude Code reads CLAUDE.md from the project root.", + }, + cursor: { + defaultPath: path.join(".cursor", "rules", "AGENTS.md"), + description: "Cursor reads Markdown rules from .cursor/rules/.", + }, +} as const; + +export type AgentStrategy = keyof typeof agentStrategies; +export type AgentsWriteMode = "create" | "overwrite" | "append"; + +export function parseAgentStrategy(value: string): AgentStrategy { + const normalized = value.trim().toLowerCase(); + if (normalized in agentStrategies) { + return normalized as AgentStrategy; + } + const choices = Object.keys(agentStrategies).join(", "); + throw new Error(`Unknown strategy "${value}". Choose one of: ${choices}.`); +} + +export function getDefaultAgentsPath(strategy: AgentStrategy) { + return agentStrategies[strategy].defaultPath; +} + +export function resolveAgentsPath({ + strategy, + outputPath, + cwd = process.cwd(), +}: { + strategy: AgentStrategy; + outputPath?: string; + cwd?: string; +}) { + return path.resolve(cwd, outputPath ?? getDefaultAgentsPath(strategy)); +} + +async function fetchAgentsMd(url = AGENTS_URL) { + const response = await fetch(url, { + headers: { + "Cache-Control": "no-cache", + }, + }); + if (!response.ok) { + throw new Error( + `Failed to fetch ${url} (${response.status} ${response.statusText}).` + ); + } + return response.text(); +} + +async function writeAgentsFile( + outputPath: string, + contents: string, + mode: AgentsWriteMode +) { + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + if (mode === "append") { + const needsNewline = !contents.startsWith("\n") && contents.length > 0; + await fs.appendFile(outputPath, needsNewline ? `\n${contents}` : contents, "utf8"); + return; + } + const flag = mode === "create" ? "wx" : "w"; + await fs.writeFile(outputPath, contents, { encoding: "utf8", flag }); +} + +export async function pullAgents({ + strategy, + outputPath, + writeMode = "create", + cwd = process.cwd(), +}: { + strategy: AgentStrategy; + outputPath?: string; + writeMode?: AgentsWriteMode; + cwd?: string; +}) { + const resolvedPath = resolveAgentsPath({ strategy, outputPath, cwd }); + const contents = await fetchAgentsMd(); + await writeAgentsFile(resolvedPath, contents, writeMode); + return { + outputPath: resolvedPath, + bytes: Buffer.byteLength(contents, "utf8"), + }; +} diff --git a/cli/test/cli.test.ts b/cli/test/cli.test.ts index 607aacf..bd238eb 100644 --- a/cli/test/cli.test.ts +++ b/cli/test/cli.test.ts @@ -104,3 +104,15 @@ test("cli fix-svg prints transformed output", async (t) => { assert.equal(result.status, 0); assert.match(result.stdout ?? "", /strokeWidth/); }); + +test("cli agents rejects output path when strategy is specified", async (t) => { + await ensureBuild(); + const tempDir = await createTempDir(); + t.after(() => cleanupDir(tempDir)); + + const outputPath = path.join(tempDir, "AGENTS.md"); + const result = runCli(["agents", outputPath, "-s", "codex"]); + + assert.notEqual(result.status, 0); + assert.match(result.stderr ?? "", /either an output path or -s\/--strategy/i); +}); diff --git a/cli/test/core.test.ts b/cli/test/core.test.ts index 0387438..a2589d5 100644 --- a/cli/test/core.test.ts +++ b/cli/test/core.test.ts @@ -5,6 +5,11 @@ import assert from "node:assert/strict"; import { compressImagesToWebp } from "../src/core/compress"; import { resizeImages } from "../src/core/resize"; import { renameFiles } from "../src/core/rename"; +import { + agentStrategies, + pullAgents, + type AgentStrategy, +} from "../src/core/agents"; import { cleanupDir, createTempDir, @@ -112,3 +117,76 @@ test("renameFiles ignores hidden files like .DS_Store", async (t) => { // Hidden files should not appear in output and should not affect numbering assert.deepEqual(outputs.sort(), ["frame_00.txt", "frame_01.txt"].sort()); }); + +test("pullAgents writes AGENTS.md for each strategy", async (t) => { + const tempDir = await createTempDir(); + t.after(() => cleanupDir(tempDir)); + + const originalFetch = globalThis.fetch; + t.after(() => { + globalThis.fetch = originalFetch; + }); + + const contents = "# agents"; + globalThis.fetch = (async () => { + return { + ok: true, + status: 200, + statusText: "OK", + text: async () => contents, + }; + }) as typeof fetch; + + const strategies = Object.keys(agentStrategies) as AgentStrategy[]; + for (const strategy of strategies) { + const result = await pullAgents({ strategy, cwd: tempDir }); + const expectedPath = path.resolve(tempDir, agentStrategies[strategy].defaultPath); + assert.equal(result.outputPath, expectedPath); + assert.equal(result.bytes, Buffer.byteLength(contents, "utf8")); + const stored = await fs.readFile(expectedPath, "utf8"); + assert.equal(stored, contents); + } +}); + +test("pullAgents respects write modes", async (t) => { + const tempDir = await createTempDir(); + t.after(() => cleanupDir(tempDir)); + + const originalFetch = globalThis.fetch; + t.after(() => { + globalThis.fetch = originalFetch; + }); + + let currentContents = "first"; + globalThis.fetch = (async () => { + return { + ok: true, + status: 200, + statusText: "OK", + text: async () => currentContents, + }; + }) as typeof fetch; + + const strategy: AgentStrategy = "codex"; + const filePath = path.resolve(tempDir, agentStrategies[strategy].defaultPath); + + await pullAgents({ strategy, cwd: tempDir, writeMode: "overwrite" }); + let stored = await fs.readFile(filePath, "utf8"); + assert.equal(stored, "first"); + + currentContents = "second"; + await pullAgents({ strategy, cwd: tempDir, writeMode: "append" }); + stored = await fs.readFile(filePath, "utf8"); + assert.equal(stored, "first\nsecond"); + + currentContents = "third"; + await pullAgents({ strategy, cwd: tempDir, writeMode: "overwrite" }); + stored = await fs.readFile(filePath, "utf8"); + assert.equal(stored, "third"); + + currentContents = "fourth"; + await assert.rejects( + () => pullAgents({ strategy, cwd: tempDir, writeMode: "create" }), + /EEXIST|exists|already/ + ); +});