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({