diff --git a/.changeset/init-root-agents-md.md b/.changeset/init-root-agents-md.md new file mode 100644 index 0000000..d3dd738 --- /dev/null +++ b/.changeset/init-root-agents-md.md @@ -0,0 +1,5 @@ +--- +"seamless-cli": minor +--- + +`seamless init` now writes a root `AGENTS.md` and a `CLAUDE.md` (`See @AGENTS.md`) beside `docker-compose.yml` and `seamless.config.json`. The guide is built from the choices init made: the directory and template each starter came from (linking its own `AGENTS.md`), how the auth server runs, the admin console, the ports, how to start the stack, the owner email that becomes the first admin, and the rules for agents (use the SDK and adapter, never your own JWT, password, session cookie or login endpoint code), plus a link to https://docs.seamlessauth.com/llms.txt. diff --git a/AGENTS.md b/AGENTS.md index a8c921c..56c30ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,7 +70,7 @@ The entry point is [src/index.ts](src/index.ts), which dispatches to a command m `fells-code/seamless-templates` monorepo (pinned by `SEAMLESS_TEMPLATES_COMMIT`, the commit `SEAMLESS_TEMPLATES_REF` resolved to, in [src/core/images.ts](src/core/images.ts)), downloads the selected templates, and applies each - template's `template.json` env contract. The auth, docker, and config pieces are still generated + template's `template.json` env contract. The auth, docker, config, and root agent guide (`AGENTS.md`, `CLAUDE.md`) pieces are generated locally in `src/generators/*`. Override the template source for development with `SEAMLESS_TEMPLATES_DIR` (a local checkout) or `SEAMLESS_TEMPLATES_REF` (a different ref, used as given). diff --git a/README.md b/README.md index 3460e45..a089777 100644 --- a/README.md +++ b/README.md @@ -256,11 +256,19 @@ my-app/ ├─ mobile/ # Expo mobile app (--mobile only) ├─ admin/ # Admin console source (--admin=source only) ├─ docker-compose.yml # not written for a managed project -└─ seamless.config.json +├─ seamless.config.json +├─ AGENTS.md # guide for coding agents, written from your choices +└─ CLAUDE.md # "See @AGENTS.md", so Claude Code loads it ``` All services are preconfigured to work together. +The root `AGENTS.md` describes this project as init built it: which directory came from which +template (linking each starter's own `AGENTS.md`), the ports and admin console, how to start the +stack, who the first admin is, and the rules an agent should follow (use the SDK and adapter, never +hand-rolled JWT, password, session cookie or login code). Like the other root files, it replaces +one already there when you scaffold into a non-empty directory with `--force`. + - Web calls the API - API communicates with the auth server - Auth manages sessions and tokens diff --git a/src/commands/init.test.ts b/src/commands/init.test.ts index e9c1e4e..409a255 100644 --- a/src/commands/init.test.ts +++ b/src/commands/init.test.ts @@ -11,6 +11,7 @@ import { generateAuthServer } from "../generators/auth/auth.js"; import { generateDockerCompose } from "../generators/docker/docker.js"; import { generateAdminSource } from "../generators/admin/admin.js"; import { generateSeamlessConfig } from "../generators/config/config.js"; +import { generateAgentsFiles } from "../generators/agents/agents.js"; import { printManagedSuccessOutput, printSuccessOutput, @@ -88,6 +89,9 @@ vi.mock("../generators/admin/admin.js", () => ({ vi.mock("../generators/config/config.js", () => ({ generateSeamlessConfig: vi.fn(), })); +vi.mock("../generators/agents/agents.js", () => ({ + generateAgentsFiles: vi.fn(), +})); vi.mock("../core/output.js", () => ({ printManagedSuccessOutput: vi.fn(), printSuccessOutput: vi.fn(), @@ -545,6 +549,34 @@ describe("scaffoldLocal", () => { "/work", expect.objectContaining({ mobileFramework: "expo" }), ); + expect(generateAgentsFiles).toHaveBeenCalledWith("/work", { + projectName: "work", + web: { + id: "web-basic", + label: "Vue Basic", + framework: "vue", + dir: "web", + hasGuide: false, + fullStack: false, + }, + api: { + id: "api-express", + label: "Express", + framework: "express", + dir: "api", + hasGuide: false, + }, + mobile: { + id: "expo", + label: "Expo", + framework: "expo", + dir: "mobile", + hasGuide: false, + }, + authMode: "docker", + adminMode: "api", + ownerEmail: "dev@example.com", + }); expect(printSuccessOutput).toHaveBeenCalledWith( expect.objectContaining({ mobileFramework: "expo" }), ); @@ -986,6 +1018,14 @@ describe("scaffoldManaged", () => { }), }), ); + expect(generateAgentsFiles).toHaveBeenCalledWith( + "/work", + expect.objectContaining({ + authMode: "managed", + api: expect.objectContaining({ dir: "api" }), + managed: { instanceUrl: expect.any(String), applicationName: "Acme" }, + }), + ); expect(printManagedSuccessOutput).toHaveBeenCalled(); // No confirm needed when the app has no existing token. expect(confirm).not.toHaveBeenCalled(); diff --git a/src/commands/init.ts b/src/commands/init.ts index 1970733..58d5308 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -22,6 +22,10 @@ import { printSuccessOutput, } from "../core/output.js"; import { generateSeamlessConfig } from "../generators/config/config.js"; +import { + generateAgentsFiles, + type AgentsTemplate, +} from "../generators/agents/agents.js"; import { applyTemplateEnv, assertCliSupports, @@ -473,6 +477,14 @@ async function scaffoldManaged( }, }); + generateAgentsFiles(root, { + projectName: projectName || path.basename(root), + ...agentsTemplates(selected, webEntry), + authMode: "managed", + adminMode: "image", + managed: { instanceUrl: authServerUrl, applicationName: app.name }, + }); + printManagedSuccessOutput({ projectName, webFramework: webEntry.framework, @@ -625,6 +637,14 @@ async function scaffoldLocal( adminMode: answers.adminMode, }); + generateAgentsFiles(root, { + projectName: projectName || path.basename(root), + ...agentsTemplates(selected, webEntry), + authMode: answers.authMode, + adminMode: answers.adminMode, + ownerEmail: answers.ownerEmail, + }); + printSuccessOutput({ projectName, root, @@ -764,6 +784,28 @@ async function resolveSelectedTemplates( return selected; } +// Each chosen template as the root AGENTS.md describes it, by layer and by the +// directory its manifest placed it in. +function agentsTemplates(selected: SelectedTemplate[], webEntry: RegistryEntry) { + const byLayer = (layer: "web" | "api" | "mobile"): AgentsTemplate | undefined => { + const s = selected.find((t) => layerOf(t.entry.kind) === layer); + return s + ? { + id: s.entry.id, + label: s.entry.label, + framework: s.entry.framework, + dir: s.manifest.targetDir, + hasGuide: fs.existsSync(path.join(s.dir, "AGENTS.md")), + } + : undefined; + }; + return { + web: { ...byLayer("web")!, fullStack: isFullStack(webEntry) }, + api: byLayer("api"), + mobile: byLayer("mobile"), + }; +} + function findEntry(templates: RegistryEntry[], id: string): RegistryEntry { const entry = templates.find((t) => t.id === id); if (!entry) { diff --git a/src/generators/agents/agents.test.ts b/src/generators/agents/agents.test.ts new file mode 100644 index 0000000..4bf9ba1 --- /dev/null +++ b/src/generators/agents/agents.test.ts @@ -0,0 +1,173 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + buildAgentsMd, + CLAUDE_MD, + generateAgentsFiles, + LLMS_TXT_URL, + type AgentsDocOptions, +} from "./agents.js"; + +const REACT = { id: "react-vite", label: "React (Vite) - basic example", framework: "react", dir: "web" }; +const EXPRESS = { id: "express", label: "Express", framework: "express", dir: "api" }; +const NEXTJS = { id: "nextjs", label: "Next.js (App Router) - full-stack example", framework: "nextjs", dir: "web" }; +const EXPO = { id: "expo", label: "Expo (React Native) - mobile example", framework: "expo", dir: "mobile" }; + +function webApiDocker(over: Partial = {}): AgentsDocOptions { + return { + projectName: "my-app", + web: REACT, + api: EXPRESS, + authMode: "docker", + adminMode: "api", + ownerEmail: "you@example.com", + ...over, + }; +} + +// Every guide shares these, whatever init chose. +function expectCommon(doc: string) { + expect(doc).toContain(LLMS_TXT_URL); + expect(doc).toContain( + "Do not write your own JWT, password, session cookie or login endpoint code.", + ); + expect(doc).toContain("`seamless check`"); + expect(doc).not.toContain("\u2014"); + expect(doc.split("\n").length).toBeLessThanOrEqual(70); +} + +describe("buildAgentsMd", () => { + it("describes a web and api project on the docker auth server", () => { + const doc = buildAgentsMd(webApiDocker()); + expectCommon(doc); + + expect(doc).toMatch(/^# Agent guide: my-app\n/); + expect(doc).toContain("`react-vite` template"); + expect(doc).toContain("[web/AGENTS.md](web/AGENTS.md)"); + expect(doc).toContain("`express` template"); + expect(doc).toContain("[api/AGENTS.md](api/AGENTS.md)"); + expect(doc).not.toContain("mobile/AGENTS.md"); + expect(doc).toContain("- API: http://localhost:3000"); + expect(doc).toContain("the Seamless Auth Docker image"); + expect(doc).toContain("- Postgres: localhost:5432"); + expect(doc).toContain( + "- Admin console: http://localhost:3000/console, served by the API", + ); + expect(doc).toContain("`docker compose up`"); + expect(doc).toContain("`you@example.com` is the owner"); + expect(doc).toContain("the `auth` service of `docker-compose.yml`"); + expect(doc).toContain("Use the SDK in `web/` and the adapter in `api/`."); + expect(doc).not.toContain("`auth/`"); + }); + + it("describes a full-stack Next.js project with no api", () => { + const doc = buildAgentsMd( + webApiDocker({ web: { ...NEXTJS, fullStack: true }, api: undefined }), + ); + expectCommon(doc); + + expect(doc).toContain( + 'its own backend (serves `/auth`), from the `nextjs` template, "Next.js (App Router) - full-stack example".', + ); + expect(doc).not.toContain("api/"); + expect(doc).not.toContain("- API:"); + expect(doc).toContain( + "- Admin console: http://localhost:5173/console, served by the web app", + ); + expect(doc).toContain("`docker compose logs web`"); + expect(doc).toContain("Use the SDK in `web/`: it serves `/auth`"); + }); + + it("links the mobile guide and its run command when a mobile app is chosen", () => { + const doc = buildAgentsMd(webApiDocker({ mobile: EXPO })); + expectCommon(doc); + + expect(doc).toContain("`mobile/`: the mobile app, from the `expo` template"); + expect(doc).toContain("[mobile/AGENTS.md](mobile/AGENTS.md)"); + expect(doc).toContain("`cd mobile && npm install && npx expo start`"); + expect(doc).toContain("http://10.0.2.2:3000"); + }); + + it("describes the auth server run from source in local mode", () => { + const doc = buildAgentsMd( + webApiDocker({ authMode: "local", adminMode: "source" }), + ); + expectCommon(doc); + + expect(doc).toContain("- `auth/`: a clone of the Seamless Auth server source"); + expect(doc).toContain("`auth/Dockerfile.dev`"); + expect(doc).toContain("built from `auth/`"); + expect(doc).not.toContain("Docker image (compose service `auth`)"); + expect(doc).toContain("change `OWNER_EMAIL` in `auth/.env`"); + expect(doc).toContain("- `admin/`: the admin console source"); + expect(doc).toContain( + "- Admin console: http://localhost:5174 (compose service `admin`, built from `admin/`)", + ); + }); + + it("links a template's README when its release shipped no AGENTS.md", () => { + const doc = buildAgentsMd( + webApiDocker({ web: { ...REACT, hasGuide: false } }), + ); + expect(doc).toContain("Read [web/README.md](web/README.md)"); + expect(doc).not.toContain("web/AGENTS.md"); + expect(doc).toContain("[api/AGENTS.md](api/AGENTS.md)"); + }); + + it("leaves the console out when admin is none", () => { + const doc = buildAgentsMd(webApiDocker({ adminMode: "none" })); + expect(doc).not.toContain("Admin console"); + }); + + it("points a managed project at its instance with no compose stack", () => { + const doc = buildAgentsMd( + webApiDocker({ + authMode: "managed", + adminMode: "image", + ownerEmail: undefined, + api: { ...EXPRESS, id: "gin", framework: "gin" }, + managed: { + instanceUrl: "https://acme.seamlessauth.com", + applicationName: "Acme", + }, + }), + ); + expectCommon(doc); + + expect(doc).toContain('https://acme.seamlessauth.com (managed application "Acme")'); + expect(doc).not.toContain("docker compose"); + expect(doc).not.toContain("docker-compose.yml"); + expect(doc).not.toContain("Postgres"); + expect(doc).not.toContain("## First admin"); + expect(doc).toContain("`cd api && go run .`"); + }); +}); + +describe("generateAgentsFiles", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "seamless-agents-test-")); + vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it("writes AGENTS.md and a CLAUDE.md that loads it, replacing existing ones", () => { + fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), "old"); + generateAgentsFiles(tmpDir, webApiDocker()); + + expect(fs.readFileSync(path.join(tmpDir, "AGENTS.md"), "utf-8")).toBe( + buildAgentsMd(webApiDocker()), + ); + expect(fs.readFileSync(path.join(tmpDir, "CLAUDE.md"), "utf-8")).toBe( + "See @AGENTS.md\n", + ); + expect(CLAUDE_MD).toBe("See @AGENTS.md\n"); + }); +}); diff --git a/src/generators/agents/agents.ts b/src/generators/agents/agents.ts new file mode 100644 index 0000000..98912ef --- /dev/null +++ b/src/generators/agents/agents.ts @@ -0,0 +1,168 @@ +import fs from "fs"; +import path from "path"; +import { apiDevCommand } from "../../core/output.js"; +import type { AdminMode } from "../docker/docker.js"; + +export const LLMS_TXT_URL = "https://docs.seamlessauth.com/llms.txt"; + +// Claude Code reads CLAUDE.md, and the `@` form makes it load the referenced +// file rather than just mention it, so AGENTS.md stays the one source. +export const CLAUDE_MD = "See @AGENTS.md\n"; + +export interface AgentsTemplate { + id: string; + label: string; + framework: string; + // Relative to the project root, as the template manifest's targetDir. + dir: string; + // Whether the template shipped its own AGENTS.md. Templates releases before + // fells-code/seamless-templates#115 did not, and a link to nothing misleads. + hasGuide?: boolean; +} + +export interface AgentsDocOptions { + projectName: string; + web: AgentsTemplate & { fullStack?: boolean }; + api?: AgentsTemplate; + mobile?: AgentsTemplate; + authMode: "docker" | "local" | "managed"; + adminMode: AdminMode; + ownerEmail?: string; + managed?: { instanceUrl: string; applicationName: string }; +} + +// The root AGENTS.md for a scaffolded project. Each template directory ships +// its own guide; this one says how they fit together, using what init chose. +export function buildAgentsMd(opts: AgentsDocOptions): string { + const { web, api, mobile, authMode, adminMode } = opts; + const managed = authMode === "managed"; + const fullStack = Boolean(web.fullStack); + const backend = fullStack ? web : api; + const backendUrl = fullStack ? "http://localhost:5173" : "http://localhost:3000"; + const lines: string[] = []; + const add = (...l: string[]) => lines.push(...l); + + add( + `# Agent guide: ${opts.projectName}`, + "", + "Scaffolded by `seamless init`. Sign-in, sessions and roles come from", + "[Seamless Auth](https://docs.seamlessauth.com), open source passwordless auth (passkeys, email", + "or SMS codes, magic links). Auth is already wired end to end. Your job when extending this", + "project is to keep using it, not to rebuild it.", + "", + "## Layout", + "", + ); + const layer = (t: AgentsTemplate, role: string) => { + const guide = t.hasGuide === false ? "README.md" : "AGENTS.md"; + add( + `- \`${t.dir}/\`: ${role}, from the \`${t.id}\` template, "${t.label}".`, + ` Read [${t.dir}/${guide}](${t.dir}/${guide}) before changing it.`, + ); + }; + layer(web, fullStack ? "the web app and its own backend (serves `/auth`)" : "the web app"); + if (api) layer(api, "the API, the web app's backend"); + if (mobile) layer(mobile, "the mobile app"); + if (authMode === "local") { + add( + "- `auth/`: a clone of the Seamless Auth server source, run in development mode by compose", + " (`auth/Dockerfile.dev`, source mounted). Its settings are in `auth/.env`.", + ); + } + if (adminMode === "source") { + add("- `admin/`: the admin console source, built by compose. Yours to edit."); + } + if (!managed) { + add("- `docker-compose.yml`: the whole local stack. Ports are bound to 127.0.0.1 only."); + } + add("- `seamless.config.json`: the choices init made. `seamless check` reads it.", "", "## Services", ""); + + add(`- Web: http://localhost:5173`); + if (api) add(`- API: http://localhost:3000 (forwards \`/auth/*\` to the auth server)`); + if (managed) { + add( + `- Auth server: ${opts.managed?.instanceUrl} (managed application "${opts.managed?.applicationName}").`, + " Users, roles and OAuth providers are managed from the dashboard.", + ); + } else { + add( + authMode === "local" + ? "- Auth server: http://localhost:5312, built from `auth/` (compose service `auth`)" + : "- Auth server: http://localhost:5312, the Seamless Auth Docker image (compose service `auth`)", + " (containers reach it as http://auth:5312, which is also its token issuer)", + "- Postgres: localhost:5432 (compose service `db`)", + ); + const consoleLine: Record = { + api: `- Admin console: ${backendUrl}/console, served by ${fullStack ? "the web app" : "the API"}`, + image: "- Admin console: http://localhost:5174 (compose service `admin`, Docker image)", + source: "- Admin console: http://localhost:5174 (compose service `admin`, built from `admin/`)", + none: null, + }; + const line = consoleLine[adminMode]; + if (line) add(line); + } + + add("", "## Run", ""); + if (managed) { + if (api) add(`- API: \`${apiDevCommand(api.framework)}\``); + add("- Web: `cd web && npm install && npm run dev`"); + } else { + add("- `docker compose up` from this directory starts everything above."); + if (fullStack) { + add("- One-time codes and sign-in links print in `docker compose logs web`."); + } + } + if (mobile) { + add( + `- Mobile: \`cd ${mobile.dir} && npm install && npx expo start\`. An Android emulator reaches the`, + " API at http://10.0.2.2:3000. Passkeys need an associated domain; see its README.", + ); + } + add( + "- `seamless check` validates the project, Docker and the running services (`--strict` exits", + " non-zero on failure). `seamless --help` lists every other command.", + ); + + if (!managed && opts.ownerEmail) { + const where = + authMode === "local" ? "`auth/.env`" : "the `auth` service of `docker-compose.yml`"; + add( + "", + "## First admin", + "", + `\`${opts.ownerEmail}\` is the owner: registering with it grants the admin role. The grant`, + `happens at signup, so change \`OWNER_EMAIL\` in ${where}`, + "before registering if it is wrong.", + ); + } + + add( + "", + "## Rules", + "", + "- Do not write your own JWT, password, session cookie or login endpoint code. Seamless Auth", + " is passwordless and already issues and checks sessions.", + fullStack + ? `- Use the SDK in \`${web.dir}/\`: it serves \`/auth\` and resolves sessions on the server.` + : `- Use the SDK in \`${web.dir}/\` and the adapter in \`${backend?.dir ?? "api"}/\`. The browser never calls the auth server.`, + "- Guard every protected route on the server; client-side checks are for display only.", + managed + ? "- The `.env` files hold a live service token. Keep them out of version control." + : "- The secrets in `.env` files and `docker-compose.yml` are for local development only.", + "", + "## Docs", + "", + "- https://docs.seamlessauth.com", + `- Everything, for agents: ${LLMS_TXT_URL}`, + ); + + return lines.join("\n") + "\n"; +} + +// Written unconditionally, like the compose file and seamless.config.json: init +// only scaffolds into a non-empty directory under --force or an explicit choice. +export function generateAgentsFiles(root: string, opts: AgentsDocOptions) { + fs.writeFileSync(path.join(root, "AGENTS.md"), buildAgentsMd(opts)); + fs.writeFileSync(path.join(root, "CLAUDE.md"), CLAUDE_MD); + console.log("Agent guides created."); +}