diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md index 5caeea9..6cc01fa 100644 --- a/.claude/rules/architecture.md +++ b/.claude/rules/architecture.md @@ -54,7 +54,7 @@ HttpClient (axios) -> performs the real HTTP request to API_BASE_URL 4. **Pure transform layer**: `bm25.ts`, `openapi-to-commands.ts`, and `command-search.ts` perform no I/O; they take inputs and return outputs. This keeps them trivially unit-testable. 5. **Side effects at the edges**: filesystem in `config.ts`/`profile-store.ts`/`openapi-loader.ts`, network in `cli.ts` via `HttpClient`. Inject these via constructors (`fs`, `httpClient`) so tests can swap them. 6. **TypeScript strict**: `strict: true` in `tsconfig.json`. Explicit types for exported functions and public interfaces. -7. **No surprise breaking changes**: every CLI-visible change must be reflected in `README.md` and `CHANGELOG.md`. +7. **No surprise breaking changes**: every CLI-visible change must be reflected in `README.md`. ## Layers and allowed dependencies diff --git a/.claude/rules/workflow.md b/.claude/rules/workflow.md index d3d3fa2..caa43c5 100644 --- a/.claude/rules/workflow.md +++ b/.claude/rules/workflow.md @@ -16,8 +16,7 @@ When the user asks for a new feature (words like "feature", "add", "implement", 6. **Full suite**: `npm test`. All tests must be green. Fix regressions before moving on. 7. **Build check**: `npm run build` to confirm `tsc` is clean (no type errors). 8. **Docs**: update `README.md` whenever any of the following change: CLI flags, command names, profile fields, `.ocli/` layout, BM25 search behavior, supported OpenAPI/Swagger features, or the benchmark numbers. If you changed observable CLI output (`--help`, error messages, exit codes), update the relevant section of the README. The `examples/skill-ocli-api.md` and `skills/ocli-api/SKILL.md` must stay aligned with the documented agent workflow. -9. **Changelog**: add an entry to `CHANGELOG.md` for any user-visible change. -10. **Report**: brief summary of files touched, tests added, suite result. +9. **Report**: brief summary of files touched, tests added, suite result. ## Bug fixes (TDD) @@ -30,14 +29,14 @@ When the user reports a bug (words like "bug", "fix", "ошибка", "баг", 5. **Confirm green**: re-run the regression test. 6. **Full suite**: `npm test`. All tests green. 7. **Build check**: `npm run build`. -8. **Docs**: update `README.md` if the bug affected documented behavior; add a `CHANGELOG.md` entry. +8. **Docs**: update `README.md` if the bug affected documented behavior. 9. **Report**: what was broken, what changed, suite result. ## Before the final answer - `npm test` is green - **always**. - `npm run build` is clean. -- `README.md` and `CHANGELOG.md` reflect any user-visible change. +- `README.md` reflects any user-visible change. - Report what changed, which tests were added, and the suite result. ## Rules sync (Cursor <-> Claude) diff --git a/.cursor/rules/architecture.mdc b/.cursor/rules/architecture.mdc index 88dd3b3..115d5a0 100644 --- a/.cursor/rules/architecture.mdc +++ b/.cursor/rules/architecture.mdc @@ -1,53 +1,88 @@ --- description: Architecture of openapi-to-cli (ocli) +globs: "src/**/*.ts" alwaysApply: true --- -## Architectural Rules +# Architecture of openapi-to-cli (ocli) -### General Architecture +`ocli` is a TypeScript/Node CLI that converts OpenAPI/Swagger specs into runtime CLI commands. No code generation: at every invocation it loads a cached spec and builds command definitions on the fly. -The `openapi-to-cli` project is a Node.js/TypeScript CLI application that: +## Data flow -- reads an OpenAPI/Swagger spec from a URL or file; -- caches the spec in `.ocli/specs`; -- uses profiles to describe connected APIs; -- maps OpenAPI operations to `ocli` subcommands. - -### Data Flow - -```text -ocli onboard --options --> write profile (profiles.ini) --> download OpenAPI --> save to .ocli/specs +``` +ocli profiles add --api-base-url ... --openapi-spec ... + | + v +ConfigLocator -> .ocli/ dir (global or local) + | + v +ProfileStore -> profiles.ini (read/write/select) + | + v +OpenapiLoader -> fetches spec, caches under .ocli/specs/.json + | + v +OpenapiToCommands -> parses spec, applies include/exclude filters, + builds CliCommand[] (name, method, path, options, body schema) | v -ocli [--profile] [options] --> load profile + cached spec --> build commands from OpenAPI --> perform HTTP request to API_BASE_URL +CommandSearch (BM25) -> ranks commands by NL query or regex + | + v +cli.ts (yargs) -> resolves profile + spec, dispatches: profiles | use | commands | + | + v +HttpClient (axios) -> performs the real HTTP request to API_BASE_URL ``` -### Components +## Components (mapping to `src/`) + +- `config.ts` - `ConfigLocator`. Finds `.ocli/` (global `~/.ocli/`, local in CWD), resolves `profiles.ini` paths. +- `profile-store.ts` - `ProfileStore`, `Profile`. Reads/writes `profiles.ini`, tracks current profile, validates fields. +- `openapi-loader.ts` - `OpenapiLoader`. Loads spec from URL or local file, caches it to `.ocli/specs/.json`, refreshes on demand. Resolves external `$ref` across multi-file specs. +- `openapi-to-commands.ts` - `OpenapiToCommands`, `CliCommand`, `CliCommandOption`. Walks the spec, applies include/exclude filters, expands path-level params, resolves local `$ref`, builds command names with optional prefix, expands `enum`/`default`/`nullable`/`oneOf` schema hints for `--help`. +- `command-search.ts` - `CommandSearch`. BM25 over `(name, method, path, description, options[].name)`, plus regex fallback. Same engine used by both `ocli commands` and any future agent skill. +- `bm25.ts` - tokenizer + BM25 scorer, no I/O. +- `cli.ts` - `ocli` entry point. yargs command tree: `profiles add|remove|list`, `use`, `commands`, and dynamic per-spec commands. Builds the `axios` request from a `CliCommand` + parsed args; injects auth, custom headers, server URL overrides. +- `version.ts` - generated by `scripts/generate-version.js` during `prebuild`. Do not edit by hand. + +## Design principles + +1. **OpenAPI-driven**: commands and their options come from the spec. No hand-maintained registry. +2. **Profiles**: every API connection is named; `profiles.ini` is the source of truth. Global vs local `.ocli/` priority is decided by `ConfigLocator`. +3. **Spec cache**: never re-download a spec on every invocation. Refresh is explicit (`onboard`/profile add or refresh flag). +4. **Pure transform layer**: `bm25.ts`, `openapi-to-commands.ts`, and `command-search.ts` perform no I/O; they take inputs and return outputs. This keeps them trivially unit-testable. +5. **Side effects at the edges**: filesystem in `config.ts`/`profile-store.ts`/`openapi-loader.ts`, network in `cli.ts` via `HttpClient`. Inject these via constructors (`fs`, `httpClient`) so tests can swap them. +6. **TypeScript strict**: `strict: true` in `tsconfig.json`. Explicit types for exported functions and public interfaces. +7. **No surprise breaking changes**: every CLI-visible change must be reflected in `README.md`. + +## Layers and allowed dependencies + +``` +Layer 0 (pure) bm25.ts, version.ts, types in openapi-to-commands.ts +Layer 1 (I/O wrappers) config.ts, profile-store.ts, openapi-loader.ts +Layer 2 (transform) openapi-to-commands.ts (uses Profile), command-search.ts (uses CliCommand + bm25) +Layer 3 (entry) cli.ts (uses everything above; only this layer talks to yargs/axios/process) +``` -- **config** - locate and select `.ocli` directory, resolve `profiles.ini` paths with global and local priority. -- **profile-store** - read and write profile INI files (`profiles.ini`), select current profile. -- **openapi-loader** - load spec from URL or file and cache it into `.ocli/specs/.json`. -- **openapi-to-commands** - parse OpenAPI, apply include/exclude filters, build command names and option schemas. -- **cli** - entry point, argument parser, command registration, help rendering. +Lower layers must not import from higher layers. New behavior should live in the lowest layer where it makes sense - prefer adding to Layer 2 over expanding `cli.ts`. -### Design Principles +## Repository layout -1. **OpenAPI-driven** - the list of commands and their parameters is defined by the spec. -2. **Profiles** - all API settings are configured via profiles (global or local). -3. **Spec cache** - the spec is cached under `.ocli/specs` to avoid fetching it on every run. -4. **TypeScript strict** - `strict: true`; explicit types for public APIs and profile interfaces. -5. **TDD for core logic** - unit tests for profile parsing, spec loading and command mapping. -6. **Language** - all documentation and code comments for this project must be written in English. +- `src/` - production code (see components above). +- `tests/` - Jest test files (`*.test.ts`), fixtures under `tests/fixtures/`, recorded results under `tests/results/`. +- `examples/skill-ocli-api.md` - example Claude Code skill describing the agent workflow. +- `skills/ocli-api/SKILL.md` - portable OpenClaw skill. +- `benchmarks/benchmark.ts` - token-overhead comparison (MCP variants vs CLI). +- `scripts/generate-version.js` - writes `src/version.ts` before build. +- `.ocli/` - working dir at runtime (not part of source). Never committed. +- `dist/` - `tsc` build output. -### Repository Layout (for the openapi-to-cli directory) +## When extending the spec parser -- `README.md` - concept and description of the CLI and profiles. -- `package.json` - npm package with the `ocli` binary. -- `tsconfig.json` - TypeScript config with strict mode. -- `jest.config.js` - Jest configuration. -- `src/`: - - `cli.ts` - `ocli` binary entry point. - - future modules: `config`, `profile-store`, `openapi-loader`, `openapi-to-commands`, etc. -- `tests/` - tests for the modules above. +Real-world OpenAPI/Swagger documents drift from any single example. Before changing `openapi-to-commands.ts` or `openapi-loader.ts`: +- Add a minimal fixture under `tests/fixtures/` that reproduces the case (don't hand-edit `box-api-yaml.test.ts` or `github-api.test.ts` fixtures - those are real specs). +- Cover both OAS 3 (`requestBody`, `components/schemas`) and Swagger 2 (`body`/`formData`, `definitions`) when the change affects request building. +- Mention the new spec feature in the README "Broader spec support" or "Better request generation" section. diff --git a/.cursor/rules/code-style.mdc b/.cursor/rules/code-style.mdc index 79199a1..9387b72 100644 --- a/.cursor/rules/code-style.mdc +++ b/.cursor/rules/code-style.mdc @@ -4,32 +4,52 @@ globs: "**/*.ts" alwaysApply: true --- -## Code Style Rules +# Code style (TypeScript) -### General rules +Applies to every `.ts` file in `src/` and `tests/`. -1. All code comments for this project must be written in English. -2. All documentation files for this project must be written in English. -3. New files must end with a single empty line. -4. Use straight double quotes `"`. Use the regular dash `-` for punctuation, do not use long dashes. +## General -### TypeScript +1. All code comments and identifiers in English. Documentation files in English. +2. New files end with a single trailing newline. +3. Straight double quotes `"` for string literals. Regular hyphen `-`, not em-dashes, in any English prose inside code or docs. +4. Default to no comments. Add a comment only when the **why** is non-obvious (workaround, hidden constraint, subtle invariant). Identifiers should carry intent. -- `strict: true` is enabled in `tsconfig.json`. -- For exported functions and public APIs, prefer explicit parameter and return types. -- For reusable object shapes, use `interface`. +## TypeScript -### File structure +- `strict: true` in `tsconfig.json`. Do not weaken it locally. +- Exported functions and public APIs declare explicit parameter and return types. Local variables may use inference. +- Use `interface` for reusable object shapes, `type` for unions, intersections, and mapped types. +- Avoid `any`. When unavoidable, scope it as narrowly as possible and annotate `// eslint-disable-next-line @typescript-eslint/no-explicit-any` with a one-line reason (see `cli.ts:HttpClient` for the canonical example). +- Prefer `unknown` over `any` at module boundaries; narrow with type guards. -1. Imports first (Node/third-party, then local). -2. Then constants and types. -3. Then main logic (functions, classes, exports). +## File structure -### Naming +1. Imports first - Node built-ins (`path`, `fs`), then third-party (`axios`, `yargs`, `js-yaml`, `zod`, `ini`), then local relative imports. +2. Types and interfaces. +3. Module-level constants. +4. Functions and classes. +5. `export` last when it improves readability; `export class` / `export function` inline is also fine. -- Classes - PascalCase. -- Functions and methods - camelCase. -- Configuration constants - UPPER_SNAKE_CASE or meaningful camelCase names. -- Files - kebab-case or camelCase, consistent with existing files. +## Naming +- Classes - `PascalCase` (`ProfileStore`, `OpenapiLoader`, `CommandSearch`). +- Functions and methods - `camelCase` (`loadSpec`, `buildCommands`, `selectProfile`). +- Interfaces and type aliases - `PascalCase` (`Profile`, `CliCommand`, `HttpClient`). +- Constants - `UPPER_SNAKE_CASE` for environment-style globals, otherwise meaningful `camelCase`. +- Files - `kebab-case.ts` matching the dominant exported type (`profile-store.ts` exports `ProfileStore`). +## Error handling + +- Throw `Error` subclasses with informative messages; never throw strings. +- At the CLI boundary (`cli.ts`), catch and translate to a user-friendly message + non-zero exit code. Inner layers should let exceptions propagate. +- For external input (HTTP responses, parsed YAML/JSON), validate with `zod` schemas before consuming. + +## Async + +- Prefer `async/await`. Avoid `.then()` chains in new code. +- Don't fire-and-forget Promises; always `await` or explicitly handle. + +## Testing-facing affordances + +When a module performs I/O, accept the dependency via a constructor option (`fs`, `httpClient`, `stdout`). This is how `OpenapiLoader`, `ProfileStore`, and the `run()` entry in `cli.ts` are structured; follow the same pattern in new modules. diff --git a/.cursor/rules/implementation-order.mdc b/.cursor/rules/implementation-order.mdc new file mode 100644 index 0000000..a20ffc5 --- /dev/null +++ b/.cursor/rules/implementation-order.mdc @@ -0,0 +1,42 @@ +--- +description: Implementation order for openapi-to-cli (one unit at a time) +globs: "src/**/*.ts" +alwaysApply: false +--- + +# Implementation order (one unit at a time) + +Applies when adding or extending modules under `src/`. + +## Layer order (lower first) + +The architecture in @architecture.mdc defines four layers. New behavior should be added at the lowest layer it can live in, then composed upward: + +1. **Layer 0 - pure** (`bm25.ts`, type-only files): no I/O, no Node built-ins beyond `Buffer`/`URL`/etc. +2. **Layer 1 - I/O wrappers** (`config.ts`, `profile-store.ts`, `openapi-loader.ts`): touch `fs`, network, or env. +3. **Layer 2 - transform** (`openapi-to-commands.ts`, `command-search.ts`): combine Layer 0 + Layer 1 outputs into the CLI command model. +4. **Layer 3 - entry** (`cli.ts`): wire everything together for yargs and axios. + +Forbidden: Layer N importing from Layer M when M > N. If a Layer 1 module suddenly needs a Layer 2 type, that is a sign the type belongs lower. + +## Steps for a new module or class + +1. Decide the layer using @architecture.mdc. +2. Write a failing test in `tests/.test.ts` that describes the smallest useful behavior (see @testing.mdc and @workflow.mdc). +3. Add the minimum implementation in `src/.ts`. Follow @code-style.mdc for types, naming, and constructor-injected I/O. +4. Make the test pass. +5. Run `npm test` to confirm no regressions, then `npm run build` to confirm `tsc` is clean. +6. Only **then** integrate the new module into the layer above (typically `cli.ts`), guarded by its own test. + +## Forbidden + +- Implementing two unrelated modules in one step before either has tests. +- Wiring a new module into `cli.ts` before its own tests pass. +- Adding optional fields to `Profile`, `CliCommand`, or `CliCommandOption` without a test that exercises the new field. +- Editing real-spec fixtures (`github-api.*`, `box-api-yaml.*`) to "make tests pass" - those represent contracts. + +## Allowed + +- Stub or fake dependencies (mock `HttpClient`, in-memory `fs`) while a lower layer is incomplete, provided the stub matches the documented contract. +- Refactor a passing module to a cleaner shape after the test suite stays green. +- Extending an existing Layer 2 module with a new transformation, as long as it is covered by a new test and does not import upward. diff --git a/.cursor/rules/testing.mdc b/.cursor/rules/testing.mdc index be0f97d..7424578 100644 --- a/.cursor/rules/testing.mdc +++ b/.cursor/rules/testing.mdc @@ -1,27 +1,52 @@ --- -description: Test rules for openapi-to-cli -globs: "**/tests/**/*.ts, **/*.test.ts" +description: Test conventions for openapi-to-cli (Jest) +globs: "tests/**/*.ts" alwaysApply: true --- -## Test Writing Rules +# Test conventions (Jest) -### General principles +Applies when editing or adding files under `tests/`. -1. Tests live in the `tests/` directory inside `openapi-to-cli`. -2. Each test must be independent; use mocks for HTTP (axios) and the filesystem when needed. -3. `describe` and `it` names should clearly describe the behavior under test. +## Layout -### Test structure +- All tests live in `tests/` alongside the matching `src/` module. +- Test files: `tests/.test.ts` (one per `src/.ts`). +- Shared inputs in `tests/fixtures/` (OpenAPI/Swagger documents, sample profiles). Recorded outputs in `tests/results/`. +- `github-api.test.ts` and `box-api-yaml.test.ts` are large real-spec regression tests - do not hand-edit their fixtures. -- Test files: `*.test.ts`. -- Grouping by modules, for example: `describe("profile-store", ...)`, `describe("openapi-loader", ...)` etc. +## Structure -### Running tests +- Group with `describe(, ...)`; one nested `describe` per public method or scenario. +- Test names: `it("does X when Y", ...)` - describe behavior, not implementation. +- Arrange / Act / Assert order inside each `it`. Blank lines between the three sections are encouraged. -From the `openapi-to-cli` directory: +## Isolation + +- Each `it` is independent. Use `beforeEach`/`afterEach` for setup and teardown, not module-level mutable state. +- Mock `fs` and `axios` (`HttpClient`) through the constructor options the modules expose. Do **not** monkey-patch the real `fs`/`axios` modules in tests. +- For `.ocli/` fixtures, use `fs.mkdtempSync(os.tmpdir() + "/...")` and clean up in `afterEach`. + +## Running ```bash -npm test +npm test # full Jest suite +npx jest tests/.test.ts # one file +npx jest tests/.test.ts -t "X" # one test by name ``` +## What to assert + +- Public behavior visible at the module boundary - return values, written files, requests issued via the mocked `HttpClient`, captured `stdout`. +- For BM25 / `command-search` - assert ranking order and matched commands, not internal scores. +- For `openapi-to-commands` - assert the resulting `CliCommand[]` structure (names, options, body schema), not intermediate spec normalization. + +## What not to assert + +- Internal helper signatures, private state, exact log strings - those are implementation details. +- Floating-point BM25 scores beyond ranking order. + +## Fixtures + +- Add new spec fixtures only when an existing one cannot reproduce the case. Keep them minimal: one path, one operation, only the fields needed for the test. +- For tests covering spec features (OAS 3 `requestBody`, Swagger 2 `formData`, multi-file `$ref`, header/cookie params), name the fixture after the feature, e.g. `tests/fixtures/swagger2-formdata.yaml`. diff --git a/.cursor/rules/workflow.mdc b/.cursor/rules/workflow.mdc index 4eeac79..c339365 100644 --- a/.cursor/rules/workflow.mdc +++ b/.cursor/rules/workflow.mdc @@ -3,64 +3,72 @@ description: Workflow for openapi-to-cli alwaysApply: true --- -## Workflow Rules +# Workflow (TDD, layered, docs-aware) -### Documentation +This file is `alwaysApply: true`, so it loads in every Cursor session (same priority as the top-level project rules). -- Main documentation is `README.md` in the root of `openapi-to-cli`. -- When changing CLI behavior, profile format or `.ocli` structure, update `README.md`. -- All documentation for this project must be written in English. +`openapi-to-cli` (`ocli`) is a TypeScript CLI that turns OpenAPI/Swagger specs into runtime commands. The user-facing surface is the `ocli` binary, `.ocli/` config dir, and `profiles.ini`. Tests live next to source in `tests/` and run with Jest. Behavior is specified by tests; the README documents the public CLI contract. -### New features (TDD) +## New features (TDD) -When adding new functionality to `openapi-to-cli`: +When the user asks for a new feature (words like "feature", "add", "implement", "фича", "добавить"), follow this order. Do not skip steps. -1. First write a test in `tests/` (for example for profile parsing or command generation). -2. Run the test and make sure it fails. -3. Implement the feature in `src/` following architecture and code-style rules. -4. Run the test again and make sure it passes. -5. Run all tests: `npm test` from the `openapi-to-cli` directory. +1. **Plan**: outline the steps for the change (modules to touch, tests to add). Use a task list if it spans more than 2-3 steps. +2. **Failing test first**: add or extend a test in `tests/.test.ts`. The test must describe the new behavior in `describe`/`it` and **fail** for the right reason. +3. **Confirm red**: run only that test: `npx jest tests/.test.ts -t ""`. Confirm it fails as expected. +4. **Implement**: write the minimum code in `src/` that makes the test pass. Follow @architecture.mdc and @code-style.mdc. When adding new classes, follow @implementation-order.mdc - lower layer first. +5. **Confirm green**: re-run the same test; it must pass. +6. **Full suite**: `npm test`. All tests must be green. Fix regressions before moving on. +7. **Build check**: `npm run build` to confirm `tsc` is clean (no type errors). +8. **Docs**: update `README.md` whenever any of the following change: CLI flags, command names, profile fields, `.ocli/` layout, BM25 search behavior, supported OpenAPI/Swagger features, or the benchmark numbers. If you changed observable CLI output (`--help`, error messages, exit codes), update the relevant section of the README. The `examples/skill-ocli-api.md` and `skills/ocli-api/SKILL.md` must stay aligned with the documented agent workflow. +9. **Report**: brief summary of files touched, tests added, suite result. -### Bug fixes (TDD) +## Bug fixes (TDD) -When fixing a bug: +When the user reports a bug (words like "bug", "fix", "ошибка", "баг", "исправить"): -1. Write a test that reproduces the bug. -2. Make sure the test fails. -3. Fix the code. -4. Make sure the test passes. -5. Run all tests. +1. **Plan** the fix. +2. **Reproduction test**: add a test in `tests/<module>.test.ts` that reproduces the bug. It must **fail** on the broken code for the right reason. +3. **Confirm red**: run only that test and confirm it fails. +4. **Fix**: minimal code change in `src/` to make the test pass; respect existing module boundaries. +5. **Confirm green**: re-run the regression test. +6. **Full suite**: `npm test`. All tests green. +7. **Build check**: `npm run build`. +8. **Docs**: update `README.md` if the bug affected documented behavior. +9. **Report**: what was broken, what changed, suite result. -### Tests +## Before the final answer -- Tests are stored in `tests/` next to `src/`. -- Test file names: `*.test.ts`. -- Run tests: +- `npm test` is green - **always**. +- `npm run build` is clean. +- `README.md` reflects any user-visible change. +- Report what changed, which tests were added, and the suite result. -```bash -npm test -``` +## Rules sync (Cursor <-> Claude) -### Rules sync (Cursor <-> Claude) - -`.cursor/rules/*.mdc` and `.claude/rules/*.md` cover the same topics and must stay aligned. **Any change to a rule in one location must be mirrored to the other in the same change**, no exceptions. +`.claude/rules/*.md` and `.cursor/rules/*.mdc` cover the same topics and must stay aligned. **Any change to a rule in one location must be mirrored to the other in the same change**, no exceptions. Mapping: -| Topic | Cursor | Claude | +| Topic | Claude | Cursor | |-------|--------|--------| -| Workflow | `.cursor/rules/workflow.mdc` | `.claude/rules/workflow.md` | -| Architecture | `.cursor/rules/architecture.mdc` | `.claude/rules/architecture.md` | -| Code style | `.cursor/rules/code-style.mdc` | `.claude/rules/code-style.md` | -| Testing | `.cursor/rules/testing.mdc` | `.claude/rules/testing.md` | -| Implementation order | `.cursor/rules/implementation-order.mdc` (create if absent) | `.claude/rules/implementation-order.md` | +| Workflow | `.claude/rules/workflow.md` | `.cursor/rules/workflow.mdc` | +| Architecture | `.claude/rules/architecture.md` | `.cursor/rules/architecture.mdc` | +| Code style | `.claude/rules/code-style.md` | `.cursor/rules/code-style.mdc` | +| Testing | `.claude/rules/testing.md` | `.cursor/rules/testing.mdc` | +| Implementation order | `.claude/rules/implementation-order.md` | `.cursor/rules/implementation-order.mdc` (create if absent) | When propagating, translate the frontmatter: -- Cursor `globs: "src/**/*.ts"` + `alwaysApply: true` -> Claude `paths: ["src/**/*.ts"]`. -- Cursor `alwaysApply: true` with no `globs` -> Claude rule without frontmatter (loaded every session). -- Cursor `alwaysApply: false` (optional/topic rule) -> Claude `paths:` narrowed to the relevant tree. -- Replace cross-rule links: Cursor `@architecture.mdc` -> Claude `[architecture.md](architecture.md)`. +- Claude `paths: ["src/**/*.ts"]` -> Cursor `globs: "src/**/*.ts"` + `alwaysApply: true` (or `false` for optional topics like `implementation-order`). +- Claude rule without frontmatter (loaded every session, e.g. `workflow.md`) -> Cursor `alwaysApply: true` with no `globs`. +- Replace cross-rule links: Claude `[architecture.md](architecture.md)` -> Cursor `@architecture.mdc`. Body content stays identical. If the change is Cursor-only or Claude-only (very rare - e.g. tool-specific quirk), state that explicitly in the file as "Tool-specific:" and skip mirroring for that section only. +## Relationship to other rules + +- @architecture.mdc is loaded under `src/**` - use it when picking modules and dependency direction. +- @implementation-order.mdc is loaded under `src/**` - use it to decide layer order when adding new classes. +- @code-style.mdc is loaded for all `.ts` files - applies to both `src/` and `tests/`. +- @testing.mdc is loaded under `tests/` - applies when writing or editing tests. diff --git a/.github/workflows/publish-on-tag.yaml b/.github/workflows/publish-on-tag.yaml index d70ebbf..97792fb 100644 --- a/.github/workflows/publish-on-tag.yaml +++ b/.github/workflows/publish-on-tag.yaml @@ -15,8 +15,8 @@ defaults: shell: bash jobs: - test-and-publish: - name: Test and publish package + publish: + name: Publish package runs-on: ubuntu-latest permissions: contents: read @@ -38,9 +38,6 @@ jobs: - name: Build TypeScript run: npm run build - - name: Run tests - run: npm test - - name: Publish to npm env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/tests-on-pr.yaml b/.github/workflows/tests-on-pr.yaml index 3f142c9..7675cdf 100644 --- a/.github/workflows/tests-on-pr.yaml +++ b/.github/workflows/tests-on-pr.yaml @@ -29,5 +29,8 @@ jobs: - name: Build TypeScript run: npm run build + - name: Download test fixtures + run: bash tests/fixtures/download.sh + - name: Run tests run: npm test diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index f16c30f..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,63 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -### Added -- OpenClaw skill (`skills/ocli-api/SKILL.md`) published to [ClawHub](https://clawhub.ai/skills/ocli-api) -- Fair 4-strategy token benchmark: MCP Naive, MCP+Search Full, MCP+Search Compact, CLI -- mcp2cli added to comparison table with MCP/GraphQL/TOON/OAuth features -- CHANGELOG.md - -### Changed -- README rewritten: "CLI vs MCP" positioning replaced with 4-layer model (Built-in Tools, MCP, Skills, CLI) -- README reduced from 285 to 175 lines — removed implementation details available via `--help` -- Benchmark now uses same BM25 engine for all strategies (fair comparison) - -### Fixed -- Nested JSON values for body flags now parsed correctly (#5, thanks @veged) - -## [0.1.3] - 2026-03-12 - -### Added -- BM25 command search (`ocli commands --query "..."`) -- Regex command search (`ocli commands --regex "..."`) -- YAML spec support (Box API 258 endpoints tested) -- GitHub API test fixture (845 endpoints) -- Box API test fixture (258 endpoints) -- `ocli commands` replaces deprecated `ocli search` -- CLI-Anything added to comparison table -- MIT license file - -## [0.1.2] - 2026-03-12 - -### Added -- Command generation from OpenAPI paths and methods - -## [0.1.1] - 2026-03-12 - -### Fixed -- Version tag generation - -## [0.1.0] - 2026-03-12 - -### Added -- Initial release -- OpenAPI/Swagger spec loading (URL and local file) -- Spec caching in `.ocli/specs/` -- Profile management (`profiles add/list/show/remove`, `use`) -- Command generation from OpenAPI paths with method suffix logic -- Path and query parameter extraction -- HTTP request execution (GET, POST, PUT, DELETE, PATCH) -- Basic and Bearer token authentication -- GitHub Actions CI/CD workflows - -[Unreleased]: https://github.com/EvilFreelancer/openapi-to-cli/compare/v0.1.3...HEAD -[0.1.3]: https://github.com/EvilFreelancer/openapi-to-cli/compare/v0.1.2...v0.1.3 -[0.1.2]: https://github.com/EvilFreelancer/openapi-to-cli/compare/v0.1.1...v0.1.2 -[0.1.1]: https://github.com/EvilFreelancer/openapi-to-cli/compare/v0.1.0...v0.1.1 -[0.1.0]: https://github.com/EvilFreelancer/openapi-to-cli/releases/tag/v0.1.0 diff --git a/README.md b/README.md index 47da421..cb33c42 100644 --- a/README.md +++ b/README.md @@ -55,8 +55,14 @@ ocli myapi_messages_post --help # Execute ocli myapi_messages_post --text "Hello world" + +# Or target a different profile for a single call (no 'use' required) +ocli myapi_messages_post --profile other --text "Hello world" +ocli commands -p other --query "send message" ``` +`--profile` (short `-p`) overrides the profile selected by `ocli use` for this invocation only. It works for both dynamic API commands and `ocli commands`. Place it anywhere after the command name. When omitted, the profile set via `ocli use` is used (falling back to `default`). + Or use `npx` without global install: ```bash diff --git a/examples/skill-ocli-api.md b/examples/skill-ocli-api.md index 2123442..7d6fa9c 100644 --- a/examples/skill-ocli-api.md +++ b/examples/skill-ocli-api.md @@ -61,10 +61,22 @@ ocli resources_post --name "New Resource" --description "Details" 3. Execute the command with required parameters 4. Parse the JSON response for the information needed +## Multiple APIs + +```bash +# Switch the default profile permanently +ocli use github + +# Or override the profile for a single call (short alias: -p) +ocli repos_get --profile github --owner octocat --repo Hello-World +ocli commands -p github --query "list pull requests" +``` + ## Tips - All responses are JSON — pipe through `jq` for filtering - Path parameters (like `{id}`) are passed as `--id <value>` - Required parameters will error if missing - Use `ocli commands` to list all available commands +- Use `--profile <name>` (or `-p <name>`) to switch profile for a single call without running `ocli use` ```` diff --git a/package.json b/package.json index 8747921..39873eb 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,6 @@ }, "main": "dist/cli.js", "scripts": { - "prebuild": "node scripts/generate-version.js", "build": "tsc", "watch": "tsc -w", "start": "node dist/cli.js", diff --git a/src/cli.ts b/src/cli.ts index a380f4c..148aa6d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -44,6 +44,58 @@ interface AddProfileArgs { "custom-headers"?: string; } +function extractProfileFlag(args: string[]): { profileName?: string; remaining: string[] } { + const remaining: string[] = []; + let profileName: string | undefined; + + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + + if (arg === "--profile" || arg === "-p") { + if (i + 1 >= args.length) { + throw new Error(`Missing value for ${arg}`); + } + profileName = args[i + 1]; + i += 1; + continue; + } + + if (arg.startsWith("--profile=")) { + profileName = arg.slice("--profile=".length); + continue; + } + + if (arg.startsWith("-p=")) { + profileName = arg.slice("-p=".length); + continue; + } + + remaining.push(arg); + } + + return { profileName, remaining }; +} + +function resolveProfile( + profileStore: ProfileStore, + cwd: string, + overrideName: string | undefined +): Profile { + if (overrideName) { + const profile = profileStore.getProfileByName(cwd, overrideName); + if (!profile) { + throw new Error(`Profile not found: ${overrideName}`); + } + return profile; + } + + const profile = profileStore.getCurrentProfile(cwd); + if (!profile) { + throw new Error("No current profile configured"); + } + return profile; +} + async function runApiCommand( toolName: string, args: string[], @@ -58,10 +110,8 @@ async function runApiCommand( const { cwd, profileStore, openapiLoader, stdout, httpClient } = env; const openapiToCommands = new OpenapiToCommands(); - const profile = profileStore.getCurrentProfile(cwd); - if (!profile) { - throw new Error("No current profile configured"); - } + const { profileName: overrideName, remaining: commandArgs } = extractProfileFlag(args); + const profile = resolveProfile(profileStore, cwd, overrideName); const spec = await openapiLoader.loadSpec(profile); const commands = openapiToCommands.buildCommands(spec, profile); @@ -71,7 +121,7 @@ async function runApiCommand( throw new Error(`Command ${toolName} is not available for profile ${profile.name}`); } - if (args.includes("-h") || args.includes("--help")) { + if (commandArgs.includes("-h") || commandArgs.includes("--help")) { stdout(`ocli ${command.name}\n\n`); if (command.description) { @@ -123,6 +173,13 @@ async function runApiCommand( }); }); + entries.push({ + key: "--profile, -p", + desc: "(optional) Profile name to use for this call; defaults to the profile selected via 'use'", + typeLabel: "string", + requiredLabel: "", + }); + entries.push({ key: "-h, --help", desc: "Show help", @@ -153,7 +210,7 @@ async function runApiCommand( return; } - const { flags } = parseArgs(args); + const { flags } = parseArgs(commandArgs); const missingRequired = command.options .filter((opt) => opt.required) @@ -576,6 +633,12 @@ export async function run(argv: string[], options?: RunOptions): Promise<void> { .scriptName("ocli") .version(VERSION) .exitProcess(false) + .fail((msg, err) => { + if (err) { + throw err; + } + throw new Error(msg); + }) .command( "onboard", "Add a new profile (alias for profiles add default)", @@ -661,12 +724,15 @@ export async function run(argv: string[], options?: RunOptions): Promise<void> { type: "number", default: 10, description: "Maximum number of results when using query or regex filters", + }) + .option("profile", { + alias: "p", + type: "string", + description: "Profile name to use (defaults to the profile selected via 'use')", }), async (args) => { - const profile = profileStore.getCurrentProfile(cwd); - if (!profile) { - throw new Error("No current profile configured"); - } + const overrideName = args.profile as string | undefined; + const profile = resolveProfile(profileStore, cwd, overrideName); const spec = await openapiLoader.loadSpec(profile); const commands = openapiToCommands.buildCommands(spec, profile); if (commands.length === 0) { diff --git a/src/version.ts b/src/version.ts index d2e86fc..3f9e765 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1,4 @@ -export const VERSION = "v0.1.0"; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const pkg = require("../package.json") as { version: string }; + +export const VERSION = process.env.OCLI_VERSION ?? `v${pkg.version}`; diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 084d409..5be9800 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -1438,4 +1438,249 @@ describe("cli", () => { const config = capturedConfigs[0] as { url: string }; expect(config.url).toBe("https://path-override.example.com/data"); }); + + describe("--profile/-p flag", () => { + function createTwoProfileDeps() { + const localDir = `${cwd}/.ocli`; + const profilesPath = `${localDir}/profiles.ini`; + const cacheA = `${localDir}/specs/api-a.json`; + const cacheB = `${localDir}/specs/api-b.json`; + + const specA = { + openapi: "3.0.0", + paths: { + "/ping": { get: { summary: "Ping A" } }, + }, + }; + const specB = { + openapi: "3.0.0", + paths: { + "/ping": { get: { summary: "Ping B" } }, + "/only-b": { get: { summary: "Only available on B" } }, + }, + }; + + const iniContent = [ + "[api-a]", + "api_base_url = https://a.example.com", + "api_basic_auth = ", + "api_bearer_token = ", + "openapi_spec_source = /spec-a.json", + `openapi_spec_cache = ${cacheA}`, + "include_endpoints = ", + "exclude_endpoints = ", + "", + "[api-b]", + "api_base_url = https://b.example.com", + "api_basic_auth = ", + "api_bearer_token = ", + "openapi_spec_source = /spec-b.json", + `openapi_spec_cache = ${cacheB}`, + "include_endpoints = ", + "exclude_endpoints = ", + "", + ].join("\n"); + + const capturedConfigs: unknown[] = []; + const fakeHttpClient: HttpClient = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + request: async (config: any) => { + capturedConfigs.push(config); + return { status: 200, statusText: "OK", headers: {}, config, data: { ok: true } }; + }, + }; + + const { profileStore, openapiLoader } = createCliDeps(cwd, homeDir, { + [profilesPath]: iniContent, + [cacheA]: JSON.stringify(specA), + [cacheB]: JSON.stringify(specB), + [`${localDir}/current`]: "api-a", + }); + + return { profileStore, openapiLoader, fakeHttpClient, capturedConfigs }; + } + + it("uses --profile to override the current profile when invoking an API command", async () => { + const { profileStore, openapiLoader, fakeHttpClient, capturedConfigs } = createTwoProfileDeps(); + + await run(["ping", "--profile", "api-b"], { + cwd, + profileStore, + openapiLoader, + httpClient: fakeHttpClient, + stdout: () => {}, + }); + + expect(capturedConfigs).toHaveLength(1); + const config = capturedConfigs[0] as { url: string }; + expect(config.url).toBe("https://b.example.com/ping"); + }); + + it("uses -p short alias to override the current profile", async () => { + const { profileStore, openapiLoader, fakeHttpClient, capturedConfigs } = createTwoProfileDeps(); + + await run(["ping", "-p", "api-b"], { + cwd, + profileStore, + openapiLoader, + httpClient: fakeHttpClient, + stdout: () => {}, + }); + + expect(capturedConfigs).toHaveLength(1); + const config = capturedConfigs[0] as { url: string }; + expect(config.url).toBe("https://b.example.com/ping"); + }); + + it("resolves an endpoint only present in the overridden profile", async () => { + const { profileStore, openapiLoader, fakeHttpClient, capturedConfigs } = createTwoProfileDeps(); + + await run(["only-b", "--profile", "api-b"], { + cwd, + profileStore, + openapiLoader, + httpClient: fakeHttpClient, + stdout: () => {}, + }); + + expect(capturedConfigs).toHaveLength(1); + const config = capturedConfigs[0] as { url: string }; + expect(config.url).toBe("https://b.example.com/only-b"); + }); + + it("falls back to the current profile when --profile is omitted", async () => { + const { profileStore, openapiLoader, fakeHttpClient, capturedConfigs } = createTwoProfileDeps(); + + await run(["ping"], { + cwd, + profileStore, + openapiLoader, + httpClient: fakeHttpClient, + stdout: () => {}, + }); + + expect(capturedConfigs).toHaveLength(1); + const config = capturedConfigs[0] as { url: string }; + expect(config.url).toBe("https://a.example.com/ping"); + }); + + it("throws a clear error when --profile names a profile that does not exist", async () => { + const { profileStore, openapiLoader, fakeHttpClient } = createTwoProfileDeps(); + + await expect( + run(["ping", "--profile", "missing-profile"], { + cwd, + profileStore, + openapiLoader, + httpClient: fakeHttpClient, + stdout: () => {}, + }) + ).rejects.toThrow("Profile not found: missing-profile"); + }); + + it("does not leak --profile flag into request body or query parameters", async () => { + const { profileStore, openapiLoader, fakeHttpClient, capturedConfigs } = createPostApiDeps(); + + await run( + [ + "org_slug_repo_slug_ci_workflows_workflow_name_trigger", + "--profile", "body-api", + "--org_slug", "myorg", + "--repo_slug", "myrepo", + "--workflow_name", "deploy", + "--revision", "main", + ], + { cwd, profileStore, openapiLoader, httpClient: fakeHttpClient, stdout: () => {} } + ); + + expect(capturedConfigs).toHaveLength(1); + const config = capturedConfigs[0] as { url: string; data: Record<string, unknown> }; + expect(config.url).not.toContain("profile="); + expect(config.url).not.toContain("body-api"); + expect(config.data).toEqual({ revision: "main" }); + }); + + it("does not leak -p short alias into request body or query parameters", async () => { + const { profileStore, openapiLoader, fakeHttpClient, capturedConfigs } = createPostApiDeps(); + + await run( + [ + "org_slug_repo_slug_ci_workflows_workflow_name_trigger", + "-p", "body-api", + "--org_slug", "myorg", + "--repo_slug", "myrepo", + "--workflow_name", "deploy", + "--revision", "main", + ], + { cwd, profileStore, openapiLoader, httpClient: fakeHttpClient, stdout: () => {} } + ); + + expect(capturedConfigs).toHaveLength(1); + const config = capturedConfigs[0] as { data: Record<string, unknown> }; + expect(config.data).toEqual({ revision: "main" }); + }); + + it("commands --profile lists commands for the named profile, not the current one", async () => { + const { profileStore, openapiLoader } = createTwoProfileDeps(); + const log: string[] = []; + + await run(["commands", "--profile", "api-b"], { + cwd, + profileStore, + openapiLoader, + stdout: (msg: string) => log.push(msg), + }); + + const out = log.join(""); + expect(out).toContain("Available commands for profile api-b"); + expect(out).toContain("only-b"); + expect(out).toContain("Only available on B"); + }); + + it("commands -p short alias works the same as --profile", async () => { + const { profileStore, openapiLoader } = createTwoProfileDeps(); + const log: string[] = []; + + await run(["commands", "-p", "api-b"], { + cwd, + profileStore, + openapiLoader, + stdout: (msg: string) => log.push(msg), + }); + + const out = log.join(""); + expect(out).toContain("Available commands for profile api-b"); + expect(out).toContain("only-b"); + }); + + it("commands --profile errors when the named profile is unknown", async () => { + const { profileStore, openapiLoader } = createTwoProfileDeps(); + + await expect( + run(["commands", "--profile", "missing-profile"], { + cwd, + profileStore, + openapiLoader, + stdout: () => {}, + }) + ).rejects.toThrow("Profile not found: missing-profile"); + }); + + it("--help for a dynamic API command lists --profile/-p", async () => { + const { profileStore, openapiLoader, fakeHttpClient } = createTwoProfileDeps(); + const log: string[] = []; + + await run(["ping", "--help"], { + cwd, + profileStore, + openapiLoader, + httpClient: fakeHttpClient, + stdout: (msg: string) => log.push(msg), + }); + + const out = log.join(""); + expect(out).toContain("--profile"); + expect(out).toContain("-p"); + }); + }); }); diff --git a/tests/version.test.ts b/tests/version.test.ts new file mode 100644 index 0000000..15d27c6 --- /dev/null +++ b/tests/version.test.ts @@ -0,0 +1,31 @@ +describe("VERSION", () => { + const originalEnv = process.env.OCLI_VERSION; + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.OCLI_VERSION; + } else { + process.env.OCLI_VERSION = originalEnv; + } + jest.resetModules(); + }); + + it("falls back to v<package.json version> when OCLI_VERSION is not set", () => { + delete process.env.OCLI_VERSION; + jest.resetModules(); + + const { VERSION } = require("../src/version") as { VERSION: string }; + const pkg = require("../package.json") as { version: string }; + + expect(VERSION).toBe(`v${pkg.version}`); + }); + + it("uses OCLI_VERSION env var when set", () => { + process.env.OCLI_VERSION = "v9.9.9-test"; + jest.resetModules(); + + const { VERSION } = require("../src/version") as { VERSION: string }; + + expect(VERSION).toBe("v9.9.9-test"); + }); +});