diff --git a/.changeset/clear-shell-attribution.md b/.changeset/clear-shell-attribution.md new file mode 100644 index 0000000..0022efa --- /dev/null +++ b/.changeset/clear-shell-attribution.md @@ -0,0 +1,5 @@ +--- +"@call-e/cli": patch +--- + +Accept `--source`, `--integration`, and `--integration-version` as per-invocation attribution overrides. Validate values before requests and preserve environment-variable compatibility. diff --git a/packages/cli/docs/cli-reference.md b/packages/cli/docs/cli-reference.md index bda7a0a..07b28e6 100644 --- a/packages/cli/docs/cli-reference.md +++ b/packages/cli/docs/cli-reference.md @@ -202,12 +202,28 @@ with boolean `retry_safe` and boolean-or-`"unknown"` `call_started` guidance. ## Common Options +Use `--source`, `--integration`, and `--integration-version` in the request's +`argv` array to override integration attribution for one invocation. Each option +overrides its matching `CALLE_SOURCE`, `CALLE_INTEGRATION`, or +`CALLE_INTEGRATION_VERSION` environment variable, including values set by the +launcher. Existing environment-based integrations continue to work. + +Use letters, numbers, dots, underscores, plus signs, or hyphens in these values. +Empty or invalid option values return `invalid_arguments` before requests are +sent. With no attribution supplied, the CLI uses `cli/cli/`. +When only part of the context is supplied, missing fields become `unknown`. +Include the same options in each invocation, including follow-up `*_argv` +requests; the CLI does not change the parent environment. + These options are accepted by all commands. Runtime configuration is resolved before command dispatch; some commands only use the subset relevant to their network requests or output. | Option | Value | Default | Applies to | Required | Repeatable | Purpose | Example | | --- | --- | --- | --- | --- | --- | --- | --- | +| `--source` | Attribution segment | `CALLE_SOURCE` or `cli` | All commands | No | No | Set the calling agent's source. | `calle auth status --source codex` | +| `--integration` | Attribution segment | `CALLE_INTEGRATION` or `cli` | All commands | No | No | Set the integration name. | `calle auth status --integration codex_plugin` | +| `--integration-version` | Attribution segment | `CALLE_INTEGRATION_VERSION` or CLI version | All commands | No | No | Set the calling integration's version. | `calle auth status --integration-version 1.0.0` | | `--help`, `-h` | Boolean | `false` | Every command level | No | No | Print help for the current root, group, or subcommand and exit. | `calle call plan --help` | | `--version`, `-V` | Boolean | `false` | Every command level | No | No | Print the installed CLI version and exit. | `calle --version` | | `--base-url` | URL | `https://seleven-mcp-sg.airudder.com` | All commands | No | No | Base CALL-E service URL used to derive broker, auth, MCP, and telemetry URLs unless those are set separately. | `calle mcp tools --base-url https://example.test` | diff --git a/packages/cli/lib/cli.js b/packages/cli/lib/cli.js index bb7b2e7..10bfce7 100644 --- a/packages/cli/lib/cli.js +++ b/packages/cli/lib/cli.js @@ -221,6 +221,9 @@ const COMMAND_GROUPS = { }; const COMMON_OPTION_NAMES = new Set([ + "source", + "integration", + "integration-version", "base-url", "broker-base-url", "server-url", @@ -262,6 +265,9 @@ const KNOWN_OPTION_NAMES = new Set([ ]); const COMMON_HELP = `Global options (accepted by every command): + --source Override CALLE_SOURCE attribution + --integration Override CALLE_INTEGRATION attribution + --integration-version Override CALLE_INTEGRATION_VERSION attribution --base-url Default: ${DEFAULT_BASE_URL} --broker-base-url Default: --base-url --server-url Default: /mcp/ diff --git a/packages/cli/lib/config.js b/packages/cli/lib/config.js index 5c4b49f..82cae55 100644 --- a/packages/cli/lib/config.js +++ b/packages/cli/lib/config.js @@ -128,6 +128,18 @@ function normalizeIntegrationSegment(value) { return cleaned; } +function integrationOption(value, fallback, flag) { + const provided = firstOptionValue(value); + if (provided === undefined) { + return fallback; + } + const normalized = normalizeIntegrationSegment(provided); + if (!normalized) { + throw new Error(`${flag} expects letters, numbers, dots, underscores, plus signs, or hyphens.`); + } + return normalized; +} + export function resolveIntegrationContext(env = {}, cliVersion = CLI_VERSION) { const source = normalizeIntegrationSegment(env.CALLE_SOURCE); const integration = normalizeIntegrationSegment(env.CALLE_INTEGRATION); @@ -160,7 +172,11 @@ export function resolveRuntimeConfig(options = {}, env = process.env) { const baseUrl = normalizeBaseUrl(options.baseUrl || DEFAULT_BASE_URL); const channel = options.channel || DEFAULT_CHANNEL; const serverUrl = resolveServerUrl({ serverUrl: options.serverUrl, baseUrl, channel }); - const integrationContext = resolveIntegrationContext(env, CLI_VERSION); + const integrationContext = resolveIntegrationContext({ + CALLE_SOURCE: integrationOption(options.source, env.CALLE_SOURCE, "--source"), + CALLE_INTEGRATION: integrationOption(options.integration, env.CALLE_INTEGRATION, "--integration"), + CALLE_INTEGRATION_VERSION: integrationOption(options.integrationVersion, env.CALLE_INTEGRATION_VERSION, "--integration-version"), + }, CLI_VERSION); return { cliVersion: CLI_VERSION, integrationContext, diff --git a/packages/cli/test/cli.test.js b/packages/cli/test/cli.test.js index 6fcb880..06f8401 100644 --- a/packages/cli/test/cli.test.js +++ b/packages/cli/test/cli.test.js @@ -701,6 +701,41 @@ test("auth login forwards upstream integration context from environment", async assert.deepEqual(mcpMethods, ["initialize", "notifications/initialized", "tools/list"]); }); +test("attribution options override environment values without changing the environment", async (t) => { + const cacheRoot = makeTempRoot("calle-cli-attribution-options"); + t.after(() => fs.rmSync(cacheRoot, { recursive: true, force: true })); + const env = { CALLE_SOURCE: "old", CALLE_INTEGRATION: "legacy", CALLE_INTEGRATION_VERSION: "0.1.0" }; + const events = []; + const result = await run([ + "auth", "status", "--cache-root", cacheRoot, + "--source", "codex", "--integration=codex_plugin", "--integration-version", "1.2.3-beta.1+test", + ], { env: { ...env, CALLE_TELEMETRY: "1" }, telemetryFetchImpl: captureTelemetry(events) }); + + assert.equal(result.code, 0, result.stderr); + assert.deepEqual(events[0].payload.context.integration_context, { + source: "codex", integration: "codex_plugin", version: "1.2.3-beta.1+test", + }); + assert.equal(resolveRuntimeConfig({ source: "codex" }, env).integrationHeader, "codex/legacy/0.1.0"); + assert.deepEqual(env, { CALLE_SOURCE: "old", CALLE_INTEGRATION: "legacy", CALLE_INTEGRATION_VERSION: "0.1.0" }); + assert.equal(resolveRuntimeConfig({}, {}).integrationHeader, defaultIntegrationHeader); + assert.equal(resolveRuntimeConfig({ source: "codex" }, {}).integrationHeader, "codex/unknown/unknown"); + + for (const flag of ["--source", "--integration", "--integration-version"]) { + for (const value of ["", "bad/value", "bad value", "bad\r\nheader"]) { + const invalid = await run(["auth", "status", flag, value], { + fetchImpl: () => assert.fail("invalid attribution must not reach the server"), + }); + assert.equal(invalid.code, 2); + const payload = JSON.parse(invalid.stdout); + assert.equal(payload.error.code, "invalid_arguments"); + assert.ok(payload.error.message.includes(`${flag} expects`), payload.error.message); + } + const missing = await run(["auth", "status", flag]); + assert.equal(missing.code, 2); + assert.ok(JSON.parse(missing.stdout).error.message.includes(`Missing value for ${flag}`)); + } +}); + test("auth login resumes a pending login without creating a new session", async () => { const cacheRoot = makeTempRoot("calle-cli-pending"); const serverUrl = "https://mcp.example/mcp/openagent_oauth"; diff --git a/packages/cli/test/e2e/cli-e2e.test.js b/packages/cli/test/e2e/cli-e2e.test.js index c1271ba..494cfa8 100644 --- a/packages/cli/test/e2e/cli-e2e.test.js +++ b/packages/cli/test/e2e/cli-e2e.test.js @@ -644,7 +644,8 @@ test("starts a call without exposing plan confirmation data", async (t) => { for (const command of ["start", "run"]) { test(`recovers call ${command} through a verified entry despite PATH shadowing and lost HTTP responses`, async (t) => { - const fake = await startFakeServer({ droppedRunResponses: 2 }); + const attribution = ["--source", "test_agent", "--integration", "test_plugin", "--integration-version", "1.0.0"]; + const fake = await startFakeServer({ droppedRunResponses: 2, integrationHeader: "test_agent/test_plugin/1.0.0" }); const cacheParent = makeTempCacheRoot(); const cacheRoot = path.join(cacheParent, "recovery cache"); t.after(() => fake.close()); @@ -665,13 +666,14 @@ for (const command of ["start", "run"]) { const cliOptions = { entry, env: { PATH: [fakeBin, process.env.PATH].join(path.delimiter) } }; const help = await runCalle(["--help"], cliOptions); assert.equal(help.code, 0); - for (const commandName of ["auth login", "mcp tools", "call run", "call recover"]) { + for (const commandName of ["auth login", "mcp tools", "call run", "call recover", "--source", "--integration", "--integration-version"]) { assert.ok(help.stdout.includes(commandName)); } writeToken(cacheRoot, fake.baseUrl); const auth = await runCalle([ "auth", "status", "--base-url", fake.baseUrl, "--cache-root", cacheRoot, "--no-telemetry", + ...attribution, ], cliOptions); assert.equal(auth.code, 0); assert.equal(parseJson(auth.stdout).usable, true); @@ -684,6 +686,7 @@ for (const command of ["start", "run"]) { "--timezone", "Asia/Shanghai", "--base-url", fake.baseUrl, "--cache-root", cacheRoot, + ...attribution, ], cliOptions); const firstPayload = parseJson(first.stdout); @@ -710,7 +713,7 @@ for (const command of ["start", "run"]) { const quotedCacheRoot = `'${cacheRoot.replaceAll("'", "'\\''")}'`; assert.equal(firstPayload.next_command, ["calle", ...recoveryArgs.slice(0, -1), quotedCacheRoot].join(" ")); assert.deepEqual(firstPayload.next_argv, recoveryArgs); - const uncertain = await runCalle(firstPayload.next_argv, cliOptions); + const uncertain = await runCalle([...firstPayload.next_argv, ...attribution], cliOptions); const uncertainPayload = parseJson(uncertain.stdout); assert.equal(uncertain.code, 1); assert.equal(uncertainPayload.stage, "run_call"); @@ -722,7 +725,7 @@ for (const command of ["start", "run"]) { assert.equal(fake.state.acceptedRuns.length, 1); assert.deepEqual(uncertainPayload.next_argv, recoveryArgs); - const recovered = await runCalle(uncertainPayload.next_argv, cliOptions); + const recovered = await runCalle([...uncertainPayload.next_argv, ...attribution], cliOptions); const recoveredPayload = parseJson(recovered.stdout); assert.equal(recovered.code, 0); assert.equal(recoveredPayload.ok, true);