Skip to content
Closed
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
@@ -0,0 +1 @@
export { registerTelemetry } from './command.js';
39 changes: 39 additions & 0 deletions src/cli/global-config.ts
Original file line numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat: add telemetry preference controls and preinstall notice by jesseturner21 · Pull Request #418 · aws/agentcore-cli · GitHub
Skip to content
Closed
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
@@ -0,0 +1 @@
export { registerTelemetry } from './command.js';
39 changes: 39 additions & 0 deletions src/cli/global-config.ts
Original file line numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: add telemetry preference controls and preinstall notice by jesseturner21 · Pull Request #418 · aws/agentcore-cli · GitHub
Skip to content
Closed
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
@@ -0,0 +1 @@
export { registerTelemetry } from './command.js';
39 changes: 39 additions & 0 deletions src/cli/global-config.ts
Original file line numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: add telemetry preference controls and preinstall notice by jesseturner21 · Pull Request #418 · aws/agentcore-cli · GitHub
Skip to content
Closed
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
@@ -0,0 +1 @@
export { registerTelemetry } from './command.js';
39 changes: 39 additions & 0 deletions src/cli/global-config.ts
Original file line numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat: add telemetry preference controls and preinstall notice by jesseturner21 · Pull Request #418 · aws/agentcore-cli · GitHub
Skip to content
Closed
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
@@ -0,0 +1 @@
export { registerTelemetry } from './command.js';
39 changes: 39 additions & 0 deletions src/cli/global-config.ts
Original file line numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: add telemetry preference controls and preinstall notice by jesseturner21 · Pull Request #418 · aws/agentcore-cli · GitHub
Skip to content
Closed
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
@@ -0,0 +1 @@
export { registerTelemetry } from './command.js';
39 changes: 39 additions & 0 deletions src/cli/global-config.ts
Original file line numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); feat: add telemetry preference controls and preinstall notice by jesseturner21 · Pull Request #418 · aws/agentcore-cli · GitHub
Skip to content
Closed
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
@@ -0,0 +1 @@
export { registerTelemetry } from './command.js';
39 changes: 39 additions & 0 deletions src/cli/global-config.ts
Original file line numberDiff line numberDiff 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