feat: import amp CLI distribution - #1
Conversation
Initial import of the `amp` CLI source, bundled Developer API OpenAPI spec, TypeScript build config, tests, packaging smoke, and the CI + release-please release automation. Source is mirrored from Amplitude's Developer API. Co-authored-by: Cursor <cursoragent@cursor.com>
pnpm/action-setup errors when both `version:` and package.json's `packageManager` field specify pnpm. Keep the pinned `packageManager` version as the single source of truth and remove the duplicate `version: 10`. Co-authored-by: Cursor <cursoragent@cursor.com>
Add .cursor/BUGBOT.md with repo-specific review priorities: public hygiene (no internal references), credential/token safety (0600, no token logging, precedence), destructive-action gating, generated-artifact protection, the human/agent output contract, and input validation / standalone portability. Co-authored-by: Cursor <cursoragent@cursor.com>
|
bugbot run |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Fabricated JSON on empty success
- Removed the synthetic
{data:{ok:true}}fallback so empty API responses emit compactnullon the JSON path and no stdout on the human TTY path.
- Removed the synthetic
- ✅ Fixed: ANSI colors leak when piped
- Made terminal chalk helpers check stdout/stderr
isTTYat call time so styling is stripped when output is piped.
- Made terminal chalk helpers check stdout/stderr
Or push these changes by commenting:
@cursor push b22cbd3056
Preview (b22cbd3056)
diff --git a/src/run.test.ts b/src/run.test.ts
--- a/src/run.test.ts
+++ b/src/run.test.ts
@@ -198,6 +198,7 @@
expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchMock.mock.calls[0][1]).toMatchObject({ method: 'DELETE' });
+ expect(logSpy).toHaveBeenCalledWith('null');
});
it('passes --dry-run through to the server when supported', async () => {
diff --git a/src/run.ts b/src/run.ts
--- a/src/run.ts
+++ b/src/run.ts
@@ -41,8 +41,6 @@
return options.isTTY ? 'confirm' : 'block';
}
-const SYNTHETIC_OK = { data: { ok: true } };
-
async function ensureDeleteAllowed(
operation: CliOperation,
flags: Record<string, FlagValue>,
@@ -105,21 +103,27 @@
);
}
- const payload = parsed ?? SYNTHETIC_OK;
const isTTY = Boolean(process.stdout.isTTY);
const useJson = shouldUseJsonOutput({
jsonFlag: isFlagEnabled(flags.json),
isTTY,
});
+ if (parsed === null) {
+ if (useJson) {
+ console.log(isTTY ? JSON.stringify(null, null, 2) : 'null');
+ }
+ return;
+ }
+
// When piped or non-interactive (the path agents and scripts take), emit
// compact JSON to avoid spending tokens on indentation. Pretty-print only
// when a human asked for JSON at a real terminal.
let output: string;
if (useJson) {
- output = isTTY ? JSON.stringify(payload, null, 2) : JSON.stringify(payload);
+ output = isTTY ? JSON.stringify(parsed, null, 2) : JSON.stringify(parsed);
} else {
- output = formatSuccessOutput(payload, operation);
+ output = formatSuccessOutput(parsed, operation);
}
console.log(output);
diff --git a/src/terminal.ts b/src/terminal.ts
--- a/src/terminal.ts
+++ b/src/terminal.ts
@@ -1,10 +1,17 @@
import chalk from 'chalk';
+function ttyAware(
+ isEnabled: () => boolean,
+ style: (text: string) => string,
+): (text: string) => string {
+ return (text: string) => (isEnabled() ? style(text) : text);
+}
+
export const terminal = {
- command: chalk.cyan,
- dim: chalk.dim,
- error: chalk.red,
- heading: chalk.bold,
- success: chalk.green,
- warning: chalk.yellow,
+ command: ttyAware(() => Boolean(process.stdout.isTTY), chalk.cyan),
+ dim: ttyAware(() => Boolean(process.stdout.isTTY), chalk.dim),
+ error: ttyAware(() => Boolean(process.stderr.isTTY), chalk.red),
+ heading: ttyAware(() => Boolean(process.stdout.isTTY), chalk.bold),
+ success: ttyAware(() => Boolean(process.stdout.isTTY), chalk.green),
+ warning: ttyAware(() => Boolean(process.stdout.isTTY), chalk.yellow),
};You can send follow-ups to the cloud agent here.
Mirror latest developer-api from javascript master:
- MCP-452: faithful empty-body handling for 204 DELETE/archive (no
synthetic {"data":{"ok":true}} on stdout)
- Public hygiene: sanitized AGENTS.md, docs/cli.md, and src comments
- Updated tests for run/output empty-success paths
Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 3 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for all 3 issues found in the latest run.
- ✅ Fixed: Unsupported dry-run with yes deletes
- deleteGateDecision now blocks unsupported --dry-run before checking --yes, and the error message no longer suggests combining them.
- ✅ Fixed: Publish job skips tests
- Added pnpm test:typescript and pnpm test steps to the release-please publish job before build.
- ✅ Fixed: Public docs leak upstream context
- Removed internal api-server monorepo paths, parent-package regeneration commands, and the MCP-414 Linear link from AGENTS.md and docs/cli.md.
Or push these changes by commenting:
@cursor push 132c8862bb
Preview (132c8862bb)
diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml
--- a/.github/workflows/release-please.yml
+++ b/.github/workflows/release-please.yml
@@ -61,6 +61,12 @@
- name: Install dependencies
run: pnpm install --frozen-lockfile
+ - name: Typecheck
+ run: pnpm test:typescript
+
+ - name: Unit tests
+ run: pnpm test
+
- name: Build the package
run: pnpm build
diff --git a/AGENTS.md b/AGENTS.md
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -4,10 +4,9 @@
Amplitude Developer API. It is vended publicly as a standalone repo, so it is
held to a higher bar than internal code.
-The OpenAPI spec in the parent `api-server` package is the contract; this CLI is
-a thin, generated client over it. Before changing command behavior, auth, or the
-generated manifest, read the parent `../AGENTS.md` and
-`../docs/golden-standards.md`.
+The OpenAPI spec bundled in `openapi/bundled/` is the contract; this CLI is a
+thin, generated client over it. Do not hand-edit `src/generated/` or
+`openapi/bundled/` — regenerate those artifacts when the API contract changes.
## Tenets
diff --git a/docs/cli.md b/docs/cli.md
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -126,11 +126,10 @@
5. **Flags write** — create → get → update description → archive dry-run → archive
6. **Events** — list → create → update → (optional delete with `--yes`)
-Known upstream issue: `flags update --enabled false` fails for deployment-less
-flags ([MCP-414](https://linear.app/amplitude/issue/MCP-414)). Avoid that path
-in smoke tests until fixed.
+Known issue: `flags update --enabled false` fails for deployment-less flags.
+Avoid that path in smoke tests until fixed.
-## Local api-server
+## Local API server
Point at a running local server:
@@ -142,7 +141,5 @@
Do not edit by hand:
-- `src/generated/cli-manifest.ts` — from `api-server/scripts/generate-cli-manifest.ts`
-- `openapi/bundled/*` — copied from `api-server/openapi/bundled/`
-
-Regenerate via `pnpm build:openapi` in the parent `api-server` package.
+- `src/generated/cli-manifest.ts`
+- `openapi/bundled/*`
diff --git a/src/run.test.ts b/src/run.test.ts
--- a/src/run.test.ts
+++ b/src/run.test.ts
@@ -84,6 +84,17 @@
}),
).toBe('block');
});
+
+ it('does not let --yes override an unsupported --dry-run', () => {
+ expect(
+ deleteGateDecision({
+ ...base,
+ dryRunRequested: true,
+ dryRunSupported: false,
+ yes: true,
+ }),
+ ).toBe('block');
+ });
});
describe('runOperation', () => {
@@ -285,4 +296,16 @@
).rejects.toThrow('does not support --dry-run');
expect(fetchMock).not.toHaveBeenCalled();
});
+
+ it('refuses an unsupported --dry-run even when --yes is set', async () => {
+ await expect(
+ runOperation(deleteWithoutDryRun, {
+ token: 'amp_test',
+ id: 'w_1',
+ 'dry-run': true,
+ yes: true,
+ }),
+ ).rejects.toThrow('does not support --dry-run');
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
});
diff --git a/src/run.ts b/src/run.ts
--- a/src/run.ts
+++ b/src/run.ts
@@ -38,6 +38,9 @@
if (!options.isDelete) {
return 'proceed';
}
+ if (options.dryRunRequested && !options.dryRunSupported) {
+ return options.isTTY ? 'confirm' : 'block';
+ }
if (options.dryRunRequested && options.dryRunSupported) {
return 'proceed';
}
@@ -70,7 +73,7 @@
if (decision === 'block') {
if (dryRunRequested && !dryRunSupported) {
throw new Error(
- `\`amp ${operation.command.join(' ')}\` does not support --dry-run. Pass --yes to confirm, or run it in an interactive terminal.`,
+ `\`amp ${operation.command.join(' ')}\` does not support --dry-run. Remove --dry-run and pass --yes to confirm, or run it in an interactive terminal.`,
);
}
throw new Error(You can send follow-ups to the cloud agent here.
Mirror latest developer-api from javascript master: - Sanitize AGENTS.md, docs/cli.md, and src comments for public mirror - MCP-453: block --dry-run --yes on unsupported DELETE commands - MCP-452 already included in prior sync (204 empty-body handling) Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 3 potential issues.
Bugbot Autofix prepared fixes for all 3 issues found in the latest run.
- ✅ Fixed: Auth status colors when piped
- runAuthStatus now applies heading, dim, and warning styling only when process.stdout.isTTY is true, keeping piped output plain text.
- ✅ Fixed: Publish uses wrong Node
- Changed the release-please publish job from node-version 24 to 22 to match package.json engines and the Build & Test workflow.
- ✅ Fixed: Logout all skips confirmation
- amp logout --all now requires interactive confirmation in a TTY or --yes in non-interactive mode before wiping all stored profiles.
Or push these changes by commenting:
@cursor push 886355fe18
Preview (886355fe18)
diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml
--- a/.github/workflows/release-please.yml
+++ b/.github/workflows/release-please.yml
@@ -55,7 +55,7 @@
- name: Set up Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
- node-version: 24
+ node-version: 22
registry-url: https://registry.npmjs.org
- name: Install dependencies
diff --git a/src/auth-commands.ts b/src/auth-commands.ts
--- a/src/auth-commands.ts
+++ b/src/auth-commands.ts
@@ -301,6 +301,7 @@
path?: string;
now?: () => number;
stdout?: (line: string) => void;
+ confirm?: (message: string) => Promise<boolean>;
}
/** Reverses a base_url back to its friendly `--env` name when one is known. */
@@ -416,10 +417,10 @@
* survivor (the active identity only changes on an explicit command), so
* logging out the default leaves no default set.
*/
-export function runLogout(
+export async function runLogout(
flags: Record<string, FlagValue>,
deps: ProfileCommandDeps = {},
-): void {
+): Promise<void> {
const emitStdout = deps.stdout ?? ((line) => console.log(line));
const store = loadStore(deps.path);
@@ -432,6 +433,19 @@
emitStdout('No profiles to remove.');
return;
}
+ const isTTY = Boolean(process.stdin.isTTY && process.stdout.isTTY);
+ if (!isFlagEnabled(flags.yes)) {
+ if (!isTTY) {
+ throw new Error('Pass --yes to log out of all profiles.');
+ }
+ const confirmLogout = deps.confirm ?? confirm;
+ const approved = await confirmLogout(
+ `Remove all ${count} profile${count === 1 ? '' : 's'}?`,
+ );
+ if (!approved) {
+ throw new Error('Aborted.');
+ }
+ }
saveStore(emptyStore(), deps.path);
emitStdout(
terminal.success(
@@ -504,8 +518,12 @@
const emitStdout = deps.stdout ?? ((line) => console.log(line));
const now = deps.now?.() ?? Date.now();
const store = deps.store ?? loadStore();
+ const styled = Boolean(process.stdout.isTTY);
+ const heading = styled ? terminal.heading : (text: string) => text;
+ const dim = styled ? terminal.dim : (text: string) => text;
+ const warning = styled ? terminal.warning : (text: string) => text;
- emitStdout(`${terminal.heading('Auth status')}\n`);
+ emitStdout(`${heading('Auth status')}\n`);
const emitProfileRows = (name: string, profile: Profile): void => {
const marker = store.default === name ? ' (default)' : '';
@@ -526,7 +544,7 @@
emitProfileRows(selected.name, selected.profile);
}
emitStdout(
- terminal.warning(error instanceof Error ? error.message : String(error)),
+ warning(error instanceof Error ? error.message : String(error)),
);
process.exitCode = 1;
return;
@@ -534,18 +552,18 @@
if (auth.source.startsWith('AMP_TOKEN')) {
emitStdout(
- terminal.warning('AMP_TOKEN is set and overrides any stored profile.'),
+ warning('AMP_TOKEN is set and overrides any stored profile.'),
);
}
- emitStdout(`Source: ${terminal.dim(auth.source)}`);
+ emitStdout(`Source: ${dim(auth.source)}`);
const profile = auth.profile ? getProfile(store, auth.profile) : undefined;
if (auth.profile && profile) {
emitProfileRows(auth.profile, profile);
}
- emitStdout(`Base URL: ${terminal.dim(auth.baseUrl)}`);
+ emitStdout(`Base URL: ${dim(auth.baseUrl)}`);
emitStdout(`Token: ${maskToken(auth.token)}`);
}
diff --git a/src/auth-profiles.test.ts b/src/auth-profiles.test.ts
--- a/src/auth-profiles.test.ts
+++ b/src/auth-profiles.test.ts
@@ -208,10 +208,10 @@
return path;
}
- it('removes a non-default profile and leaves the default untouched', () => {
+ it('removes a non-default profile and leaves the default untouched', async () => {
const path = seeded();
const out: string[] = [];
- runLogout({ profile: 'staging' }, { path, stdout: (l) => out.push(l) });
+ await runLogout({ profile: 'staging' }, { path, stdout: (l) => out.push(l) });
const after = loadStore(path);
expect(after.profiles.staging).toBeUndefined();
@@ -219,10 +219,10 @@
expect(out.join('\n')).toMatch(/Logged out of "staging"\./);
});
- it('clears the default (never auto-promotes) when logging out the default', () => {
+ it('clears the default (never auto-promotes) when logging out the default', async () => {
const path = seeded();
const out: string[] = [];
- runLogout({}, { path, stdout: (l) => out.push(l) });
+ await runLogout({}, { path, stdout: (l) => out.push(l) });
const after = loadStore(path);
expect(after.profiles.amplitude).toBeUndefined();
@@ -231,25 +231,28 @@
expect(out.join('\n')).toMatch(/No default set/);
});
- it('errors with known names on an unknown target', () => {
+ it('errors with known names on an unknown target', async () => {
const path = seeded();
- expect(() => runLogout({ profile: 'nope' }, { path })).toThrow(
+ await expect(runLogout({ profile: 'nope' }, { path })).rejects.toThrow(
/Known: amplitude, staging/,
);
});
- it('errors when there is nothing to log out of', () => {
+ it('errors when there is nothing to log out of', async () => {
const dir = mkdtempSync(join(tmpdir(), 'amp-logout-empty-'));
dirs.push(dir);
const path = join(dir, 'credentials.json');
saveStore(emptyStore(), path);
- expect(() => runLogout({}, { path })).toThrow(/No profile to log out of/);
+ await expect(runLogout({}, { path })).rejects.toThrow(/No profile to log out of/);
});
- it('--all wipes every profile and clears the default', () => {
+ it('--all wipes every profile and clears the default', async () => {
const path = seeded();
const out: string[] = [];
- runLogout({ all: true }, { path, stdout: (l) => out.push(l) });
+ await runLogout(
+ { all: true, yes: true },
+ { path, stdout: (l) => out.push(l) },
+ );
const after = loadStore(path);
expect(Object.keys(after.profiles)).toEqual([]);
@@ -257,20 +260,20 @@
expect(out.join('\n')).toMatch(/Removed all 2 profiles/);
});
- it('--all on an empty store is a no-op message, not an error', () => {
+ it('--all on an empty store is a no-op message, not an error', async () => {
const dir = mkdtempSync(join(tmpdir(), 'amp-logout-all-empty-'));
dirs.push(dir);
const path = join(dir, 'credentials.json');
saveStore(emptyStore(), path);
const out: string[] = [];
- runLogout({ all: true }, { path, stdout: (l) => out.push(l) });
+ await runLogout({ all: true }, { path, stdout: (l) => out.push(l) });
expect(out.join('\n')).toMatch(/No profiles to remove/);
});
- it('rejects --all combined with --profile', () => {
- expect(() =>
+ it('rejects --all combined with --profile', async () => {
+ await expect(
runLogout({ all: true, profile: 'staging' }, { path: seeded() }),
- ).toThrow(/not both/);
+ ).rejects.toThrow(/not both/);
});
});
diff --git a/src/cli.ts b/src/cli.ts
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -91,7 +91,7 @@
}
if (command[0] === 'logout' && command.length === 1) {
- runLogout(flags);
+ await runLogout(flags);
return;
}You can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit c1aa4ac. Configure here.
Mirror latest developer-api from javascript master: - BA-389: improved device-flow login prompt and open-in-browser support (authToken.ts + tests) External-only: - release-please publish job: Node 24 → 22 to match build.yml and package.json engines (^22.22.0) Co-authored-by: Cursor <cursoragent@cursor.com>
Mirror latest developer-api from javascript master: - Gate auth status ANSI styling on TTY (plain output when piped) - Require confirmation or --yes for logout --all - terminal.ts helpers for TTY-aware formatting Co-authored-by: Cursor <cursoragent@cursor.com>


Description
Initial import of the
ampCLI source, bundled Developer API OpenAPI spec, TypeScript build config, tests, packaging smoke, and the CI + release-please release automation. Source is mirrored from Amplitude's Developer API.Manual testing (local, external repo
chore/initial-import)pnpm build+pnpm cli -- auth login --profile prod --env prod— device flow completed; user_code + verification URL shown; profile savedpnpm cli -- auth status— oauth profile, prod base URL, masked token, expirypnpm cli -- context— authenticated user/org context resolvedpnpm cli -- projects list --limit 3— table output, pagination hintTested from source via
pnpm cli(tsx), not installed tarball. CI already coverssmoke-cli.shpackaging path.Note
Medium Risk
First ship of a credential-handling CLI plus automated npm publish; risk is mainly operational (release secrets, Trusted Publisher config) and the breadth of imported runtime code not fully visible in this diff slice.
Overview
Initial import of the standalone
@amplitude/developer-apipackage (v0.1.0) exposing theampbin, build/test/smoke scripts, and npmfilesthat shipdist, bundled OpenAPI, and docs.Adds automation:
build.ymlruns typecheck, Vitest, build,publint, and offline packaging smoke (scripts/smoke-cli.sh);release-please.ymlversions onmain(GitHub App token so release PRs run checks) and publishes to npm with OIDC provenance on thebetadist-tag via thenpm-publishenvironment.Adds public-facing guidance (
AGENTS.md,.cursor/BUGBOT.md,docs/cli.md) documenting auth profiles, credential precedence, global flags, and review expectations for credentials, destructive commands, and generated artifacts. Commits bundled Developer API OpenAPI underopenapi/bundled/and seeds release-please at0.1.0via.release-please-manifest.json.Reviewed by Cursor Bugbot for commit 23df68d. Bugbot is set up for automated code reviews on this repo. Configure here.