Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/fix-option-parsing.md
Original file line number Diff line number Diff line change
@@ -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
35 changes: 35 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ sequenceDiagram
API-->>CLI: ✓ Transformation created

Note over User,CLI: Step 4: Create Pipeline
User->>CLI: pipelines create --name my_pipeline<br/>--transformation my_transform<br/>--filter my_filter<br/>--networks base_sepolia<br/>--webhook-url https://...
User->>CLI: pipelines create my_pipeline<br/>--transformation my_transform<br/>--filter my_filter<br/>--networks base_sepolia<br/>--webhook-url https://...
CLI->>API: POST /dw/pipelines
API-->>CLI: ✓ Pipeline active
```
Expand Down
2 changes: 1 addition & 1 deletion src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 2 additions & 4 deletions src/commands/pipelines.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
),
Expand Down Expand Up @@ -318,5 +316,5 @@ export const pipelinesCommand = Command.make("pipelines").pipe(
pipelinesDeleteCommand,
pipelinesRmCommand,
pipelinesRemoveCommand
]),
])
)
2 changes: 1 addition & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
66 changes: 60 additions & 6 deletions src/lib/argGuards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>): string | undefined => {
const firstNonOption = argv.find((arg) => !arg.startsWith("-"))
Expand All @@ -35,19 +62,46 @@ export const validateArgumentOrder = (argv: Array<string>): 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
}
continue
}
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
Expand All @@ -57,7 +111,7 @@ export const validateArgumentOrder = (argv: Array<string>): 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.`,
Expand Down
35 changes: 35 additions & 0 deletions test/cli-usability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})
})
9 changes: 7 additions & 2 deletions test/filters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
21 changes: 12 additions & 9 deletions test/pipelines.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -113,14 +118,12 @@ describe("pipelines commands", () => {
Effect.gen(function*() {
const captured: Array<HttpClientRequest.HttpClientRequest> = []
const client = makeMockHttpClient((request) => captured.push(request))

const runWith = (cmd: Command.Command<typeof pipelinesDeleteCommand>) =>
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) => {
Expand Down
2 changes: 1 addition & 1 deletion test/transformations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading