diff --git a/.changeset/fix-option-parsing.md b/.changeset/fix-option-parsing.md new file mode 100644 index 0000000..5b11804 --- /dev/null +++ b/.changeset/fix-option-parsing.md @@ -0,0 +1,9 @@ +--- +"@dtechvision/indexingco-cli": patch +--- + +Fixed CLI argument parsing for commands with many options + +- Fixed a bug where option values (e.g., `--transformation my-value`) were incorrectly detected as positional arguments, causing a misleading hint about argument ordering +- Unified `pipelines create` to use positional `name` argument (at the end) instead of `--name` option, matching the pattern used by `filters create` and `transformations create` +- Added CLAUDE.md with development guidance for future CLI option additions diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..dc765c5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,35 @@ +# CLAUDE.md + +This file provides guidance for Claude Code when working on this project. + +## Build Commands + +- `bun run build` - Build the CLI +- `bun run lint:tsc` - Type-check with TypeScript +- `bun ./node_modules/.bin/vitest run` - Run all tests +- `bun ./node_modules/.bin/vitest run test/cli-usability.test.ts` - Run CLI usability tests + +## Architecture Notes + +### CLI Argument Validation (`src/lib/argGuards.ts`) + +This file contains pure helper functions for validating CLI arguments and providing helpful error messages. + +**Important:** The `optionsWithValues` array lists all options that take a value argument. This is used to correctly skip option values when detecting positional arguments. + +When adding new command options: +1. **Options with values** (e.g., `--network base_sepolia`): Add to `optionsWithValues` array +2. **Boolean options** (e.g., `--all`, `--verbose`): Do NOT add to the array +3. Add tests in `test/cli-usability.test.ts` for any new validation behavior + +If you forget to add a value-taking option to `optionsWithValues`, users may see a false "options after positionals" warning when using that option. + +### Command Structure + +Commands are defined in `src/commands/`: +- `pipelines.ts` - Pipeline CRUD and testing +- `filters.ts` - Filter CRUD operations +- `transformations.ts` - Transformation CRUD and testing +- `hello.ts` - Simple hello world command + +Each command uses Effect's `@effect/cli` for parsing and `@effect/platform` for HTTP requests. diff --git a/README.md b/README.md index 1470b95..462807f 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ sequenceDiagram API-->>CLI: ✓ Transformation created Note over User,CLI: Step 4: Create Pipeline - User->>CLI: pipelines create --name my_pipeline
--transformation my_transform
--filter my_filter
--networks base_sepolia
--webhook-url https://... + User->>CLI: pipelines create my_pipeline
--transformation my_transform
--filter my_filter
--networks base_sepolia
--webhook-url https://... CLI->>API: POST /dw/pipelines API-->>CLI: ✓ Pipeline active ``` diff --git a/src/bin.ts b/src/bin.ts index 5d25004..c182366 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -4,10 +4,10 @@ import * as NodeContext from "@effect/platform-node/NodeContext" import * as NodeRuntime from "@effect/platform-node/NodeRuntime" import * as FetchHttpClient from "@effect/platform/FetchHttpClient" import * as Config from "effect/Config" +import * as ConfigProvider from "effect/ConfigProvider" import * as Effect from "effect/Effect" import * as Layer from "effect/Layer" import { run } from "./Cli.js" -import * as ConfigProvider from "effect/ConfigProvider" import { validateArgumentOrder, validateTopLevelCommand } from "./lib/argGuards.js" const argv = process.argv.slice(2) diff --git a/src/commands/pipelines.ts b/src/commands/pipelines.ts index 438a87b..675e611 100644 --- a/src/commands/pipelines.ts +++ b/src/commands/pipelines.ts @@ -37,9 +37,7 @@ export const pipelinesCreateCommand = Command.make( "create", { apiKey: apiKeyOption, - name: Options.text("name").pipe( - Options.withDescription("Name of the pipeline") - ), + name: Args.text({ name: "name" }).pipe(Args.withDescription("Name of the pipeline")), transformation: Options.text("transformation").pipe( Options.withDescription("Name of the transformation to use") ), @@ -318,5 +316,5 @@ export const pipelinesCommand = Command.make("pipelines").pipe( pipelinesDeleteCommand, pipelinesRmCommand, pipelinesRemoveCommand - ]), + ]) ) diff --git a/src/config.ts b/src/config.ts index fbeb8b0..92d7a67 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,7 +3,7 @@ import * as Config from "effect/Config" import * as Console from "effect/Console" import * as Effect from "effect/Effect" import * as Option from "effect/Option" -import * as Redacted from "effect/Redacted" +import type * as Redacted from "effect/Redacted" // Configuration for API key - reads from environment variable // Supports both API_KEY_INDEXINGCO (preferred) and API_KEY_INDEXING_CO (legacy) diff --git a/src/lib/argGuards.ts b/src/lib/argGuards.ts index 6892251..4eede70 100644 --- a/src/lib/argGuards.ts +++ b/src/lib/argGuards.ts @@ -15,10 +15,37 @@ const knownSubcommands = new Set([ "rm" ]) -const optionPatterns = ["--api-key", "--network", "--beat", "--hash"] +// Options that take a value (used to skip the value when detecting positional args). +// Boolean options like --all, --include-timestamps are intentionally omitted. +// Keep this list in sync when adding new options to commands. +const optionsWithValues = [ + // Global + "--api-key", + // pipelines create + "--transformation", + "--filter", + "--filter-keys", + "--networks", + "--webhook-url", + "--auth-header", + "--auth-value", + // pipelines backfill + "--network", + "--value", + "--beat-start", + "--beat-end", + "--beats", + // pipelines test / transformations test + "--beat", + "--hash", + // filters + "--values", + "--page-token", + "--prefix" +] -const matchesOption = (arg: string): string | undefined => - optionPatterns.find((opt) => arg === opt || arg.startsWith(`${opt}=`)) +const matchesOptionWithValue = (arg: string): string | undefined => + optionsWithValues.find((opt) => arg === opt || arg.startsWith(`${opt}=`)) export const validateTopLevelCommand = (argv: Array): string | undefined => { const firstNonOption = argv.find((arg) => !arg.startsWith("-")) @@ -35,12 +62,12 @@ export const validateArgumentOrder = (argv: Array): string | undefined = // Find the first true positional argument (not a subcommand, not an option) let firstPositionalIdx = -1 let firstPositionalArg = "" + let lastPositionalCandidate: string | undefined for (let i = 0; i < argv.length; i++) { const arg = argv[i] - const optionMatch = matchesOption(arg) + const optionMatch = matchesOptionWithValue(arg) if (optionMatch !== undefined) { - // Skip the next token if the option uses a separated value (e.g., --network base_sepolia) if (!arg.includes("=") && argv[i + 1] !== undefined && !argv[i + 1].startsWith("-")) { i += 1 } @@ -48,6 +75,33 @@ export const validateArgumentOrder = (argv: Array): string | undefined = } if (arg.startsWith("-")) continue if (knownSubcommands.has(arg)) continue + lastPositionalCandidate = arg + } + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] + const optionMatch = matchesOptionWithValue(arg) + if (optionMatch !== undefined) { + // Skip the next token if the option uses a separated value (e.g., --network base_sepolia) + if (!arg.includes("=") && argv[i + 1] !== undefined && !argv[i + 1].startsWith("-")) { + i += 1 + } + continue + } + if (arg.startsWith("-")) { + // For unknown options, skip potential values unless it looks like the final positional. + if ( + !arg.includes("=") && + argv[i + 1] !== undefined && + !argv[i + 1].startsWith("-") && + !knownSubcommands.has(argv[i + 1]) && + argv[i + 1] !== lastPositionalCandidate + ) { + i += 1 + } + continue + } + if (knownSubcommands.has(arg)) continue firstPositionalIdx = i firstPositionalArg = arg break @@ -57,7 +111,7 @@ export const validateArgumentOrder = (argv: Array): string | undefined = for (let j = firstPositionalIdx + 1; j < argv.length; j++) { const laterArg = argv[j] - const optionMatch = matchesOption(laterArg) + const optionMatch = matchesOptionWithValue(laterArg) if (optionMatch !== undefined) { return [ `Option '${laterArg}' must come before positional arguments.`, diff --git a/test/cli-usability.test.ts b/test/cli-usability.test.ts index e03bdde..91694cb 100644 --- a/test/cli-usability.test.ts +++ b/test/cli-usability.test.ts @@ -24,4 +24,39 @@ describe("CLI usability guards (pure)", () => { ]) expect(message).toBeUndefined() }) + + it("skips values for options listed in optionsWithValues", () => { + // This tests the fix for the bug where --transformation value was treated as positional + const message = validateArgumentOrder([ + "pipelines", + "create", + "--transformation", + "example-transformation", + "--filter", + "example-filter", + "--filter-keys", + "contract_address", + "--networks", + "BASE_SEPOLIA", + "--webhook-url", + "https://example.com/api/webhooks/events", + "example-pipeline" + ]) + expect(message).toBeUndefined() + }) + + it("warns when an unknown option would otherwise swallow the positional arg", () => { + // Unknown options might be boolean flags, so don't treat the next token as a value + // when it looks like the final positional argument. + const message = validateArgumentOrder([ + "pipelines", + "create", + "--unknown-flag", + "my-pipeline", + "--filter", + "f" + ]) + // --filter is a known option, so we get the specific error message + expect(message).toContain("must come before positional arguments") + }) }) diff --git a/test/filters.test.ts b/test/filters.test.ts index a0490d1..753a7ba 100644 --- a/test/filters.test.ts +++ b/test/filters.test.ts @@ -6,10 +6,15 @@ import * as HttpClient from "@effect/platform/HttpClient" import type * as HttpClientRequest from "@effect/platform/HttpClientRequest" import * as HttpClientResponse from "@effect/platform/HttpClientResponse" import { describe, expect, it } from "@effect/vitest" -import { beforeAll } from "vitest" import * as Effect from "effect/Effect" import * as Layer from "effect/Layer" -import { filtersCreateCommand, filtersDeleteCommand, filtersRemoveCommand, filtersRmCommand } from "../src/commands/filters.js" +import { beforeAll } from "vitest" +import { + filtersCreateCommand, + filtersDeleteCommand, + filtersRemoveCommand, + filtersRmCommand +} from "../src/commands/filters.js" const run = Command.run({ name: "indexingco-cli-test", diff --git a/test/pipelines.test.ts b/test/pipelines.test.ts index 677a20b..46823ae 100644 --- a/test/pipelines.test.ts +++ b/test/pipelines.test.ts @@ -6,10 +6,15 @@ import * as HttpClient from "@effect/platform/HttpClient" import type * as HttpClientRequest from "@effect/platform/HttpClientRequest" import * as HttpClientResponse from "@effect/platform/HttpClientResponse" import { describe, expect, it } from "@effect/vitest" -import { beforeAll } from "vitest" import * as Effect from "effect/Effect" import * as Layer from "effect/Layer" -import { pipelinesDeleteCommand, pipelinesRemoveCommand, pipelinesRmCommand, pipelinesTestCommand } from "../src/commands/pipelines.js" +import { beforeAll } from "vitest" +import { + pipelinesDeleteCommand, + pipelinesRemoveCommand, + pipelinesRmCommand, + pipelinesTestCommand +} from "../src/commands/pipelines.js" const run = Command.run({ name: "indexingco-cli-test", @@ -113,14 +118,12 @@ describe("pipelines commands", () => { Effect.gen(function*() { const captured: Array = [] const client = makeMockHttpClient((request) => captured.push(request)) - - const runWith = (cmd: Command.Command) => - run(cmd)(["--api-key", "test-key", "demo-delete"]) - const layer = Layer.merge(cliLayer, Layer.succeed(HttpClient.HttpClient, client)) - yield* Effect.provide(runWith(pipelinesDeleteCommand), layer) - yield* Effect.provide(runWith(pipelinesRemoveCommand), layer) - yield* Effect.provide(runWith(pipelinesRmCommand), layer) + const args = ["--api-key", "test-key", "demo-delete"] + + yield* Effect.provide(run(pipelinesDeleteCommand)(args), layer) + yield* Effect.provide(run(pipelinesRemoveCommand)(args), layer) + yield* Effect.provide(run(pipelinesRmCommand)(args), layer) expect(captured.length).toBe(3) captured.forEach((req) => { diff --git a/test/transformations.test.ts b/test/transformations.test.ts index b7b4844..7744541 100644 --- a/test/transformations.test.ts +++ b/test/transformations.test.ts @@ -6,9 +6,9 @@ import * as HttpClient from "@effect/platform/HttpClient" import type * as HttpClientRequest from "@effect/platform/HttpClientRequest" import * as HttpClientResponse from "@effect/platform/HttpClientResponse" import { describe, expect, it } from "@effect/vitest" -import { beforeAll } from "vitest" import * as Effect from "effect/Effect" import * as Layer from "effect/Layer" +import { beforeAll } from "vitest" import { transformationsShowCommand } from "../src/commands/transformations.js" const run = Command.run({