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: 5 additions & 0 deletions .changeset/bumpy-kids-pull.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@joycostudio/scripts": patch
---

add new agents command
1 change: 1 addition & 0 deletions blender/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ Custom Blender plugins for exporting data to web-friendly formats.




1 change: 1 addition & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
Expand All @@ -12,6 +13,7 @@ const commandRegistrations = [
registerSequence,
registerResize,
registerFixSvg,
registerAgents,
];

export function buildProgram() {
Expand Down
119 changes: 119 additions & 0 deletions cli/src/commands/agents.ts
Original file line number Diff line number Diff line change
@@ -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<AgentsWriteMode | "cancel"> {
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 <strategy>]")
.argument(
"[dest_path]",
"Output path (defaults to the strategy's standard location)."
)
.option(
"-s, --strategy <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",
]);
}
96 changes: 96 additions & 0 deletions cli/src/core/agents.ts
Original file line number Diff line number Diff line change
@@ -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"),
};
}
12 changes: 12 additions & 0 deletions cli/test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
78 changes: 78 additions & 0 deletions cli/test/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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/
);
});
Loading