diff --git a/README.md b/README.md index c2b0c01..5fdc2dc 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,8 @@ norbix scheduler list | `norbix hub ` | Call ANY hub endpoint with plain words (see below) | | `norbix api ` | Same for the data-plane API | | `norbix raw ` | Low-level HTTP escape hatch (hub by default, `--api` for API) | +| `norbix schema [command]` | Describe every command as data (`--json`) — for scripts and agents | +| `norbix ai init` | Set up a project for Claude Code / Cursor / AGENTS.md agents | ### Plain-word access to every endpoint @@ -178,6 +180,23 @@ nothing** unless you pass `--yes`. Preview any of them with `--dry-run`: the request is built with your real auth, region and project, printed, and not sent. +## Use with Claude Code / AI agents + +The CLI is built to be driven by a coding agent from the shell — no MCP +server needed: one JSON document per call (`--json`), documented exit codes +(0–9), nothing destructive without `--yes`, `--dry-run` on every change, and +`norbix schema --json` to discover every command. Set a project up once: + +```sh +norbix ai init # writes .claude/skills/norbix/SKILL.md + a CLAUDE.md block +norbix ai init --target all # also AGENTS.md (Codex, OpenCode) and a Cursor rule +``` + +Then give the agent credentials with a profile (`norbix login --api-key … --profile ci`) +or `NORBIX_API_KEY` / `NORBIX_PROJECT_ID` / `NORBIX_REGION`. The recipe the +agent follows is in [docs/AGENTS.md](docs/AGENTS.md); the guarantees it +relies on are in [docs/agent-contract.md](docs/agent-contract.md). + ## Versioning The major version is frozen at **v1** until the public launch. diff --git a/bin/run.js b/bin/run.js index 7202228..a2b8746 100644 --- a/bin/run.js +++ b/bin/run.js @@ -1,4 +1,13 @@ #!/usr/bin/env node -import {execute} from '@oclif/core' -await execute({dir: import.meta.url}) +// Fast path: `norbix --version` answers from package.json without loading +// oclif (the agent contract asks for < 200 ms; oclif alone costs more on a +// slow machine). Same string oclif prints: name/version platform-arch node-v. +if (process.argv.length === 3 && process.argv[2] === '--version') { + const {readFileSync} = await import('node:fs') + const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) + process.stdout.write(`${pkg.name}/${pkg.version} ${process.platform}-${process.arch} node-${process.version}\n`) +} else { + const {execute} = await import('@oclif/core') + await execute({dir: import.meta.url}) +} diff --git a/docs/AGENTS.md b/docs/AGENTS.md new file mode 100644 index 0000000..15a3196 --- /dev/null +++ b/docs/AGENTS.md @@ -0,0 +1,95 @@ +# Using the Norbix CLI from a coding agent + +The recipe a coding agent (Claude Code, Cursor, Codex, OpenCode) follows to +work on a Norbix backend from the shell. `norbix ai init` writes a short +version of this into your project (a Claude Code skill, a `CLAUDE.md` / +`AGENTS.md` block, a Cursor rule). The guarantees behind it are in +[`agent-contract.md`](agent-contract.md). + +## 0. Install + +```sh +npm install -g @norbix.ai/cli # or: npx @norbix.ai/cli … +norbix --version +``` + +## 1. Authenticate without a terminal + +Agents cannot answer prompts, so never run `norbix login` or +`norbix configure` interactively. Give the agent one of: + +- a **profile** created once by a human: `norbix login --api-key nbk_… --project --region nb-eu-germany --profile ci`, then every call takes `--profile ci`; +- **environment variables**: `NORBIX_API_KEY`, `NORBIX_PROJECT_ID`, `NORBIX_REGION` (and `NORBIX_ENV`, `NORBIX_ACCOUNT_ID` when needed). + +First call of a session: `norbix whoami --json`. Exit 4 means no usable +credentials — stop and ask. + +## 2. Always `--json` + +Stdout is then exactly one JSON document: the result, or +`{"error": {code, message, status, exit, fieldErrors, url, traceId, hint, docs}}`. +Nothing else is printed on stdout; stderr carries progress and warnings. + +## 3. Discover, do not guess + +```sh +norbix schema --json # every command: args, flags, examples, destructive, supportsDryRun +norbix schema users delete --json # one command +norbix users delete --help # the human page, with a realistic example +norbix hub scheduler --json # SDK methods of a module with their request fields +norbix hub scheduler task delete --help +``` + +Prefer the named commands (`db`, `users`, `files`, `email`, `sms`, `push`, +`scheduler`, `logs`, `env`, `apikeys`, `webhooks`, `payments`, `account`). +When there is none, every SDK endpoint is reachable with plain words: + +```sh +norbix hub scheduler task delete 66b2f0a1c3d4e5f6a7b8c9d0 --yes --json +norbix hub scheduler task save --body '{"name":"nightly","cronExpression":"0 2 * * *"}' --json +``` + +`--body` takes the whole request as JSON (nothing is merged with +`--field` flags); `--field:str value` forces a string when a value looks +like a number. + +## 4. Change things in two steps + +```sh +norbix db update orders --id 66b2f0a1c3d4e5f6a7b8c9d0 --update '{"$set":{"status":"shipped"}}' --dry-run --json +# inspect "http" and "request" … +norbix db update orders --id 66b2f0a1c3d4e5f6a7b8c9d0 --update '{"$set":{"status":"shipped"}}' --json +``` + +A **destructive** command (`destructive: true` in the schema) additionally +needs `--yes`; without it, and without a terminal, it exits 3 and sends +nothing. Never add `--yes` to a first attempt — dry-run first. + +## 5. Branch on the exit code + +| exit | meaning | next step | +|---|---|---| +| 0 | ok | | +| 2 | usage error | read `error.message`, fix the call (`--help`) | +| 3 | confirmation required | re-run with `--yes` after a dry run | +| 4 | not authenticated / forbidden | ask for credentials or another profile | +| 5 | not found | check the id, `--env`, `--project`; list first | +| 6 | validation | `error.fieldErrors` names the fields | +| 7 | network | check `--region`, endpoints, connectivity | +| 8 | server / rate limit | retry with backoff; keep `error.traceId` | +| 9 | cancelled | | +| 1 | internal | report with `error.traceId` | + +`error.hint` says what to do; `error.docs` says where to read. + +## 6. Set up a project once + +```sh +cd my-project +norbix ai init # Claude Code skill + CLAUDE.md block +norbix ai init --target all # also AGENTS.md and a Cursor rule +norbix ai init --dry-run # see what it would write +``` + +Files are never overwritten without `--force`; an existing block is left +alone. diff --git a/docs/agent-contract.md b/docs/agent-contract.md new file mode 100644 index 0000000..1efcbbe --- /dev/null +++ b/docs/agent-contract.md @@ -0,0 +1,139 @@ +# The agent contract + +What a script or a coding agent (Claude Code, Cursor, Codex, OpenCode) can +rely on when it runs `norbix` from a shell. Read it once; every error the CLI +prints points back here through its `hint` and `docs` fields. +`norbix schema --json` names this file too. + +## 1. The rules + +1. **Never hang, never act silently.** In a non-interactive shell (stdout or + stdin is not a terminal, or `CI` is set, or `--json` is passed) a command + that needs a confirmation and did not get `--yes` exits **3** with one + line on stderr that names `--yes`. It sends nothing. In a terminal + without `--yes` it asks, as before. +2. **`--json` means stdout is exactly one JSON document, always.** Success: + the result. Failure: `{"error": {...}}` (section 3). No text on stdout in + JSON mode, ever. Progress and warnings go to stderr. The exit code is the + same with and without `--json`. +3. **Errors carry everything the SDK knows** — `status`, `code`, + `fieldErrors`, `url`, `traceId` — plus a `hint` (what to do next) and + `docs` (`norbix --help` or a URL). +4. **Documented exit codes** (section 2). Branch on them. +5. **`--dry-run` on every mutating command.** It resolves auth, region and + project exactly as a real call would (so a dry run that would fail for + real fails here too), prints the request that would be sent, sends + nothing, exits 0. +6. **Discoverable without reading source.** `norbix schema --json` returns + the whole command surface (args, flags, types, required, env var, + default, examples, `destructive`, `supportsDryRun`); `norbix schema + --json` one command. `--help` on every command has an example + with real-looking values and, for mutating commands, the `--dry-run` / + `--yes` pattern. `norbix hub --json` lists the SDK methods with + their request fields. +7. **Cheap to run.** `norbix --version` answers in well under 200 ms; + prompts and other heavy modules load only when used. +8. **No ANSI codes** with `--json`, with `NO_COLOR` set, or when stdout is + not a terminal. + +## 2. Exit codes + +| code | meaning | what to do | +|---|---|---| +| 0 | success | | +| 1 | unexpected / internal error | file an issue, include `traceId` | +| 2 | usage: bad flags or args, invalid JSON input, unknown command, missing config | `norbix --help` / `norbix schema --json` | +| 3 | confirmation required (non-interactive shell, no `--yes`) | re-run with `--yes` after a `--dry-run` | +| 4 | not authenticated / auth rejected (401, 403, no key) | `norbix login`, `--api-key`, `--profile`, `NORBIX_API_KEY` | +| 5 | not found (404) | check the id, `--env`, `--project` | +| 6 | validation rejected by the server (400 / 422, or a 200 with `isSuccess: false`) | `fieldErrors` lists the fields | +| 7 | network / timeout / endpoint unreachable | the error shows the URL; check `--region`, `api_url` / `hub_url`, connectivity | +| 8 | rate limited / server error (429, 5xx) | retry with backoff; include `traceId` | +| 9 | cancelled by the user at a prompt | | + +Exit codes are identical with and without `--json`. The table is also in +`norbix schema --json` under `exitCodes`, and in code at +`src/lib/exit-codes.ts`. + +## 3. The JSON error envelope + +One document on stdout, nothing on stderr: + +```json +{ + "error": { + "code": "USER_NOT_FOUND", + "message": "User not found", + "status": 404, + "exit": 5, + "fieldErrors": {"email": ["is required"]}, + "url": "https://nb-eu-germany.api.norbix.ai/v2/membership/auth/66b2f0a1c3d4e5f6a7b8c9d0", + "traceId": "7f3c…", + "hint": "Check the id, the --env and the --project. List items first (e.g. `norbix users list --json`).", + "docs": "norbix users get --help" + } +} +``` + +- `code` — the gateway's or the SDK's own code when there is one + (`CM-ERRORS-FILES-016`, `NORBIX_NETWORK_ERROR`), else derived from the + category (`NOT_FOUND`, `USAGE_ERROR`, `CONFIRMATION_REQUIRED`, + `UNKNOWN_COMMAND`, `CANCELLED`, `INTERNAL_ERROR`). +- `status` — HTTP status, only when the server answered. +- `exit` — the process exit code (section 2). +- `fieldErrors` — only when the server named fields: `{field: [messages]}`. +- `url` — the URL that was called, when known (network errors always have it). +- `traceId` — when the server returned one (`traceId`, `correlationId`, + or `responseStatus.meta.correlationId`). +- `hint`, `docs` — always present. + +Without `--json` the same information goes to **stderr** as text: + +``` +Error: User not found + code: USER_NOT_FOUND + status: 404 + url: https://…/v2/membership/auth/66b2f0a1c3d4e5f6a7b8c9d0 + traceId: 7f3c… +Hint: Check the id, the --env and the --project. … +Docs: norbix users get --help +``` + +## 4. What a dry run prints + +```json +{ + "dryRun": true, + "method": "api.membership.deleteUser", + "request": {"id": "66b2f0a1c3d4e5f6a7b8c9d0"}, + "http": { + "method": "DELETE", + "url": "https://nb-eu-germany.api.norbix.ai/v2/membership/auth?id=66b2f0a1c3d4e5f6a7b8c9d0", + "headers": {"accept": "application/json", "authorization": "Bearer ***", "norbix-project-id": "…"}, + "body": null + } +} +``` + +`method` is the SDK call (or a local action such as `config.set`); `http` is +the request as it would leave the machine, with the token redacted. A +command that makes several calls (`files upload`) shows the first. + +## 5. Which commands confirm, which dry-run + +- **Destructive** (`destructive: true` in the schema; take `--yes` and + `--dry-run`): `delete`, `stop`, `regenerate`, `rotate`, `block`, + `disable`, `remove`, `db update --many`, `db delete`, and any `hub` / + `api` method whose verb is one of those. +- **Mutating** (`supportsDryRun: true`, no confirmation): `create`, + `insert`, `update --id`, `replace`, `archive`, `unarchive`, `clone`, + `enable`, `invite`, `unblock`, `upload`, `publish`, `unpublish`, + `config set / unset`, `env use`, `logout`, `raw`. +- **Read-only**: everything else; no `--yes`, no `--dry-run`. + +## 6. Shared code + +`src/lib/exit-codes.ts`, `src/lib/cli-error.ts` (envelope), +`src/lib/schema.ts` (schema document) and `src/lib/request-fields.ts` +(SDK method → request fields) have no oclif dependency, so the MCP server +can import them and speak the same contract. diff --git a/docs/tasks/agent-native-cli.md b/docs/tasks/agent-native-cli.md index e207294..23fa3f4 100644 --- a/docs/tasks/agent-native-cli.md +++ b/docs/tasks/agent-native-cli.md @@ -369,7 +369,7 @@ code (parallel work), colour, new runtime dependencies. realistic `--help` examples, hub/api parser fixes (`--body`, typed fields, boolean traps) — **done** 3. PR 3 — startup (`oclif manifest` in build, lazy prompts), docs - (`agent-contract.md`, `AGENTS.md`), `norbix ai init`, README section — todo + (`agent-contract.md`, `AGENTS.md`), `norbix ai init`, README section — **done** ### Changes @@ -397,6 +397,13 @@ code (parallel work), colour, new runtime dependencies. | 35 mutating command files | `...BaseCommand.dryRunFlags`; explicit dry runs in `config set/unset`, `env use`, `logout`, `raw`, `files publish/unpublish` (`publicFilesRequest`) | 2 | | every command file | realistic example values; a `--dry-run` example on every mutating command, `--yes` on every destructive one | 2 | | `tests/schema.test.ts`, `tests/dispatch.test.ts`, `tests/agent-mode.test.ts` | schema snapshot + completeness, parser unit tests, dry-run on non-destructive / local / raw-fetch commands, `--body` | 2 | +| `bin/run.js` | `--version` fast path (answers from package.json, no oclif load) | 3 | +| `package.json` | `build` ends with `oclif manifest` (manifest stays gitignored, `postpack` still cleans it); `prepack` = build; `ai` topic | 3 | +| `src/lib/agent-files.ts` | new: the skill, the CLAUDE.md / AGENTS.md block, the Cursor rule, and the plan per target (no oclif import) | 3 | +| `src/commands/ai/init.ts` | new: `norbix ai init [--target claude\|agents\|cursor\|all] [--dry-run] [--force] [--dir]` | 3 | +| `docs/agent-contract.md`, `docs/AGENTS.md` | new: the contract (§1 + §3.1 + §3.2 + dry-run shape + which commands confirm) and the agent recipe | 3 | +| `README.md` | "Use with Claude Code / AI agents" section; `schema` and `ai init` in the command table | 3 | +| `tests/ai-init.test.ts` | dry-run lists, writes, refuses without `--force`, appends to an existing CLAUDE.md, `--force` replaces the block in place, `--target all`, `--version` string | 3 | ### Findings @@ -421,7 +428,15 @@ code (parallel work), colour, new runtime dependencies. "See more help with --help"); `toEnvelope` strips it. - `--version` timings on this machine vary 0.5 s – 3.2 s between runs (heavy load); the before/after numbers in the report use the best of - several runs. + several runs. Idle machine: oclif's own `--version` is 0.17 s with or + without a manifest (loading @oclif/core dominates), so the fast path in + `bin/run.js` answers from package.json in 0.03 s. `users delete --help`: + 0.18 s without manifest → 0.12 s with. +- `@inquirer/prompts` was already lazy after PR 1 (dynamic import in + `confirmOrFail`, `login`, `configure`); no static import is left. +- `oclif manifest` choice: generated by `npm run build`, never committed + (`.gitignore` unchanged, `postpack` still removes it) — a committed + manifest goes stale with every flag change. - The SDK's `hub2.dtos.d.ts` names some request DTOs without the `Request` suffix (`DeleteSchedulerTask`) — the generator keys on the JSDoc @@ -444,4 +459,7 @@ code (parallel work), colour, new runtime dependencies. ### Open questions -- (none blocking) +- Where will `docs/AGENTS.md` live for the hub/portal docs team? Not + blocking: the README links to the file in this repo; recommended default + is to publish it unchanged at docs.norbix.ai/cli/agents and keep the repo + copy as the source. diff --git a/package.json b/package.json index 25c399b..234d875 100644 --- a/package.json +++ b/package.json @@ -36,10 +36,10 @@ ], "scripts": { "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"", - "build": "npm run clean && tsc && node -e \"require('node:fs').cpSync('src/generated','dist/generated',{recursive:true})\"", + "build": "npm run clean && tsc && node -e \"require('node:fs').cpSync('src/generated','dist/generated',{recursive:true})\" && oclif manifest", "typecheck": "tsc --noEmit", "dev": "npm run build && node ./bin/run.js", - "prepack": "npm run build && oclif manifest", + "prepack": "npm run build", "postpack": "node -e \"require('node:fs').rmSync('oclif.manifest.json',{force:true})\"", "test": "vitest run", "test:watch": "vitest", @@ -81,7 +81,7 @@ "description": "Account profile, projects, team, usage, billing" }, "ai": { - "description": "LLM and MCP server integrations, AI service users", + "description": "LLM and MCP server integrations, AI service users, and coding-agent setup (ai init)", "subtopics": { "llm": { "description": "Create, test, turn on/off and delete an LLM integration" @@ -111,6 +111,12 @@ "db": { "description": "Work with database collections (find, insert, update, delete)" }, + "email": { + "description": "Email templates and campaigns" + }, + "env": { + "description": "Manage project environments (PROD, TEST, ...)" + }, "files": { "description": "Upload, download and manage files", "subtopics": { @@ -119,9 +125,6 @@ } } }, - "env": { - "description": "Manage project environments (PROD, TEST, ...)" - }, "logs": { "description": "Read project logs" }, diff --git a/src/commands/ai/init.ts b/src/commands/ai/init.ts new file mode 100644 index 0000000..b8ba9d0 --- /dev/null +++ b/src/commands/ai/init.ts @@ -0,0 +1,92 @@ +import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs' +import {dirname, join, resolve} from 'node:path' + +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../base.js' +import {hasBlock, planTarget, type PlannedFile, type Target} from '../../lib/agent-files.js' +import {usageError} from '../../lib/cli-error.js' + +const TARGETS: Target[] = ['claude', 'agents', 'cursor'] + +export default class AiInit extends BaseCommand { + static description = `Set up this project so a coding agent uses the norbix CLI correctly. + +Writes, into the current folder: + claude .claude/skills/norbix/SKILL.md (a Claude Code skill) and a short + block in CLAUDE.md + agents the same block in AGENTS.md (Codex, OpenCode, Cursor and others + read it) + cursor .cursor/rules/norbix.mdc + all all of the above + +The files are short on purpose: \`norbix schema --json\` and \`--help\` are the +source of truth. An existing file is never overwritten without --force; a +block that is already present is left alone. --dry-run lists what would be +written.` + + static examples = [ + '<%= config.bin %> ai init', + '<%= config.bin %> ai init --dry-run', + '<%= config.bin %> ai init --target all', + '<%= config.bin %> ai init --target agents --force', + ] + + static flags = { + ...BaseCommand.dryRunFlags, + target: Flags.string({ + char: 't', + description: 'Which agent files to write', + options: [...TARGETS, 'all'], + default: 'claude', + }), + force: Flags.boolean({description: 'Overwrite an existing skill / rule file and rewrite an existing block', default: false}), + dir: Flags.string({description: 'Project folder (default: current folder)'}), + } + + async run(): Promise { + const {flags} = await this.parse(AiInit) + const root = resolve(flags.dir ?? process.cwd()) + const targets: Target[] = flags.target === 'all' ? TARGETS : [flags.target as Target] + + const exists = (p: string) => existsSync(join(root, p)) + const read = (p: string) => readFileSync(join(root, p), 'utf8') + const planned = targets.flatMap((t) => planTarget(t, exists, read, flags.force)) + + const refused = planned.filter((f) => { + if (f.action === 'create' && exists(f.path) && !flags.force) return true + if (f.action === 'append' && hasBlock(read(f.path))) return true + return false + }) + if (refused.length > 0) { + throw usageError( + `Already set up: ${refused.map((f) => f.path).join(', ')}.`, + 'Pass --force to rewrite the skill / rule file and the block, or --dry-run to see what would change.', + 'norbix ai init --help', + ) + } + + const files = planned.map((f) => ({path: f.path, action: f.action, bytes: Buffer.byteLength(f.content)})) + if (flags['dry-run']) { + return this.dryRun({method: 'ai.init', request: {dir: root, targets, files}}) + } + + for (const f of planned) this.write(root, f) + + this.print( + [ + ...files.map((f) => `${f.action === 'append' ? 'updated' : f.action === 'replace' ? 'replaced' : 'wrote'} ${f.path}`), + '', + 'Done. The agent now knows to use `norbix` with --json, --dry-run and --yes.', + 'Give it credentials with a profile (`norbix login --api-key ... --profile `) or NORBIX_* env vars.', + ].join('\n'), + ) + return {dir: root, targets, files} + } + + private write(root: string, f: PlannedFile): void { + const full = join(root, f.path) + mkdirSync(dirname(full), {recursive: true}) + writeFileSync(full, f.content) + } +} diff --git a/src/lib/agent-files.ts b/src/lib/agent-files.ts new file mode 100644 index 0000000..f16ea7f --- /dev/null +++ b/src/lib/agent-files.ts @@ -0,0 +1,117 @@ +/** + * What `norbix ai init` writes into a project so a coding agent uses the + * CLI correctly. Kept short on purpose: the CLI's own `--help` and + * `norbix schema --json` are the source of truth, not these files. + * + * No oclif import. + */ + +export const MARKER_START = '' +export const MARKER_END = '' + +export type Target = 'claude' | 'agents' | 'cursor' + +export interface PlannedFile { + path: string + /** create a new file, append a block to an existing one, or replace the block / file (--force). */ + action: 'create' | 'append' | 'replace' + content: string +} + +/** The recipe every target shares (Markdown, ~10 lines). */ +export function recipeBlock(): string { + return [ + MARKER_START, + '## Norbix backend — use the `norbix` CLI', + '', + 'This project uses Norbix (database, users, files, templates, campaigns, scheduler, logs). Talk to it with the `norbix` CLI from the shell; never edit the backend by hand.', + '', + '- Check who you are first: `norbix whoami --json`. If it fails with exit 4, stop and ask for credentials (`--profile ` or `NORBIX_API_KEY` + `NORBIX_PROJECT_ID` + `NORBIX_REGION`). Never run `norbix login` or `norbix configure` — they need a terminal.', + '- Always pass `--json`: stdout is then exactly one JSON document (a result, or `{"error": {code, message, status, exit, fieldErrors, url, traceId, hint, docs}}`).', + '- Discover before guessing: `norbix schema --json` (every command, `destructive`, `supportsDryRun`), then `norbix --help`. Any SDK endpoint without a named command: `norbix hub --json` lists methods and request fields; call with `norbix hub [id] --body \'\'`.', + '- Change something only in two steps: `--dry-run` (prints the exact request, sends nothing), inspect, then re-run with `--yes` when it is destructive. Without `--yes` a destructive command exits 3 and sends nothing.', + '- Branch on the exit code: 0 ok · 2 usage (fix the call) · 3 needs `--yes` · 4 auth · 5 not found · 6 validation (`fieldErrors`) · 7 network · 8 server (retry with backoff) · 9 cancelled.', + '- Full contract: `docs/agent-contract.md` in the norbix-code/cli repo; the hint and docs fields of every error tell you the next step.', + MARKER_END, + '', + ].join('\n') +} + +/** Claude Code skill: frontmatter + the same recipe. */ +export function claudeSkill(): string { + return [ + '---', + 'name: norbix', + 'description: Use when the task touches the Norbix backend — database records, users, files, email/SMS/push templates and campaigns, scheduler tasks, logs, environments, API keys, webhooks — or when the user mentions norbix. Runs the `norbix` CLI from the shell (never the browser login).', + '---', + '', + '# Norbix via the CLI', + '', + 'Use the `norbix` command for everything on the Norbix backend. It is built for agents: one JSON document per call, documented exit codes, nothing destructive without `--yes`, and `--dry-run` on every change.', + '', + '## Steps', + '', + '1. `norbix whoami --json` — confirms the profile, project, environment and region. Exit 4 means no credentials: ask the user for `--profile ` or the `NORBIX_API_KEY`, `NORBIX_PROJECT_ID` and `NORBIX_REGION` variables. Do not run `norbix login` / `norbix configure`; they need a terminal.', + '2. Find the command: `norbix schema --json` lists every command with `destructive` and `supportsDryRun`; `norbix --help` has an example with real-looking values. No named command? `norbix hub --json` (or `norbix api --json`) lists every SDK method with its request fields; call one with plain words and `--body \'\'`.', + '3. Read with `--json` and pipe or parse the one document on stdout.', + '4. Change in two steps: `norbix ... --dry-run --json` prints the exact HTTP request and sends nothing. When it is right, re-run without `--dry-run`, adding `--yes` if the command is destructive. Never add `--yes` to the first attempt.', + '5. Branch on the exit code, and read `error.hint` / `error.docs` on failure: 0 ok · 2 usage · 3 add `--yes` after a dry run · 4 auth · 5 not found (check id / `--env` / `--project`) · 6 validation (`error.fieldErrors`) · 7 network (check `--region`, endpoints) · 8 server (retry with backoff, keep `traceId`) · 9 cancelled.', + '', + '## Examples', + '', + '```sh', + 'norbix db find orders --filter \'{"status":"paid"}\' --page-size 20 --json', + 'norbix db update orders --id 66b2f0a1c3d4e5f6a7b8c9d0 --update \'{"$set":{"status":"shipped"}}\' --dry-run --json', + 'norbix users delete 66b2f0a1c3d4e5f6a7b8c9d0 --dry-run --json # then: --yes --json', + 'norbix hub scheduler --json # methods + fields', + 'norbix hub scheduler task delete 66b2f0a1c3d4e5f6a7b8c9d0 --yes --json', + '```', + '', + 'Context flags on every command: `--profile`, `--project`, `--env`, `--region`, `--api-key` (or `NORBIX_*` env vars). Full contract: docs/agent-contract.md in norbix-code/cli.', + '', + ].join('\n') +} + +/** Cursor rule file (.mdc): frontmatter + the recipe. */ +export function cursorRule(): string { + return ['---', 'description: Use the norbix CLI for anything on the Norbix backend', 'alwaysApply: false', 'globs:', '---', '', recipeBlock()].join('\n') +} + +/** Files one target needs, given what is already on disk. */ +export function planTarget(target: Target, exists: (path: string) => boolean, read: (path: string) => string, force: boolean): PlannedFile[] { + switch (target) { + case 'claude': + return [ + fileOrReplace('.claude/skills/norbix/SKILL.md', claudeSkill(), exists, force), + blockIn('CLAUDE.md', exists, read, force), + ] + case 'agents': + return [blockIn('AGENTS.md', exists, read, force)] + default: + return [fileOrReplace('.cursor/rules/norbix.mdc', cursorRule(), exists, force)] + } +} + +function fileOrReplace(path: string, content: string, exists: (p: string) => boolean, force: boolean): PlannedFile { + return {path, action: exists(path) ? (force ? 'replace' : 'create') : 'create', content} +} + +/** Append the block to a Markdown file, or replace the block between the markers with --force. */ +function blockIn(path: string, exists: (p: string) => boolean, read: (p: string) => string, force: boolean): PlannedFile { + const block = recipeBlock() + if (!exists(path)) return {path, action: 'create', content: block} + const current = read(path) + const start = current.indexOf(MARKER_START) + const end = current.indexOf(MARKER_END) + if (start !== -1 && end !== -1) { + if (!force) return {path, action: 'append', content: block} // refused later: already present + return {path, action: 'replace', content: current.slice(0, start) + block.trimEnd() + current.slice(end + MARKER_END.length)} + } + + return {path, action: 'append', content: (current.endsWith('\n') ? current : current + '\n') + '\n' + block} +} + +/** True when the file already carries the block (append would duplicate it). */ +export function hasBlock(content: string): boolean { + return content.includes(MARKER_START) +} diff --git a/tests/__snapshots__/schema.test.ts.snap b/tests/__snapshots__/schema.test.ts.snap index e1ce592..2ae0c65 100644 --- a/tests/__snapshots__/schema.test.ts.snap +++ b/tests/__snapshots__/schema.test.ts.snap @@ -54,6 +54,18 @@ exports[`norbix schema > is stable (snapshot of ids, flags and markers) 1`] = ` "id": "account usage", "supportsDryRun": false, }, + { + "args": [], + "destructive": false, + "flags": [ + "dry-run:boolean", + "target:option", + "force:boolean", + "dir:string", + ], + "id": "ai init", + "supportsDryRun": true, + }, { "args": [ "id", diff --git a/tests/ai-init.test.ts b/tests/ai-init.test.ts new file mode 100644 index 0000000..ca8e960 --- /dev/null +++ b/tests/ai-init.test.ts @@ -0,0 +1,103 @@ +import {existsSync, mkdtempSync, readFileSync, writeFileSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +import {beforeEach, describe, expect, it} from 'vitest' + +import {cli, makeHome, parseSingleJson} from './_cli.js' + +const home = makeHome(1) +let project: string + +beforeEach(() => { + project = mkdtempSync(join(tmpdir(), 'norbix-ai-init-')) +}) + +interface Report { + dryRun?: boolean + method?: string + request?: {files: Array<{path: string; action: string}>} + files?: Array<{path: string; action: string}> +} + +describe('norbix ai init', () => { + it('--dry-run lists the files and writes nothing', async () => { + const r = await cli(home, ['ai', 'init', '--dry-run', '--dir', project, '--json']) + expect(r.code).toBe(0) + const doc = parseSingleJson(r.stdout) as Report + expect(doc.dryRun).toBe(true) + expect(doc.request?.files.map((f) => `${f.action} ${f.path}`)).toEqual(['create .claude/skills/norbix/SKILL.md', 'create CLAUDE.md']) + expect(existsSync(join(project, '.claude'))).toBe(false) + expect(existsSync(join(project, 'CLAUDE.md'))).toBe(false) + }) + + it('writes the skill and the CLAUDE.md block, then refuses a second run without --force', async () => { + const first = await cli(home, ['ai', 'init', '--dir', project, '--json']) + expect(first.code).toBe(0) + expect((parseSingleJson(first.stdout) as Report).files?.map((f) => f.path)).toEqual(['.claude/skills/norbix/SKILL.md', 'CLAUDE.md']) + + const skill = readFileSync(join(project, '.claude/skills/norbix/SKILL.md'), 'utf8') + expect(skill).toMatch(/^---\nname: norbix\ndescription: Use when the task touches the Norbix backend/) + expect(skill).toContain('norbix schema --json') + expect(skill).toContain('--dry-run') + const claude = readFileSync(join(project, 'CLAUDE.md'), 'utf8') + expect(claude).toContain('') + expect(claude.split('\n').filter((l) => l.trim()).length).toBeLessThanOrEqual(12) + + const second = await cli(home, ['ai', 'init', '--dir', project, '--json']) + expect(second.code).toBe(2) + const err = (parseSingleJson(second.stdout) as {error: {message: string; hint: string}}).error + expect(err.message).toMatch(/Already set up/) + expect(err.hint).toMatch(/--force/) + }) + + it('appends the block to an existing CLAUDE.md without touching the rest', async () => { + writeFileSync(join(project, 'CLAUDE.md'), '# My project\n\nKeep this.\n') + const r = await cli(home, ['ai', 'init', '--dir', project, '--json']) + expect(r.code).toBe(0) + expect((parseSingleJson(r.stdout) as Report).files?.find((f) => f.path === 'CLAUDE.md')?.action).toBe('append') + const claude = readFileSync(join(project, 'CLAUDE.md'), 'utf8') + expect(claude.startsWith('# My project\n\nKeep this.\n')).toBe(true) + expect(claude).toContain('') + }) + + it('--force rewrites the skill and replaces the block in place', async () => { + await cli(home, ['ai', 'init', '--dir', project]) + writeFileSync(join(project, '.claude/skills/norbix/SKILL.md'), 'stale') + const claude = readFileSync(join(project, 'CLAUDE.md'), 'utf8') + writeFileSync(join(project, 'CLAUDE.md'), `# Top\n\n${claude}\n# Bottom\n`) + + const r = await cli(home, ['ai', 'init', '--dir', project, '--force', '--json']) + expect(r.code).toBe(0) + expect(readFileSync(join(project, '.claude/skills/norbix/SKILL.md'), 'utf8')).not.toBe('stale') + const after = readFileSync(join(project, 'CLAUDE.md'), 'utf8') + expect(after.startsWith('# Top\n')).toBe(true) + expect(after.endsWith('# Bottom\n')).toBe(true) + expect(after.split('').length).toBe(2) + }) + + it('--target all writes AGENTS.md and the Cursor rule too', async () => { + const r = await cli(home, ['ai', 'init', '--target', 'all', '--dir', project, '--json']) + expect(r.code).toBe(0) + expect((parseSingleJson(r.stdout) as Report).files?.map((f) => f.path)).toEqual([ + '.claude/skills/norbix/SKILL.md', + 'CLAUDE.md', + 'AGENTS.md', + '.cursor/rules/norbix.mdc', + ]) + expect(readFileSync(join(project, '.cursor/rules/norbix.mdc'), 'utf8')).toMatch(/^---\ndescription: /) + }) + + it('rejects an unknown target with exit 2', async () => { + const r = await cli(home, ['ai', 'init', '--target', 'emacs', '--dir', project, '--json']) + expect(r.code).toBe(2) + }) +}) + +describe('norbix --version', () => { + it('prints the oclif-style user agent', async () => { + const r = await cli(home, ['--version']) + expect(r.code).toBe(0) + expect(r.stdout).toMatch(/^@norbix\.ai\/cli\/\d+\.\d+\.\d+ \w+-\w+ node-v\d+/) + }) +})