Skip to content
Draft
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
"scripts"
],
"scripts": {
"preinstall": "node scripts/check-old-cli.mjs",
"preinstall": "node scripts/preinstall-warnings.mjs",
"build": "npm run build:lib && npm run build:cli && npm run build:assets",
"build:lib": "tsc -p tsconfig.build.json",
"build:cli": "node esbuild.config.mjs",
Expand Down
16 changes: 16 additions & 0 deletions scripts/check-old-cli.mjs → scripts/preinstall-warnings.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,19 @@ try {
} catch {
// No agentcore binary found or unexpected error — nothing to do
}

// Telemetry notice — shown on every install/upgrade
try {
console.warn(
[
'',
'\x1b[33m⚠ NOTICE: The AgentCore CLI collects aggregated, anonymous usage\x1b[0m',
'\x1b[33manalytics to help improve the tool. To opt out, run:\x1b[0m',
'\x1b[33m agentcore telemetry disable\x1b[0m',
'\x1b[33mOr set: AGENTCORE_TELEMETRY_DISABLED=true\x1b[0m',
'',
].join('\n')
);
} catch {
// Never fail the install
}
87 changes: 87 additions & 0 deletions src/cli/__tests__/global-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { GLOBAL_CONFIG_DIR, GLOBAL_CONFIG_FILE, readGlobalConfig, updateGlobalConfig } from '../global-config';
import { mkdir, readFile, writeFile } from 'fs/promises';
import { beforeEach, describe, expect, it, vi } from 'vitest';

vi.mock('fs/promises');

const mockMkdir = vi.mocked(mkdir);
const mockReadFile = vi.mocked(readFile);
const mockWriteFile = vi.mocked(writeFile);

describe('global-config', () => {
beforeEach(() => {
vi.clearAllMocks();
});

describe('readGlobalConfig', () => {
it('returns parsed config when file exists', async () => {
mockReadFile.mockResolvedValue(JSON.stringify({ telemetry: { enabled: false } }));

const config = await readGlobalConfig();

expect(config).toEqual({ telemetry: { enabled: false } });
expect(mockReadFile).toHaveBeenCalledWith(GLOBAL_CONFIG_FILE, 'utf-8');
});

it('returns empty object when file does not exist', async () => {
mockReadFile.mockRejectedValue(new Error('ENOENT'));

const config = await readGlobalConfig();

expect(config).toEqual({});
});

it('returns empty object when file contains invalid JSON', async () => {
mockReadFile.mockResolvedValue('not json');

const config = await readGlobalConfig();

expect(config).toEqual({});
});
});

describe('updateGlobalConfig', () => {
it('creates directory and writes merged config', async () => {
mockReadFile.mockResolvedValue(JSON.stringify({ telemetry: { enabled: true } }));
mockMkdir.mockResolvedValue(undefined);
mockWriteFile.mockResolvedValue(undefined);

await updateGlobalConfig({ telemetry: { enabled: false } });

expect(mockMkdir).toHaveBeenCalledWith(GLOBAL_CONFIG_DIR, { recursive: true });
const written = JSON.parse(mockWriteFile.mock.calls[0]![1] as string);
expect(written).toEqual({ telemetry: { enabled: false } });
});

it('merges telemetry sub-object without overwriting other keys', async () => {
mockReadFile.mockResolvedValue(JSON.stringify({ telemetry: { enabled: true } }));
mockMkdir.mockResolvedValue(undefined);
mockWriteFile.mockResolvedValue(undefined);

await updateGlobalConfig({ telemetry: { enabled: false } });

const written = JSON.parse(mockWriteFile.mock.calls[0]![1] as string);
expect(written).toEqual({ telemetry: { enabled: false } });
});

it('silently ignores write failures', async () => {
mockReadFile.mockResolvedValue('{}');
mockMkdir.mockResolvedValue(undefined);
mockWriteFile.mockRejectedValue(new Error('EACCES'));

// Should not throw
await updateGlobalConfig({ telemetry: { enabled: true } });
});

it('handles missing existing config gracefully', async () => {
mockReadFile.mockRejectedValue(new Error('ENOENT'));
mockMkdir.mockResolvedValue(undefined);
mockWriteFile.mockResolvedValue(undefined);

await updateGlobalConfig({ telemetry: { enabled: true } });

const written = JSON.parse(mockWriteFile.mock.calls[0]![1] as string);
expect(written).toEqual({ telemetry: { enabled: true } });
});
});
});
2 changes: 2 additions & 0 deletions src/cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { registerInvoke } from './commands/invoke';
import { registerPackage } from './commands/package';
import { registerRemove } from './commands/remove';
import { registerStatus } from './commands/status';
import { registerTelemetry } from './commands/telemetry';
import { registerUpdate } from './commands/update';
import { registerValidate } from './commands/validate';
import { PACKAGE_VERSION } from './constants';
Expand Down Expand Up @@ -132,6 +133,7 @@ export function registerCommands(program: Command) {
registerPackage(program);
registerRemove(program);
registerStatus(program);
registerTelemetry(program);
registerUpdate(program);
registerValidate(program);
}
Expand Down
92 changes: 92 additions & 0 deletions src/cli/commands/telemetry/__tests__/telemetry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import * as globalConfig from '../../../global-config';
import * as resolve from '../../../telemetry/resolve';
import { handleTelemetryDisable, handleTelemetryEnable, handleTelemetryStatus } from '../actions';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

vi.mock('../../../global-config');
vi.mock('../../../telemetry/resolve');

const mockUpdateGlobalConfig = vi.mocked(globalConfig.updateGlobalConfig);
const mockResolveTelemetryPreference = vi.mocked(resolve.resolveTelemetryPreference);

describe('telemetry actions', () => {
let consoleSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
vi.clearAllMocks();
// eslint-disable-next-line @typescript-eslint/no-empty-function
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
mockUpdateGlobalConfig.mockResolvedValue(undefined);
});

afterEach(() => {
consoleSpy.mockRestore();
});

describe('handleTelemetryDisable', () => {
it('writes disabled config and prints confirmation', async () => {
await handleTelemetryDisable();

expect(mockUpdateGlobalConfig).toHaveBeenCalledWith({ telemetry: { enabled: false } });
expect(consoleSpy).toHaveBeenCalledWith('Telemetry has been disabled.');
});
});

describe('handleTelemetryEnable', () => {
it('writes enabled config and prints confirmation', async () => {
await handleTelemetryEnable();

expect(mockUpdateGlobalConfig).toHaveBeenCalledWith({ telemetry: { enabled: true } });
expect(consoleSpy).toHaveBeenCalledWith('Telemetry has been enabled.');
});
});

describe('handleTelemetryStatus', () => {
it('shows enabled status with default source', async () => {
mockResolveTelemetryPreference.mockResolvedValue({ enabled: true, source: 'default' });

await handleTelemetryStatus();

expect(consoleSpy).toHaveBeenCalledWith('Telemetry: Enabled');
expect(consoleSpy).toHaveBeenCalledWith('Source: default');
});

it('shows disabled status with global-config source', async () => {
mockResolveTelemetryPreference.mockResolvedValue({ enabled: false, source: 'global-config' });

await handleTelemetryStatus();

expect(consoleSpy).toHaveBeenCalledWith('Telemetry: Disabled');
expect(consoleSpy).toHaveBeenCalledWith('Source: global config (~/.agentcore/config.json)');
});

it('shows env var note when source is environment (AGENTCORE_TELEMETRY_DISABLED)', async () => {
const originalEnv = process.env;
process.env = { ...originalEnv, AGENTCORE_TELEMETRY_DISABLED: 'true' };

mockResolveTelemetryPreference.mockResolvedValue({ enabled: false, source: 'environment' });

await handleTelemetryStatus();

expect(consoleSpy).toHaveBeenCalledWith('Telemetry: Disabled');
expect(consoleSpy).toHaveBeenCalledWith('Source: environment variable');
expect(consoleSpy).toHaveBeenCalledWith('\nNote: AGENTCORE_TELEMETRY_DISABLED=true is set in your environment.');

process.env = originalEnv;
});

it('shows env var note when source is environment (DO_NOT_TRACK)', async () => {
const originalEnv = process.env;
process.env = { ...originalEnv, DO_NOT_TRACK: '1' };
delete process.env.AGENTCORE_TELEMETRY_DISABLED;

mockResolveTelemetryPreference.mockResolvedValue({ enabled: false, source: 'environment' });

await handleTelemetryStatus();

expect(consoleSpy).toHaveBeenCalledWith('\nNote: DO_NOT_TRACK=1 is set in your environment.');

process.env = originalEnv;
});
});
});
40 changes: 40 additions & 0 deletions src/cli/commands/telemetry/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { updateGlobalConfig } from '../../global-config.js';
import { resolveTelemetryPreference } from '../../telemetry/resolve.js';

export async function handleTelemetryDisable(): Promise<void> {
await updateGlobalConfig({ telemetry: { enabled: false } });
console.log('Telemetry has been disabled.');
}

export async function handleTelemetryEnable(): Promise<void> {
await updateGlobalConfig({ telemetry: { enabled: true } });
console.log('Telemetry has been enabled.');
}

export async function handleTelemetryStatus(): Promise<void> {
const pref = await resolveTelemetryPreference();

const status = pref.enabled ? 'Enabled' : 'Disabled';

const sourceLabel =
pref.source === 'environment'
? 'environment variable'
: pref.source === 'global-config'
? 'global config (~/.agentcore/config.json)'
: 'default';

console.log(`Telemetry: ${status}`);
console.log(`Source: ${sourceLabel}`);

if (pref.source === 'environment') {
// eslint-disable-next-line @typescript-eslint/dot-notation
const agentcoreEnv = process.env['AGENTCORE_TELEMETRY_DISABLED'];
// eslint-disable-next-line @typescript-eslint/dot-notation
const doNotTrack = process.env['DO_NOT_TRACK'];
if (agentcoreEnv !== undefined) {
console.log(`\nNote: AGENTCORE_TELEMETRY_DISABLED=${agentcoreEnv} is set in your environment.`);
} else if (doNotTrack !== undefined) {
console.log(`\nNote: DO_NOT_TRACK=${doNotTrack} is set in your environment.`);
}
}
}
34 changes: 34 additions & 0 deletions src/cli/commands/telemetry/command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { COMMAND_DESCRIPTIONS } from '../../tui/copy.js';
import { handleTelemetryDisable, handleTelemetryEnable, handleTelemetryStatus } from './actions.js';
import type { Command } from '@commander-js/extra-typings';

export function registerTelemetry(program: Command) {
const telemetry = program
.command('telemetry')
.description(COMMAND_DESCRIPTIONS.telemetry)
.argument('[subcommand]', 'Subcommand to run (enable, disable, status)')
.action(() => {
telemetry.outputHelp();
});

telemetry
.command('disable')
.description('Disable anonymous usage analytics')
.action(async () => {
await handleTelemetryDisable();
});

telemetry
.command('enable')
.description('Enable anonymous usage analytics')
.action(async () => {
await handleTelemetryEnable();
});

telemetry
.command('status')
.description('Show current telemetry preference and source')
.action(async () => {
await handleTelemetryStatus();
});
}
1 change: 1 addition & 0 deletions src/cli/commands/telemetry/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { registerTelemetry } from './command.js';
39 changes: 39 additions & 0 deletions src/cli/global-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { mkdir, readFile, writeFile } from 'fs/promises';
import { homedir } from 'os';
import { join } from 'path';

export const GLOBAL_CONFIG_DIR = join(homedir(), '.agentcore');
export const GLOBAL_CONFIG_FILE = join(GLOBAL_CONFIG_DIR, 'config.json');

export interface GlobalConfig {
telemetry?: {
enabled?: boolean;
};
}

export async function readGlobalConfig(): Promise<GlobalConfig> {
try {
const data = await readFile(GLOBAL_CONFIG_FILE, 'utf-8');
return JSON.parse(data) as GlobalConfig;
} catch {
return {};
}
}

export async function updateGlobalConfig(partial: GlobalConfig): Promise<void> {
try {
const existing = await readGlobalConfig();

// Shallow merge with one level of nesting for telemetry sub-object
const merged: GlobalConfig = { ...existing };

if (partial.telemetry !== undefined) {
merged.telemetry = { ...existing.telemetry, ...partial.telemetry };
}

await mkdir(GLOBAL_CONFIG_DIR, { recursive: true });
await writeFile(GLOBAL_CONFIG_FILE, JSON.stringify(merged, null, 2), 'utf-8');
} catch {
// Silently ignore write failures
}
}
Loading
Loading