Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
153 changes: 153 additions & 0 deletions apps/server/src/codexCatalog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

import { afterEach, describe, expect, it, vi } from "vitest";

import { listCodexCustomPrompts, resolveCodexPromptHomePath } from "./codexCatalog";

const tempDirs = new Set<string>();
const originalCodexHome = process.env.CODEX_HOME;

function makeTempDir(prefix: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
tempDirs.add(dir);
return dir;
}

afterEach(() => {
for (const dir of tempDirs) {
fs.rmSync(dir, { recursive: true, force: true });
}
tempDirs.clear();
if (typeof originalCodexHome === "string") {
process.env.CODEX_HOME = originalCodexHome;
} else {
delete process.env.CODEX_HOME;
}
vi.restoreAllMocks();
});

describe("resolveCodexPromptHomePath", () => {
it("prefers the explicit homePath input", () => {
process.env.CODEX_HOME = "/env/codex-home";
expect(resolveCodexPromptHomePath({ homePath: "/custom/home" })).toBe(
path.resolve("/custom/home"),
);
});

it("falls back to CODEX_HOME and then ~/.codex", () => {
process.env.CODEX_HOME = "/env/codex-home";
expect(resolveCodexPromptHomePath()).toBe(path.resolve("/env/codex-home"));

delete process.env.CODEX_HOME;
vi.spyOn(os, "homedir").mockReturnValue("/Users/tester");
expect(resolveCodexPromptHomePath()).toBe(path.resolve("/Users/tester/.codex"));
});
});

describe("listCodexCustomPrompts", () => {
it("discovers top-level markdown prompts, parses frontmatter, and sorts by name", async () => {
const codexHome = makeTempDir("t3code-codex-prompts-");
const promptsDir = path.join(codexHome, "prompts");
fs.mkdirSync(promptsDir, { recursive: true });
fs.writeFileSync(
path.join(promptsDir, "beta.md"),
["---", "description: Beta prompt", "argument-hint: FILE=", "---", "Review $FILE"].join("\n"),
"utf8",
);
fs.writeFileSync(path.join(promptsDir, "alpha.md"), "Summarize $1", "utf8");
fs.mkdirSync(path.join(promptsDir, "nested"), { recursive: true });
fs.writeFileSync(path.join(promptsDir, "nested", "ignored.md"), "Ignore me", "utf8");

await expect(listCodexCustomPrompts({ homePath: codexHome })).resolves.toEqual({
prompts: [
{
name: "alpha",
content: "Summarize $1",
},
{
name: "beta",
description: "Beta prompt",
argumentHint: "FILE=",
content: "Review $FILE",
},
],
});
});

it("loads project-local .codex/prompts and prefers them over global prompts", async () => {
const projectRoot = makeTempDir("t3code-project-prompts-");
const projectPromptsDir = path.join(projectRoot, ".codex", "prompts");
fs.mkdirSync(projectPromptsDir, { recursive: true });
fs.writeFileSync(
path.join(projectPromptsDir, "review.md"),
["---", "description: Project review prompt", "---", "Project review $FILE"].join("\n"),
"utf8",
);

const codexHome = makeTempDir("t3code-global-prompts-");
const globalPromptsDir = path.join(codexHome, "prompts");
fs.mkdirSync(globalPromptsDir, { recursive: true });
fs.writeFileSync(
path.join(globalPromptsDir, "review.md"),
["---", "description: Global review prompt", "---", "Global review $FILE"].join("\n"),
"utf8",
);
fs.writeFileSync(path.join(globalPromptsDir, "summarize.md"), "Summarize $1", "utf8");

await expect(
listCodexCustomPrompts({ homePath: codexHome, projectPath: projectRoot }),
).resolves.toEqual({
prompts: [
{
name: "review",
description: "Project review prompt",
content: "Project review $FILE",
},
{
name: "summarize",
content: "Summarize $1",
},
],
});
});

it("uses CODEX_HOME when explicit input is missing", async () => {
const codexHome = makeTempDir("t3code-codex-prompts-env-");
const promptsDir = path.join(codexHome, "prompts");
fs.mkdirSync(promptsDir, { recursive: true });
fs.writeFileSync(path.join(promptsDir, "env.md"), "From env", "utf8");
process.env.CODEX_HOME = codexHome;

await expect(listCodexCustomPrompts()).resolves.toEqual({
prompts: [{ name: "env", content: "From env" }],
});
});

it("falls back to ~/.codex when CODEX_HOME is unset", async () => {
const fakeHome = makeTempDir("t3code-codex-home-");
const codexHome = path.join(fakeHome, ".codex");
const promptsDir = path.join(codexHome, "prompts");
fs.mkdirSync(promptsDir, { recursive: true });
fs.writeFileSync(path.join(promptsDir, "default.md"), "Default prompt", "utf8");
delete process.env.CODEX_HOME;
vi.spyOn(os, "homedir").mockReturnValue(fakeHome);

await expect(listCodexCustomPrompts()).resolves.toEqual({
prompts: [{ name: "default", content: "Default prompt" }],
});
});

it("skips invalid prompt files instead of failing the whole result", async () => {
const codexHome = makeTempDir("t3code-codex-prompts-invalid-");
const promptsDir = path.join(codexHome, "prompts");
fs.mkdirSync(promptsDir, { recursive: true });
fs.writeFileSync(path.join(promptsDir, "good.md"), "Good prompt", "utf8");
fs.writeFileSync(path.join(promptsDir, "broken.md"), "---\ndescription: Missing end", "utf8");

await expect(listCodexCustomPrompts({ homePath: codexHome })).resolves.toEqual({
prompts: [{ name: "good", content: "Good prompt" }],
});
});
});
181 changes: 181 additions & 0 deletions apps/server/src/codexCatalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import type { Dirent } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";

import type {
CodexCustomPrompt,
CodexListCustomPromptsInput,
CodexListCustomPromptsResult,
} from "@t3tools/contracts";

function resolveHomePathSegment(input: string): string {
if (input === "~") {
return os.homedir();
}
if (input.startsWith("~/") || input.startsWith("~\\")) {
return path.join(os.homedir(), input.slice(2));
}
return input;
}

function stripMatchingQuotes(input: string): string {
const trimmed = input.trim();
if (trimmed.length < 2) {
return trimmed;
}
const first = trimmed[0];
const last = trimmed[trimmed.length - 1];
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
return trimmed.slice(1, -1);
}
return trimmed;
}

function parsePromptFrontmatter(fileContents: string): {
description?: string;
argumentHint?: string;
content: string;
} | null {
const normalized = fileContents.replace(/^\uFEFF/, "");
if (!normalized.startsWith("---")) {
return { content: normalized };
}

const delimiterMatch = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(normalized);
if (!delimiterMatch) {
return null;
}

const frontmatterBlock = delimiterMatch[1] ?? "";
const bodyStart = delimiterMatch[0].length;
let description: string | undefined;
let argumentHint: string | undefined;

for (const line of frontmatterBlock.split(/\r?\n/)) {
const separatorIndex = line.indexOf(":");
if (separatorIndex === -1) {
continue;
}
const rawKey = line.slice(0, separatorIndex).trim().toLowerCase();
const rawValue = stripMatchingQuotes(line.slice(separatorIndex + 1));
if (!rawValue) {
continue;
}
if (rawKey === "description") {
description = rawValue;
continue;
}
if (rawKey === "argument-hint" || rawKey === "argument_hint") {
argumentHint = rawValue;
}
}

return {
...(description ? { description } : {}),
...(argumentHint ? { argumentHint } : {}),
content: normalized.slice(bodyStart),
};
}

export function resolveCodexPromptHomePath(
input?: Pick<CodexListCustomPromptsInput, "homePath">,
): string {
const homePath = input?.homePath?.trim() || process.env.CODEX_HOME?.trim() || "~/.codex";
return path.resolve(resolveHomePathSegment(homePath));
}

function resolveProjectPromptDir(
input?: Pick<CodexListCustomPromptsInput, "projectPath">,
): string | null {
const projectPath = input?.projectPath?.trim();
if (!projectPath) {
return null;
}
return path.resolve(projectPath, ".codex", "prompts");
}

async function readPromptDirectory(promptDir: string): Promise<CodexCustomPrompt[]> {
let entries: Dirent<string>[];
try {
entries = await fs.readdir(promptDir, { withFileTypes: true, encoding: "utf8" });
} catch {
return [];
}

const prompts = await Promise.all(
entries
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".md"))
.map(async (entry): Promise<CodexCustomPrompt | null> => {
const promptName = entry.name.slice(0, -3).trim();
if (!promptName) {
return null;
}
const filePath = path.join(promptDir, entry.name);
try {
const fileContents = await fs.readFile(filePath, "utf8");
const parsed = parsePromptFrontmatter(fileContents);
if (!parsed) {
return null;
}
if (parsed.description && parsed.argumentHint) {
return {
name: promptName,
description: parsed.description,
argumentHint: parsed.argumentHint,
content: parsed.content,
};
}
if (parsed.description) {
return {
name: promptName,
description: parsed.description,
content: parsed.content,
};
}
if (parsed.argumentHint) {
return {
name: promptName,
argumentHint: parsed.argumentHint,
content: parsed.content,
};
}
return {
name: promptName,
content: parsed.content,
};
} catch {
return null;
}
}),
);

return prompts.filter((prompt): prompt is CodexCustomPrompt => prompt !== null);
}

export async function listCodexCustomPrompts(
input?: CodexListCustomPromptsInput,
): Promise<CodexListCustomPromptsResult> {
const projectPromptDir = resolveProjectPromptDir(input);
const globalPromptDir = path.join(resolveCodexPromptHomePath(input), "prompts");
const [projectPrompts, globalPrompts] = await Promise.all([
projectPromptDir ? readPromptDirectory(projectPromptDir) : Promise.resolve([]),
readPromptDirectory(globalPromptDir),
]);

const promptsByName = new Map<string, CodexCustomPrompt>();
for (const prompt of projectPrompts) {
promptsByName.set(prompt.name, prompt);
}
for (const prompt of globalPrompts) {
if (!promptsByName.has(prompt.name)) {
promptsByName.set(prompt.name, prompt);
}
}

return {
prompts: Array.from(promptsByName.values()).toSorted((left, right) =>
left.name.localeCompare(right.name),
),
};
}
46 changes: 46 additions & 0 deletions apps/server/src/wsServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1573,6 +1573,52 @@ describe("WebSocket Server", () => {
});
});

it("supports codex.listCustomPrompts", async () => {
const projectRoot = makeTempDir("t3code-ws-project-prompts-");
const projectPromptsDir = path.join(projectRoot, ".codex", "prompts");
fs.mkdirSync(projectPromptsDir, { recursive: true });
fs.writeFileSync(
path.join(projectPromptsDir, "project-review.md"),
"Project review $FILE",
"utf8",
);

const codexHome = makeTempDir("t3code-ws-codex-prompts-");
const promptsDir = path.join(codexHome, "prompts");
fs.mkdirSync(promptsDir, { recursive: true });
fs.writeFileSync(
path.join(promptsDir, "review.md"),
["---", "description: Review prompt", "---", "Review $FILE"].join("\n"),
"utf8",
);

server = await createTestServer({ cwd: "/test" });
const addr = server.address();
const port = typeof addr === "object" && addr !== null ? addr.port : 0;

const [ws] = await connectAndAwaitWelcome(port);
connections.push(ws);

const response = await sendRequest(ws, WS_METHODS.codexListCustomPrompts, {
homePath: codexHome,
projectPath: projectRoot,
});
expect(response.error).toBeUndefined();
expect(response.result).toEqual({
prompts: [
{
name: "project-review",
content: "Project review $FILE",
},
{
name: "review",
description: "Review prompt",
content: "Review $FILE",
},
],
});
});

it("supports projects.writeFile within the workspace root", async () => {
const workspace = makeTempDir("t3code-ws-write-file-");

Expand Down
Loading