Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
9aa6670
feat: instrument config command
Hweinstock May 26, 2026
a977e51
fix: simplify set path by validing the partial instead of the merged
Hweinstock May 26, 2026
24de6c4
refactor: unify parsing logic with telemetry for common abstraction
Hweinstock May 26, 2026
76e6890
fix: adjust tests to include quotes for strings
Hweinstock May 27, 2026
0f33f91
docs: update display text that telemetry is now active
Hweinstock May 29, 2026
a23b8b5
fix(config): avoid overwriting config when not parsable
Hweinstock May 30, 2026
7b861f3
feat(telemetry): wire endpoint resolution with constant default and o…
Hweinstock May 30, 2026
ef5daa1
test(config): exit non-zero on corrupt config
Hweinstock Jun 1, 2026
b919c75
fix(config): show friendly error with path on config read/write failures
Hweinstock Jun 1, 2026
2df5774
refactor(telemetry): remove enable/disable subcommands in favor of ag…
Hweinstock Jun 1, 2026
e112e68
fix(config): regenerate installationId when persisted value is not a …
Hweinstock Jun 1, 2026
fd9606e
fix(telemetry): suppress first-run notice when telemetry is disabled
Hweinstock Jun 1, 2026
073fedc
test(integ): isolate spawned CLI from host telemetry env
Hweinstock Jun 1, 2026
a2f92f5
fix(telemetry): use .jsonl extension for audit files
Hweinstock Jun 1, 2026
b11dff7
docs(telemetry): correct stale comment on OtelMetricSink try/catch
Hweinstock Jun 1, 2026
01ea06c
test(config): remove redundant integ tests and fix type error
Hweinstock Jun 1, 2026
16042d4
docs: strip noisy comments from telemetry instrumentation
Hweinstock Jun 1, 2026
75b9a58
fix(test): update telemetry helper to search the correct files
Hweinstock Jun 1, 2026
f60789d
chore(telemetry): remove dead commands from schema
Hweinstock Jun 1, 2026
95ccc8f
docs: remove double quote in comment
Hweinstock Jun 1, 2026
a59d425
fix: swap telemetry tests to existing metric
Hweinstock Jun 1, 2026
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
10 changes: 5 additions & 5 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -1339,13 +1339,13 @@ agentcore update cli --check # Same as `agentcore update --check`
Manage anonymous usage analytics preferences. Telemetry is opt-in and used to improve the CLI.

```bash
agentcore telemetry status # Show current preference and where it was set
agentcore telemetry enable # Opt in
agentcore telemetry disable # Opt out
agentcore telemetry status # Show current preference and where it was set
agentcore config telemetry.enabled true # Opt in
agentcore config telemetry.enabled false # Opt out
```

`enable`, `disable`, and `status` take no flags beyond `-h, --help`. The preference is stored in your global CLI config
and persists across projects.
`status` takes no flags beyond `-h, --help`. The preference is stored in your global CLI config and persists across
projects.

### help

Expand Down
142 changes: 142 additions & 0 deletions integ-tests/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { spawnAndCollect } from '../src/test-utils/cli-runner.js';
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, describe, expect, it } from 'vitest';

const testConfigDir = mkdtempSync(join(tmpdir(), 'agentcore-config-integ-'));
const cliPath = join(__dirname, '..', 'dist', 'cli', 'index.mjs');

function run(args: string[]) {
return spawnAndCollect('node', [cliPath, ...args], tmpdir(), {
AGENTCORE_SKIP_INSTALL: '1',
AGENTCORE_CONFIG_DIR: testConfigDir,
});
}

function readConfig() {
return JSON.parse(readFileSync(join(testConfigDir, 'config.json'), 'utf-8'));
}

describe('config command', () => {
afterAll(() => rm(testConfigDir, { recursive: true, force: true }));

it('lists config with installationId when fresh', async () => {
const result = await run(['config']);
expect(result.exitCode).toBe(0);
const parsed = JSON.parse(result.stdout);
expect(parsed.installationId).toBeDefined();
});

it('sets a string value', async () => {
const result = await run(['config', 'uvIndex', 'https://example.com']);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('Set uvIndex = https://example.com');
expect(readConfig().uvIndex).toBe('https://example.com');
});

it('gets a value', async () => {
const result = await run(['config', 'uvIndex']);
expect(result.exitCode).toBe(0);
expect(result.stdout.trim()).toBe('"https://example.com"');
});

it('sets a nested value with dot notation', async () => {
const result = await run(['config', 'telemetry.endpoint', 'https://metrics.example.com']);
expect(result.exitCode).toBe(0);
expect(readConfig().telemetry.endpoint).toBe('https://metrics.example.com');
});

it('gets a nested value with dot notation', async () => {
const result = await run(['config', 'telemetry.endpoint']);
expect(result.exitCode).toBe(0);
expect(result.stdout.trim()).toBe('"https://metrics.example.com"');
});

it('gets an object value as JSON', async () => {
const result = await run(['config', 'telemetry']);
expect(result.exitCode).toBe(0);
const parsed = JSON.parse(result.stdout);
expect(parsed.endpoint).toBe('https://metrics.example.com');
});

it('sets a boolean value via JSON parsing', async () => {
const result = await run(['config', 'telemetry.enabled', 'true']);
expect(result.exitCode).toBe(0);
expect(readConfig().telemetry.enabled).toBe(true);
});

it('sets a numeric value via JSON parsing', async () => {
const result = await run(['config', 'transactionSearchIndexPercentage', '50']);
expect(result.exitCode).toBe(0);
expect(readConfig().transactionSearchIndexPercentage).toBe(50);
});

it('rejects invalid value for a typed key', async () => {
const result = await run(['config', 'telemetry.enabled', 'notabool']);
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain('Invalid value');
});

it('rejects unknown keys', async () => {
const result = await run(['config', 'foo.bar.baz', 'hello']);
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain('Invalid value');
});

it('returns error for unset key', async () => {
const result = await run(['config', 'disableTransactionSearch']);
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain('is not set');
});

it('lists all config after mutations', async () => {
const result = await run(['config']);
expect(result.exitCode).toBe(0);
const parsed = JSON.parse(result.stdout);
expect(parsed.uvIndex).toBe('https://example.com');
expect(parsed.telemetry.endpoint).toBe('https://metrics.example.com');
});

describe('corrupt config file', () => {
const corruptDir = mkdtempSync(join(tmpdir(), 'agentcore-config-corrupt-'));
const corruptFile = join(corruptDir, 'config.json');

afterAll(() => rm(corruptDir, { recursive: true, force: true }));

function runCorrupt(args: string[]) {
return spawnAndCollect('node', [cliPath, ...args], tmpdir(), {
AGENTCORE_SKIP_INSTALL: '1',
AGENTCORE_CONFIG_DIR: corruptDir,
});
}

it('exits non-zero with a clear error when listing a corrupt config', async () => {
writeFileSync(corruptFile, '{ this is not valid json');

const result = await runCorrupt(['config']);

expect(result.exitCode).toBe(1);
expect(result.stderr).toContain(`Error: Unable to parse config file at ${corruptFile}`);
});

it('exits non-zero with a clear error when getting a key from a non-object config', async () => {
writeFileSync(corruptFile, '"a string"');

const result = await runCorrupt(['config', 'telemetry.enabled']);

expect(result.exitCode).toBe(1);
expect(result.stderr).toContain(`Error: Unable to parse config file at ${corruptFile}`);
});
});

describe('installationId validation', () => {
it('rejects setting installationId to a non-UUID value', async () => {
const result = await run(['config', 'installationId', 'my-custom-id']);

expect(result.exitCode).toBe(1);
expect(result.stderr).toContain('Invalid value');
});
});
});
111 changes: 94 additions & 17 deletions src/cli/__tests__/global-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from '../../lib/schemas/io/global-config';
import { createTempConfig } from './helpers/temp-config';
import { readFile, writeFile } from 'fs/promises';
import assert from 'node:assert';
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: we import and use assert here because it provides type narrowing and avoids verbosity in follow-up assertions on result types.

import { afterAll, beforeEach, describe, expect, it } from 'vitest';

const tmp = createTempConfig('gc');
Expand All @@ -15,19 +16,35 @@ describe('global-config', () => {
afterAll(() => tmp.cleanup());

describe('readGlobalConfig', () => {
it('returns parsed config when file exists', async () => {
it('returns success with parsed config when file exists', async () => {
await writeFile(tmp.configFile, JSON.stringify({ telemetry: { enabled: false } }));

const config = await readGlobalConfig(tmp.configFile);
const result = await readGlobalConfig(tmp.configFile);

expect(config).toEqual({ telemetry: { enabled: false } });
expect(result).toEqual({ success: true, config: { telemetry: { enabled: false } } });
});

it('returns empty object when file is missing or invalid', async () => {
expect(await readGlobalConfig(tmp.testDir + '/nonexistent.json')).toEqual({});
it('returns success with empty config when file is missing', async () => {
const result = await readGlobalConfig(tmp.testDir + '/nonexistent.json');

expect(result).toEqual({ success: true, config: {} });
});

it('returns failure when file is malformed JSON', async () => {
await writeFile(tmp.configFile, 'not json');
expect(await readGlobalConfig(tmp.configFile)).toEqual({});

const result = await readGlobalConfig(tmp.configFile);

assert(!result.success);
expect(result.error).toBeInstanceOf(Error);
});

it('returns failure when JSON is valid but not an object', async () => {
await writeFile(tmp.configFile, '"a string"');

const result = await readGlobalConfig(tmp.configFile);

assert(!result.success);
});

it('drops invalid fields while preserving valid ones', async () => {
Expand All @@ -40,9 +57,10 @@ describe('global-config', () => {
})
);

const config = await readGlobalConfig(tmp.configFile);
const result = await readGlobalConfig(tmp.configFile);

expect(config).toEqual({
assert(result.success);
expect(result.config).toEqual({
transactionSearchIndexPercentage: undefined,
uvIndex: 'https://valid.url',
telemetry: { enabled: undefined, endpoint: 'https://example.com' },
Expand All @@ -51,15 +69,16 @@ describe('global-config', () => {

it('preserves unknown fields via passthrough', async () => {
const full = {
installationId: 'abc-123',
installationId: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
telemetry: { enabled: true, endpoint: 'https://example.com', audit: false },
futureField: 'hello',
};
await writeFile(tmp.configFile, JSON.stringify(full));

const config = await readGlobalConfig(tmp.configFile);
const result = await readGlobalConfig(tmp.configFile);

expect(config).toEqual(full);
assert(result.success);
expect(result.config).toEqual(full);
});
});

Expand Down Expand Up @@ -92,16 +111,17 @@ describe('global-config', () => {
});

it('deep-merges telemetry sub-object with existing config', async () => {
const validUuid = 'f47ac10b-58cc-4372-a567-0e02b2c3d479';
await writeFile(
tmp.configFile,
JSON.stringify({ installationId: 'keep-me', telemetry: { enabled: true, endpoint: 'https://x.com' } })
JSON.stringify({ installationId: validUuid, telemetry: { enabled: true, endpoint: 'https://x.com' } })
);

await updateGlobalConfig({ telemetry: { enabled: false } }, tmp.configDir, tmp.configFile);

const written = JSON.parse(await readFile(tmp.configFile, 'utf-8'));
expect(written).toEqual({
installationId: 'keep-me',
installationId: validUuid,
telemetry: { enabled: false, endpoint: 'https://x.com' },
});
});
Expand All @@ -115,24 +135,81 @@ describe('global-config', () => {

expect(ok).toBe(false);
});

it('does not overwrite when existing file is malformed JSON', async () => {
const corrupt = '{ this is not valid json';
await writeFile(tmp.configFile, corrupt);

const ok = await updateGlobalConfig({ telemetry: { enabled: false } }, tmp.configDir, tmp.configFile);

expect(ok).toBe(false);
const onDisk = await readFile(tmp.configFile, 'utf-8');
expect(onDisk).toBe(corrupt);
});
});

describe('getOrCreateInstallationId', () => {
it('generates installationId on first run and returns created: true', async () => {
const result = await getOrCreateInstallationId(tmp.configDir, tmp.configFile);

assert(result.success);
expect(result.created).toBe(true);
expect(result.id).toMatch(/^[0-9a-f-]{36}$/);
const config = await readGlobalConfig(tmp.configFile);
expect(config.installationId).toBe(result.id);
const read = await readGlobalConfig(tmp.configFile);
assert(read.success);
expect(read.config.installationId).toBe(result.id);
});

it('returns existing id with created: false', async () => {
await writeFile(tmp.configFile, JSON.stringify({ installationId: 'existing-id' }));
const validUuid = 'f47ac10b-58cc-4372-a567-0e02b2c3d479';
await writeFile(tmp.configFile, JSON.stringify({ installationId: validUuid }));

const result = await getOrCreateInstallationId(tmp.configDir, tmp.configFile);

expect(result).toEqual({ success: true, id: validUuid, created: false });
});

it('regenerates id when existing value is not a valid UUID', async () => {
await writeFile(tmp.configFile, JSON.stringify({ installationId: 'my-custom-id' }));

const result = await getOrCreateInstallationId(tmp.configDir, tmp.configFile);

expect(result).toEqual({ id: 'existing-id', created: false });
assert(result.success);
expect(result.created).toBe(true);
expect(result.id).toMatch(/^[0-9a-f-]{36}$/);
expect(result.id).not.toBe('my-custom-id');
const read = await readGlobalConfig(tmp.configFile);
assert(read.success);
expect(read.config.installationId).toBe(result.id);
});

it('regenerates id when existing value is an empty string', async () => {
await writeFile(tmp.configFile, JSON.stringify({ installationId: '' }));

const result = await getOrCreateInstallationId(tmp.configDir, tmp.configFile);

assert(result.success);
expect(result.created).toBe(true);
expect(result.id).toMatch(/^[0-9a-f-]{36}$/);
});

it('returns failure when existing config is unreadable', async () => {
await writeFile(tmp.configFile, '{ malformed json');

const result = await getOrCreateInstallationId(tmp.configDir, tmp.configFile);

assert(!result.success);
expect(result.error).toBeInstanceOf(Error);
});

it('returns failure when the new id cannot be persisted', async () => {
const result = await getOrCreateInstallationId(
tmp.testDir + '/\0invalid',
tmp.testDir + '/\0invalid/config.json'
);

assert(!result.success);
expect(result.error).toBeInstanceOf(Error);
});
});
});
10 changes: 7 additions & 3 deletions src/cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { registerABTestCommand } from './commands/abtest';
import { registerAdd } from './commands/add';
import { registerAddTool } from './commands/add/tool-command';
import { registerArchive } from './commands/archive';
import { registerConfig } from './commands/config';
import { registerConfigBundle } from './commands/config-bundle';
import { registerCreate } from './commands/create';
import { registerDataset } from './commands/dataset';
Expand Down Expand Up @@ -112,6 +113,7 @@ export function registerCommands(program: Command) {
registerUpdate(program);
registerValidate(program);
registerConfigBundle(program);
registerConfig(program);
registerDataset(program);
registerArchive(program);

Expand All @@ -134,8 +136,10 @@ export const main = async (argv: string[]) => {
// Register global cleanup handlers once at startup
setupAltScreenCleanup();

// Generate installationId on first run and show telemetry notice
const { created: isFirstRun } = await getOrCreateInstallationId();
// Generate installationId on first run and show telemetry notice. If we
// could not persist the id, suppress the notice so it doesn't fire every run.
const installationIdResult = await getOrCreateInstallationId();
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

potential optimization: we read the config file in a few places, we should allow it to be cached. The challenge is that if a user edits mid TUI, we want to pick up the changes so will require some thought there.

const isFirstRun = installationIdResult.success && installationIdResult.created;

const program = createProgram();

Expand All @@ -153,7 +157,7 @@ export const main = async (argv: string[]) => {
}

if (isFirstRun) {
printTelemetryNotice();
await printTelemetryNotice();
}

await TelemetryClientAccessor.init(args[0] ?? 'unknown');
Expand Down
Loading
Loading