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
5 changes: 5 additions & 0 deletions .changeset/clear-shell-attribution.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 16 additions & 0 deletions packages/cli/docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<CLI version>`.
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` |
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/lib/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,9 @@ const COMMAND_GROUPS = {
};

const COMMON_OPTION_NAMES = new Set([
"source",
"integration",
"integration-version",
"base-url",
"broker-base-url",
"server-url",
Expand Down Expand Up @@ -262,6 +265,9 @@ const KNOWN_OPTION_NAMES = new Set([
]);

const COMMON_HELP = `Global options (accepted by every command):
--source <name> Override CALLE_SOURCE attribution
--integration <name> Override CALLE_INTEGRATION attribution
--integration-version <ver> Override CALLE_INTEGRATION_VERSION attribution
--base-url <url> Default: ${DEFAULT_BASE_URL}
--broker-base-url <url> Default: --base-url
--server-url <url> Default: <base-url>/mcp/<channel>
Expand Down
18 changes: 17 additions & 1 deletion packages/cli/lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
35 changes: 35 additions & 0 deletions packages/cli/test/cli.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
11 changes: 7 additions & 4 deletions packages/cli/test/e2e/cli-e2e.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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);
Expand All @@ -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);

Expand All @@ -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");
Expand All @@ -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);
Expand Down