Skip to content

feat: import amp CLI distribution - #1

Merged
ekim-amplitude merged 7 commits into
mainfrom
chore/initial-import
Jun 26, 2026
Merged

feat: import amp CLI distribution#1
ekim-amplitude merged 7 commits into
mainfrom
chore/initial-import

Conversation

@ekim-amplitude

@ekim-amplitude ekim-amplitude commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator

Description

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.

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 saved
  • pnpm cli -- auth status — oauth profile, prod base URL, masked token, expiry
  • pnpm cli -- context — authenticated user/org context resolved
  • pnpm cli -- projects list --limit 3 — table output, pagination hint

Tested from source via pnpm cli (tsx), not installed tarball. CI already covers smoke-cli.sh packaging 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-api package (v0.1.0) exposing the amp bin, build/test/smoke scripts, and npm files that ship dist, bundled OpenAPI, and docs.

Adds automation: build.yml runs typecheck, Vitest, build, publint, and offline packaging smoke (scripts/smoke-cli.sh); release-please.yml versions on main (GitHub App token so release PRs run checks) and publishes to npm with OIDC provenance on the beta dist-tag via the npm-publish environment.

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 under openapi/bundled/ and seeds release-please at 0.1.0 via .release-please-manifest.json.

Reviewed by Cursor Bugbot for commit 23df68d. Bugbot is set up for automated code reviews on this repo. Configure here.

ekim-amplitude and others added 3 commits June 25, 2026 18:39
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>
@ekim-amplitude

Copy link
Copy Markdown
Collaborator Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 compact null on the JSON path and no stdout on the human TTY path.
  • ✅ Fixed: ANSI colors leak when piped
    • Made terminal chalk helpers check stdout/stderr isTTY at call time so styling is stripped when output is piped.

Create PR

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.

Comment thread src/run.ts
Comment thread src/cli.ts
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>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Create PR

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.

Comment thread src/run.ts
Comment thread .github/workflows/release-please.yml
Comment thread docs/cli.md Outdated
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>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 3 potential issues.

Fix All in Cursor

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.

Create PR

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.

Comment thread src/auth-commands.ts
Comment thread src/auth-commands.ts
Comment thread .github/workflows/release-please.yml Outdated
ekim-amplitude and others added 2 commits June 26, 2026 09:06
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>
@ekim-amplitude
ekim-amplitude merged commit 976f4f7 into main Jun 26, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant