diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..185b734 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,54 @@ +# AGENTS.md + +The Kontent.ai CLI (`kontent` bin, entry `src/index.ts`): ESM, TypeScript, pnpm. + +## After changing code, before handing off + +``` +pnpm typecheck && pnpm lint && pnpm biome:check && pnpm test +``` + +- Autofix: `pnpm lint:fix`, `pnpm biome:fix`. Build: `pnpm build`. +- Node is `lts`; `.nvmrc` and CI `runtime:` must stay in step. + +## Layers + +Dependencies point downward only: `commands -> core -> lib`. + +- `src/commands/**` yargs wiring and presentation. +- `src/core/**` orchestration of business logic. +- `src/lib/**` reusable primitives. +- Nested `AGENTS.md` files hold the details: `src/commands/`, `src/core/`, `src/lib/`, `test/`, `evals/`. Read the one covering a folder before editing in it. +- Editing `scripts/generateCommandDocs.ts`: the generated-docs rules are in `src/commands/AGENTS.md`. +- Commands obtain API clients and pass them into core; core operations take clients as input. +- Core reports recoverable failures as `Result` and never writes to the console (exception: interactive flows, see core's file). Commands own presentation and exit codes. + +## Auth + +- The `kontent login` token is the credential for both iapi and mapi. A logged-in user needs no API key, so a command only ever resolves the environment id. + +## Output channels + +- stdout: only the command's payload (response body, token, list). `--logLevel none` still prints it. +- stderr: progress, warnings, errors, verbose traces. Never the payload. +- `createLoggerFromArgs` (`src/log.ts`) is the only place resolving `--logLevel`/`--verbose`. + +## Conventions + +- Functional, not OOP. No classes. Branch with `match` (`ts-pattern`), not `switch`. +- Errors are values: `Result` (`src/lib/result.ts`), `Option` (`src/lib/option.ts`); convert thrown errors at boundaries with `tryAsync`/`fromThrowable`. +- Boolean names start with is/has/can/should/was. +- `const` over `let`. +- No `return` on the same line as its condition. +- Prefer `readonly` and `ReadonlyArray`. +- Relative imports end in `.js`. +- No redundant forwarding wrappers. +- Comments only for non-obvious why: no restating code, no justifying changes to the reviewer. +- Exports first, then private helpers in call order, depth-first; a private constant sits directly above its one user. +- No barrel files except a deliberate public API. + +## Telemetry + +- Amplitude-based, see `TELEMETRY.md`. +- Env vars come from `process.env`, never yargs options (`src/index.ts` deliberately omits `.env()`). +- Event names and custom property keys are kebab-case (`cli__some-command`, `error-code`); Amplitude built-in fields keep snake_case. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index b98eb40..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,73 +0,0 @@ -# CLAUDE.md - -Guidance for agents working in this repo. The Kontent.ai CLI (`kontent` bin → `src/index.ts`) is the command-line interface. ESM, TypeScript, pnpm. - -**Keep this file current, concise, and token-aware.** When a change alters a documented architecture/convention/pattern, update CLAUDE.md in the same change — but prune stale or low-value lines rather than letting it grow. Always check before halting whether anything you did makes a statement here stale. - -## Before halting - -Run and pass these (same gate as CI, in order): - -``` -pnpm typecheck && pnpm lint && pnpm biome:check && pnpm test -``` - -Autofix is available: `pnpm lint:fix`, `pnpm biome:fix`. Build with `pnpm build` (tsdown). Node is `lts` — CI pins it through `pnpm/setup`'s `runtime:` input in each workflow, `.nvmrc` covers local `nvm use`; keep the two in step. Always use pnpm, never npm/yarn. - -## Architecture - -Three layers, dependencies point downward only (`commands → core → lib`): - -- `src/index.ts` — composition root. Folds each command's `register` (from `src/commands/registry.ts`) over yargs via `reduce`, wires shared `deps` (telemetry). -- `src/commands/**` — yargs wiring + presentation only. Register the command, call core, format output, log, set `process.exitCode`, fire the telemetry tracker. No business logic. -- `src/core/**` — orchestration of business logic. Returns `Result`/`Option`; never writes to the console directly (logs only through a passed `Logger`). **Exception:** interactive commands may drive their own terminal UI from core — e.g. `src/core/project/bootstrap.ts` uses the prompts of `src/lib/ui/prompts.ts` (spinners, `confirm`/`select`, notes) directly because the flow is inherently interactive. Keep non-interactive core free of direct console writes. -- `src/lib/**` — reusable primitives: `auth/`, `iapi/`, `mapi/`, `config/`, `telemetry/`, plus `result.ts` and `option.ts`. - -Adding a command: export a `register: RegisterCommand` (see `src/commands/login/login.ts`), then add its import to the `register` array in the parent command or `src/commands/registry.ts`. Then run `pnpm docs:generate` (`scripts/generateCommandDocs.ts`) — it replays the registrations against a recording proxy and rewrites the generated docs: the marker-fenced command table in the root `README.md`, and the `` block in each command's `README.md` (created as a skeleton when missing), placed in the leaf's own folder when it has one and in its parent group's folder otherwise. Prose outside the markers is handwritten — write command docs there, never inside the block. Two opt-out sets in the script: `commandsWithoutPage` (no colocated README) and `commandsWithoutIndexEntry` (no root-README table row; telemetry is there). The generator errors on a command-folder README with markers but no matching command (stale after rename/removal) — resolve by hand; it never deletes pages. - -### API clients - -- `iapi` (`src/lib/iapi`) — internal Kontent.ai API; hand-rolled client, one file per endpoint, over `@kontent-ai/core-sdk`. Endpoint validators (the `schema` field) must be **`zod/mini`** (`import * as z from "zod/mini"`) — classic `zod` won't infer the payload. -- `mapi` (`src/lib/mapi`) — public Management API via `@kontent-ai/management-sdk`. `src/lib/mapi/raw` is the deliberate opposite: a passthrough (no schema, no response interpretation) behind `kontent mapi`, where a 4xx/5xx is a result, not an error. It builds on core-sdk's `getDefaultHttpService` and turns the non-2xx it reports as errors back into results, reading the body off `error.details.adapterResponse`; retry, `Retry-After` and header merging are core-sdk's. Its doc comments carry the why: `raw/client.ts` for which SDK error reasons stay errors, `raw/contentType.ts` for the rule that decides whether a body is printed. -- `learn` (`src/lib/learn`) — tokenless `createFetchQuery` client over the Learn-MCP service (`https://learn-mcp.kontent.ai`, no auth, GET only) behind `kontent docs`. The `zod/mini` schemas declare only the fields the CLI reads; core-sdk hands back the raw payload, so unknown keys survive to stdout. `runtimeValidation.validateResponses` must stay on, otherwise the schema never runs. -- `@kontent-ai/core-sdk` — shared HTTP/SDK layer all clients build on. - -**Commands build clients; core receives them.** The command builds the `iapiClient`/`mapiClient` and passes them into core (e.g. `performBootstrap(params, { logger, iapiClient, mapiClient })`); core never constructs clients itself. Auth failure is handled in the command, not surfaced as a core `Result` error. The `kontent login` token is the credential for both `iapi` and `mapi`: a logged-in user needs no separate API key, so only the environment id ever needs resolving. Same split for arguments: pure parsers live in `lib` (`mapi/raw/headers.ts`, `mapi/raw/method.ts`), reading what the invocation points at stays in the command, and each layer declares only the error kinds it raises. - -### Output channels - -- **stdout** — the data the command exists to produce, and nothing else. It is never level-gated: `--logLevel none` must still print a payload, because a response body is not a log. -- **stderr** — everything said *about* producing it: progress, warnings, errors, verbose traces. This is the POSIX meaning of stderr (diagnostics, not errors), and how curl, git and npm behave. - -A reader closing the pipe early (`| head`) is that reader exiting normally, not a write failure: `src/index.ts` swallows `EPIPE` on both streams so it never becomes a stack trace, and leaves `process.exitCode` to the command. - -A handler that logs starts with `const logger = createLoggerFromArgs(args)` (`src/log.ts`) and passes that `Logger` down; one that only emits a payload takes no logger at all (`src/commands/telemetry/status.ts`). -Core takes the logger as a parameter or inside its `deps` object; `createLoggerFromArgs` is the only place that resolves the `--logLevel`/`--verbose` pair; everything else builds a logger from a single `LogLevel` via `createLogger`. The `sink` parameter is a test seam, not a routing knob — never point a log at stdout. - -## Conventions - -- **Functional, not OOP.** No classes. Modules of small, composable, pure functions. Compose with `reduce`, `match` (`ts-pattern`), and the `Result`/`Option` combinators. -- **Errors are values.** Don't throw across layers. Use `Result` (`src/lib/result.ts`) and `Option` (`src/lib/option.ts`); convert thrown errors at the boundary with `tryAsync`/`fromThrowable`. -- **Boolean names** start with a helper verb: is/has/can/should/was (e.g. `isAlreadyAuthenticated`, `shouldForceRefresh`). Applies to params, locals, fields, and props. -- **`const` over `let`** unless reassignment is genuinely required. -- **No `return` on the same line as its condition** — put the guard's body on its own line. -- **Prefer `readonly`** types and `ReadonlyArray` for inputs. -- **ESM import extensions:** relative imports must end in `.js` (biome enforces `useImportExtensions`). -- **No redundant wrappers.** Don't add a function that only forwards to another; reuse existing helpers instead of duplicating logic. -- **Comments only for non-obvious "why".** No restating-the-code comments, no repeating a fact already stated elsewhere (put domain facts once, on the type), no justifying a change to the reviewer ("X already did Y, so..."). No emojis anywhere. -- **Exports first.** A module's exported types and functions go at the top, private helpers below them. Below them, private helpers in call order, depth-first; a private constant sits directly above its one user. Arrow consts are only called after module evaluation, so referring downward is safe. -- **No barrel files** except a deliberate public API. - -## Testing - -Vitest; `test/unit/` for pure unit tests, `test/integration/` for integration tests, `test/helpers/` for shared helpers. Command-level behavior (argument parsing, exit codes, which stream a message lands on) is tested by folding a command's `register` over a real yargs instance and faking only the core call underneath — see `test/integration/mapiCommand.test.ts`. Run `pnpm test`. Inject fakes into core instead of real I/O — for iapi reuse `test/helpers/iapiTestClient.ts` (real client over core-sdk's `HttpAdapter` seam, declarative routes). Harness unit tests live in `evals/test/` (helpers in `evals/test/helpers/`) and run in `pnpm test`; only the paid agent run is opt-in. - -`test/e2e/` runs the built binary against a real Kontent.ai project (clone-per-run from an empty template env). Gated on `E2E_MAPI_KEY`/`E2E_SOURCE_ENV_ID` (fails fast with an error when unset). Run with `pnpm test:e2e` (own `vitest.e2e.config.ts`, loads `.env`); excluded from `pnpm test` and the before-halting gate. CI: `.github/workflows/e2e.yml` (master push, PRs, manual; fork PRs are skipped at the job level — no secret access). - -`evals/` is a Vitest-driven agent-eval harness (`pnpm evals:run`, own `vitest.evals.config.ts`): the Agent SDK drives the built CLI with Bash (confined to a workspace dir) plus WebFetch (`kontent.ai` only, key-in-URL denied), against one cloned environment shared by every task, run sequentially in dependency order (a task whose parent did not PASS is recorded BLOCKED, no agent spawned). The policy is a pure reducer (`evals/lib/policy.ts`) wired into the `PreToolUse` hook by `evals/lib/agent.ts`, the only effectful module; tool calls and denials are derived from the run's raw messages in `evals/lib/toolCalls.ts`; CLI invocations and exit codes come from the shim's per-task log (`evals/lib/invocations.ts`), never from parsing shell text. The agent run never runs in `pnpm test` or CI, and is gated on `EVALS_MAPI_KEY`/`EVALS_SOURCE_ENV_ID` (which the CLI itself must never read). `evals/tasks//task.ts` (prompt + check together) grades the resulting state through the Management SDK; checks stay deterministic: return `Result` (a failed lookup is an `err`, never a failed assertion), and stay tolerant about names the task did not fix. Reports (`evals/lib/report/`, markdown only, no LLM calls) are a nice-to-have removable by deleting that folder and its one call site. Playbook: `evals/README.md`. - -## Telemetry - -Amplitude-based, see `TELEMETRY.md`. Env vars are read from `process.env` where they apply, never mapped onto yargs options — `src/index.ts` deliberately does not call `.env()`, so a stray `KONTENT_*` var cannot break an unrelated command. - -Event names and the custom event-property keys we set are kebab-case (`cli__some-command`, `error-code`, `sample-project-type`); single words stay bare (`outcome`). Amplitude's built-in fields (`device_id`, `user_id`, `platform`, `app_version`, `os_name`, `os_version`) are the exception and keep `snake_case`. diff --git a/evals/AGENTS.md b/evals/AGENTS.md new file mode 100644 index 0000000..4a726b8 --- /dev/null +++ b/evals/AGENTS.md @@ -0,0 +1,9 @@ +# evals + +- Agent-eval harness: Vitest plus the Agent SDK, `pnpm evals:run`, own `vitest.evals.config.ts`. The paid agent run never runs in `pnpm test` or CI; the harness unit tests in `test/` (helpers in `test/helpers/`) do run in `pnpm test`. +- Gated on `EVALS_MAPI_KEY`/`EVALS_SOURCE_ENV_ID`, which the CLI must never read. +- The agent gets Bash confined to a workspace dir, plus WebFetch limited to `kontent.ai` with key-in-URL denied. The policy is a pure reducer in `lib/policy.ts`; `lib/agent.ts` owns Agent SDK execution and wires the policy into the `PreToolUse` hook. +- Tool calls and denials come from the run's raw messages (`lib/toolCalls.ts`); CLI invocations and exit codes from the shim's per-task log (`lib/invocations.ts`), never from parsing shell text. +- One cloned environment shared by every task, run sequentially in dependency order; a task whose parent did not PASS is BLOCKED. +- `tasks//task.ts` holds the prompt and check. Checks are deterministic, return `Result` (a failed lookup is an `err`, never a failed assertion), and stay tolerant about names the task did not fix. +- Reports in `lib/report/` are markdown only, removable by deleting the folder and its call site. Playbook: `README.md`. diff --git a/src/commands/AGENTS.md b/src/commands/AGENTS.md new file mode 100644 index 0000000..393db26 --- /dev/null +++ b/src/commands/AGENTS.md @@ -0,0 +1,18 @@ +# src/commands + +- Yargs wiring and presentation only: register, call core, format output, log, set `process.exitCode`, fire the telemetry tracker. No business logic. +- New command: export `register: RegisterCommand` (see `login/login.ts`), add it to `commandsToRegister` in `registry.ts` or a parent's `subcommandsToRegister`, then run `pnpm docs:generate`. +- Obtain the `iapiClient`/`mapiClient` here and pass them into core. +- Resolving arguments into real inputs (reading the `--input` file, stdin) lives here. +- A handler that logs starts with `const logger = createLoggerFromArgs(args)`; a payload-only handler takes none. + +## Generated docs + +`pnpm docs:generate` runs `scripts/generateCommandDocs.ts`. + +- Rewrites the marker-fenced table in root `README.md` and the `` block in each command README. Prose outside the markers is handwritten, never write inside them. +- A leaf's README lands in its own folder, or the parent group's folder when it has none. +- Two opt-out sets at the top of the script, both keyed by the top-level command name and independent of each other: + - `commandsWithoutPage`: the command gets no README of its own. + - `commandsWithoutIndexEntry`: the command gets no row in the root `README.md` table. +- After a command is renamed or removed, its old README is left behind. The generator fails on it and never deletes it: move the handwritten prose to the new README, then delete the old file yourself. diff --git a/src/core/AGENTS.md b/src/core/AGENTS.md new file mode 100644 index 0000000..6d24ad4 --- /dev/null +++ b/src/core/AGENTS.md @@ -0,0 +1,8 @@ +# src/core + +- Orchestration of business logic. Use `Result` for recoverable failures and `Option` for optional values. Commands own presentation and exit codes. +- Operations receive `iapiClient`/`mapiClient` from the command, e.g. `performBootstrap(params, { logger, iapiClient, mapiClient })`. Core builds a client only where a fresh token first becomes one: `login/login.ts` and `iapi/authenticatedClient.ts`. +- Never writes to stdout or the console. The payload is returned as a value and the command prints it. +- Diagnostics go only through the passed `Logger` (a parameter, or inside `deps`), which writes to stderr. +- Pick the level per message: `logger.info("standard", ...)` for progress the user should see, `"verbose"` for traces. `logger.error(...)` takes no level. +- Exception: an inherently interactive flow may drive its own terminal UI, e.g. `project/bootstrap.ts`. Prompts, spinners and notes must go through the wrappers in `src/lib/ui/prompts.ts`: they force stderr, while clack on its own writes to stdout and breaks the output contract. Everything else stays free of direct writes. diff --git a/src/lib/AGENTS.md b/src/lib/AGENTS.md new file mode 100644 index 0000000..dddcfe0 --- /dev/null +++ b/src/lib/AGENTS.md @@ -0,0 +1,7 @@ +# src/lib + +- `iapi`: internal API, hand-rolled over `@kontent-ai/core-sdk`, one file per endpoint. `schema` validators must be `zod/mini`; classic zod cannot infer the payload. +- `mapi`: Management API via `@kontent-ai/management-sdk`. `mapi/raw` is a passthrough behind `kontent mapi`: no schema, 4xx/5xx is a result, not an error. Why: doc comments in `raw/client.ts`, `raw/contentType.ts`. +- `learn`: tokenless Learn-MCP client behind `kontent docs`. Schemas declare only the fields read, so unknown keys survive to stdout; `runtimeValidation.validateResponses` must stay on. +- `config`: `cliConfig.ts` is the persisted CLI state (telemetry consent, ids); `kontentUrl.ts` allowlists the domains the CLI may talk to, never bypass it. +- `telemetry`: see `TELEMETRY.md`. diff --git a/test/AGENTS.md b/test/AGENTS.md new file mode 100644 index 0000000..d09379c --- /dev/null +++ b/test/AGENTS.md @@ -0,0 +1,7 @@ +# test + +- Vitest. `unit/` pure unit tests, `integration/` integration tests, `helpers/` shared helpers, `e2e/` the built binary against a real project. +- Command-level behavior (argument parsing, exit codes, which stream a message hits): fold a command's `register` over a real yargs instance and fake only the core call; see `integration/mapiCommand.test.ts`. +- Inject fakes into core instead of real I/O; for iapi reuse `helpers/iapiTestClient.ts`. +- The evals harness has its own unit tests in `evals/test/`; they run in `pnpm test` too. +- e2e: clone-per-run from an empty template env, gated on `E2E_MAPI_KEY`/`E2E_SOURCE_ENV_ID`, fails fast when unset. `pnpm test:e2e` (own `vitest.e2e.config.ts`, loads `.env`); excluded from `pnpm test` and the gate. CI `.github/workflows/e2e.yml`, fork PRs skipped.