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/init-root-agents-md.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions src/commands/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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" }),
);
Expand Down Expand Up @@ -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();
Expand Down
42 changes: 42 additions & 0 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
173 changes: 173 additions & 0 deletions src/generators/agents/agents.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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");
});
});
Loading
Loading