@cantinasecurity/apex-cli 0.1.41 → 0.1.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -74,7 +74,7 @@ If the Apex MCP server is not configured, fall back to the local CLI:
74
74
  - `apex credits` reports standard credits plus audit, fix review, and Lite scan entitlements when Bedrock returns those balances.
75
75
  - Work from the target repository directory so Apex can resolve `.apex/workspace.json`.
76
76
  - `apex scan` scans the current working directory by default; pass `--repo` only when the user asks to scan explicit alternate roots.
77
- - `apex-doctor` is read-only for guest sessions and reports the local snapshot plan without reserving a workspace. For account sessions it reports whether Apex will use remote materialization or a local snapshot upload for each selected source.
77
+ - `apex-doctor` reports the running CLI version and detects conflicting npm/pnpm global installations. It is read-only for guest sessions and reports the local snapshot plan without reserving a workspace. For account sessions it reports whether Apex will use remote materialization or a local snapshot upload for each selected source.
78
78
  - Plain local directories and dirty git worktrees can scan through local snapshot uploads without provider access.
79
79
  - Audit scans use `--mode audit` in user-facing CLI calls and request payloads. The legacy `ultra` mode remains accepted as an alias, but audit scans still require provider-backed GitHub, GitLab or company-enabled Forgejo sources.
80
80
  - Lite scans use `--mode lite` in CLI calls or `mode: "lite"` in MCP, require Lite Scan beta access, and require provider-backed GitHub or GitLab sources without local snapshot fallback.
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Cantina agent plugins for security review workflows.",
9
- "version": "0.1.41"
9
+ "version": "0.1.43"
10
10
  },
11
11
  "plugins": [
12
12
  {
@@ -14,10 +14,10 @@
14
14
  "source": {
15
15
  "source": "npm",
16
16
  "package": "@cantinasecurity/apex-cli",
17
- "version": "0.1.41"
17
+ "version": "0.1.43"
18
18
  },
19
19
  "description": "Run Apex security scans and review findings from Claude Code.",
20
- "version": "0.1.41",
20
+ "version": "0.1.43",
21
21
  "author": {
22
22
  "name": "Cantina",
23
23
  "email": "support@cantina.xyz"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apex-cli",
3
- "version": "0.1.41",
3
+ "version": "0.1.43",
4
4
  "description": "Run Apex security scans and review findings from Claude Code.",
5
5
  "author": {
6
6
  "name": "Cantina",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apex-cli",
3
- "version": "0.1.41",
3
+ "version": "0.1.43",
4
4
  "description": "Run Apex security scans and review findings from Codex.",
5
5
  "author": {
6
6
  "name": "Cantina",
package/.mcp.claude.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "args": [
6
6
  "-y",
7
7
  "-p",
8
- "@cantinasecurity/apex-cli@0.1.41",
8
+ "@cantinasecurity/apex-cli@0.1.43",
9
9
  "apex-mcp"
10
10
  ],
11
11
  "env": {
package/.mcp.codex.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "args": [
5
5
  "-y",
6
6
  "-p",
7
- "@cantinasecurity/apex-cli@0.1.41",
7
+ "@cantinasecurity/apex-cli@0.1.43",
8
8
  "apex-mcp"
9
9
  ],
10
10
  "env": {
package/MARKETPLACE.md CHANGED
@@ -13,7 +13,7 @@ This package is prepared as both a Claude Code plugin and a Codex plugin. The pu
13
13
  The plugin MCP configs launch the pinned npm CLI package with:
14
14
 
15
15
  ```bash
16
- npx -y -p @cantinasecurity/apex-cli@0.1.41 apex-mcp
16
+ npx -y -p @cantinasecurity/apex-cli@0.1.43 apex-mcp
17
17
  ```
18
18
 
19
19
  That keeps marketplace installs independent of a user's global `apex` install.
package/README.md CHANGED
@@ -66,6 +66,14 @@ cannot adopt the new merge and generation-safety rules until it restarts.
66
66
 
67
67
  When `apex` is run in an interactive terminal, it checks for updates periodically and offers to install them.
68
68
 
69
+ `apex update` updates the package-manager installation that owns the running
70
+ executable and verifies that installation actually changed before reporting
71
+ success. `apex doctor` reports the running version and package manager. If npm
72
+ and pnpm contain different global Apex versions, doctor identifies the stale
73
+ active copy and prints the command that repairs it. The same installation
74
+ diagnostic is included in `apex doctor --json`, including when another doctor
75
+ check fails.
76
+
69
77
  ## Local Development
70
78
 
71
79
  1. Install dependencies:
@@ -158,30 +166,30 @@ unclaimed local trial proof so `apex claim` can still use it.
158
166
  Agent-safe copy/paste prompt (substitute the issued code):
159
167
 
160
168
  ```text
161
- Install and run Cantina Apex CLI 0.1.41 to start one free standard security scan of this repository immediately.
169
+ Install and run Cantina Apex CLI 0.1.43 to start one free standard security scan of this repository immediately.
162
170
 
163
171
  Use this trial code only where <TRIAL_CODE> appears below: SPBT-DEVREL-XXXXXXXX
164
172
 
165
173
  Run the commands yourself from the repository root; do not stop after setup or only explain the steps. Use terminal CLI commands only. Do not use an Apex MCP tool or run `apex setup`, `apex mcp`, `apex login`, `apex-auth-start`, or `apex-auth-wait`. Never use `--replace` or `--force`.
166
174
 
167
175
  Use this exact command prefix for every Apex command:
168
- npx -y -p @cantinasecurity/apex-cli@0.1.41 apex
176
+ npx -y -p @cantinasecurity/apex-cli@0.1.43 apex
169
177
 
170
178
  1. Before redeeming, confirm every command will use the same persistent, user-accessible machine or container, HOME, filesystem, and repository root. Confirm Node.js 20+ and npx are available. If `APEX_BASE_URL` is set, continue only when its origin is `https://ai.cantina.xyz`. Run the exact prefix with `--help` and continue only if help lists both `redeem` and `claim`. If any check fails, STOP before sending the code; do not install system packages.
171
179
  2. Redeem, with at most one response-loss recovery retry:
172
- npx -y -p @cantinasecurity/apex-cli@0.1.41 apex redeem <TRIAL_CODE> --json
180
+ npx -y -p @cantinasecurity/apex-cli@0.1.43 apex redeem <TRIAL_CODE> --json
173
181
  This terminal command is the only allowed tool call containing the code. Never echo or restate the code, store it in a file or environment variable, put it in a URL or MCP call, or include it in your response. Parse the JSON from stdout only; do not merge stderr into stdout, because npm may write warnings to stderr. Continue when the command exits 0 and stdout is JSON with `redeemed: true` and `outcome` equal to `redeemed` or `idempotent`. `idempotent` means the existing guest session resumed and it may already own a workspace. If and only if the first attempt ends with an ambiguous transport/connection failure or an interrupted/missing response and no typed server error code, retry this exact same command once on the same machine, HOME, and repository root. Never retry a typed server rejection and never make more than one recovery retry. If the allowed retry does not return a successful JSON outcome, STOP.
174
182
  3. Immediately start exactly one scan; do not wait for another instruction:
175
- npx -y -p @cantinasecurity/apex-cli@0.1.41 apex scan --mode standard --non-interactive --no-open --json
183
+ npx -y -p @cantinasecurity/apex-cli@0.1.43 apex scan --mode standard --non-interactive --no-open --json
176
184
  The CLI validates the complete guest local archive before it reserves a workspace. An unbound renamed checkout may recover only the sole empty guest workspace when no explicit workspace name was supplied. On any error or nonzero exit, STOP. After a successful scan JSON response, inspect `localPersistence.workspaceBindingSaved`. If it is `false`, the server scan already started: capture and report `binding.lastScanId`, then STOP; do not run status or findings until local repository permissions are repaired. Otherwise capture `binding.lastScanId` from the JSON as <SCAN_ID>; if it is absent, STOP.
177
185
  4. About every 60 seconds, poll that exact scan:
178
- npx -y -p @cantinasecurity/apex-cli@0.1.41 apex status --scan <SCAN_ID> --json
186
+ npx -y -p @cantinasecurity/apex-cli@0.1.43 apex status --scan <SCAN_ID> --json
179
187
  Read `progress.progress.status`. Wait while it is `created`, `pending`, `queued`, `starting`, `running`, `in_progress`, or `processing`. Continue on `completed`. STOP on `failed`, `cancelled`, a missing or unknown status, or a command failure.
180
188
  5. Fetch that exact scan's findings:
181
- npx -y -p @cantinasecurity/apex-cli@0.1.41 apex findings --scan <SCAN_ID> --json
189
+ npx -y -p @cantinasecurity/apex-cli@0.1.43 apex findings --scan <SCAN_ID> --json
182
190
  On success, summarize findings by severity, most severe first, including identifier, title, status, and confidence. This command does not return file locations, so do not invent them. On failure, STOP.
183
191
  6. Do not claim automatically. Tell me the claim deadline from step 2 and remind me to run this exact command myself from the same repository root, machine, and HOME:
184
- npx -y -p @cantinasecurity/apex-cli@0.1.41 apex claim
192
+ npx -y -p @cantinasecurity/apex-cli@0.1.43 apex claim
185
193
  It may open a browser so I can create or sign in to a Cantina account and keep the results. Claim moves the trial into the currently selected Cantina workspace when I can run scans there, or into my existing personal workspace. If the selected workspace cannot run scans, tell me to switch to one that can and retry before the claim deadline.
186
194
 
187
195
  Never paste raw command transcripts. For any failure, report only the step, exit code, and sanitized error. Redact the trial code and every `apex_at_`, `apex_rt_`, or `apex_ct_` value from all reports.
@@ -687,7 +695,7 @@ Telemetry event posts do not include bearer tokens. The telemetry endpoint is `P
687
695
  {
688
696
  "schemaVersion": 1,
689
697
  "event": "apex.invocation.completed",
690
- "cliVersion": "0.1.41",
698
+ "cliVersion": "0.1.43",
691
699
  "invocation": {
692
700
  "surface": "mcp",
693
701
  "command": "apex-scan",
@@ -718,7 +726,7 @@ The npm package also includes marketplace-ready plugin artifacts:
718
726
  - `.claude-plugin/plugin.json` and `.mcp.claude.json` for Claude Code plugin installs
719
727
  - `.claude-plugin/marketplace.json` for a Claude marketplace entry backed by the public npm package
720
728
 
721
- These plugin installs launch the pinned npm package with `npx -y -p @cantinasecurity/apex-cli@0.1.41 apex-mcp`, so users do not need to install `apex` globally before enabling the plugin.
729
+ These plugin installs launch the pinned npm package with `npx -y -p @cantinasecurity/apex-cli@0.1.43 apex-mcp`, so users do not need to install `apex` globally before enabling the plugin.
722
730
 
723
731
  The repository also includes `.agents/plugins/marketplace.json` for local Codex marketplace testing from a checkout.
724
732
 
@@ -20,13 +20,18 @@ Internal employee beta:
20
20
  apex triage findings --workspace-ref <id>
21
21
  apex triage review <finding-id> --workspace-ref <id> --expected-updated-at <timestamp from findings> --validity valid|partially_valid|invalid [--severity high] [--note <rationale>] [--request-id <uuid>]
22
22
  apex triage edit <finding-id> --workspace-ref <id> --file <edit.json> --expected-updated-at <timestamp from findings> [--request-id <uuid>]
23
- Edit JSON: {"action":"comment","content":"..."}, {"action":"severity","severity":"high"},
24
- {"action":"review","validity":"valid","severity":"high","note":"..."}, or {"action":"cvss","vector":"CVSS:3.1/...","note":"..."}.
23
+ Edit JSON supports comments only: {"action":"comment","content":"..."}.
25
24
  `;
26
25
  export function hasCodeAccess(me) {
27
26
  return Boolean(me?.user.triageWorkspaces?.length);
28
27
  }
29
28
  const uuid = z.string().uuid();
29
+ const commentEdit = z
30
+ .object({
31
+ action: z.literal("comment"),
32
+ content: z.string().trim().min(1).max(10_000),
33
+ })
34
+ .strict();
30
35
  const quote = (s) => `'${s.replaceAll("'", "'\\''")}'`;
31
36
  export function createSsmPluginOutputFilter() {
32
37
  const prefix = Buffer.from("\nStarting session with SessionId:");
@@ -429,7 +434,7 @@ export async function commandInternalTriage(client, flags, action, id) {
429
434
  const findingId = uuid.parse(id).toLowerCase(), file = getFlagString(flags, "file");
430
435
  if (!file)
431
436
  throw new Error("--file is required");
432
- const edit = JSON.parse(readFileSync(file, "utf8"));
437
+ const edit = commentEdit.parse(JSON.parse(readFileSync(file, "utf8")));
433
438
  const expectedUpdatedAt = z
434
439
  .string()
435
440
  .datetime({ offset: true })
package/dist/commands.js CHANGED
@@ -19,10 +19,50 @@ import { createWorkspaceBindingFromSummary, fetchCompanyCredits, fetchCompanyWor
19
19
  import { loadWorkspaceBinding, saveWorkspaceBinding } from "./workspace-binding.js";
20
20
  import { commandSetup as runCliSetup } from "./setup.js";
21
21
  import { getTelemetryStatus, setTelemetryAuthKind, setTelemetryEnabled, setTelemetryOnboardingOutcome, } from "./telemetry.js";
22
- import { commandUpdate as runCliUpdate } from "./update.js";
22
+ import { commandUpdate as runCliUpdate, getCliInstallDiagnostics, } from "./update.js";
23
23
  import { mkdir, writeFile } from "node:fs/promises";
24
24
  import path from "node:path";
25
25
  import { z } from "zod";
26
+ function getRequestedWorkspaceSrDoctorPayload(me, flags, cliInstallation) {
27
+ const requested = getFlagString(flags, "company")?.trim().toLowerCase();
28
+ if (!requested)
29
+ return null;
30
+ if (me.companies.some((company) => company.id.toLowerCase() === requested ||
31
+ company.handle?.toLowerCase() === requested)) {
32
+ return null;
33
+ }
34
+ const triageWorkspaces = me.user.triageWorkspaces ?? [];
35
+ const scopes = triageWorkspaces.filter((workspace) => workspace.companyId.toLowerCase() === requested ||
36
+ workspace.companyHandle?.toLowerCase() === requested);
37
+ if (scopes.length === 0) {
38
+ const companyIdsMissingHandles = [
39
+ ...new Set(triageWorkspaces
40
+ .filter((workspace) => !workspace.companyHandle)
41
+ .map((workspace) => workspace.companyId)),
42
+ ];
43
+ if (companyIdsMissingHandles.length > 0) {
44
+ throw new Error(`Workspace SR access metadata is missing a company handle. Retry with --company ${companyIdsMissingHandles.join(" or --company ")}.`);
45
+ }
46
+ return null;
47
+ }
48
+ const company = scopes[0];
49
+ const workspaceIds = [...new Set(scopes.map((scope) => scope.workspaceId))];
50
+ return {
51
+ cliInstallation,
52
+ authenticatedAs: me.user.username ?? me.user.id,
53
+ accessMode: "workspace_sr",
54
+ company: {
55
+ id: company.companyId,
56
+ name: company.companyName ?? null,
57
+ handle: company.companyHandle ?? null,
58
+ },
59
+ workspaces: workspaceIds.map((workspaceId) => ({
60
+ workspaceId,
61
+ findingsCommand: `apex triage findings --workspace-ref ${workspaceId}`,
62
+ createSandboxCommand: `apex code-access create --workspace-ref ${workspaceId}`,
63
+ })),
64
+ };
65
+ }
26
66
  const WORKSPACE_USE_PATTERN = "<workspace-name|workspace-prefix|workspace-id>";
27
67
  const WORKSPACE_BINDING_GUIDANCE = "Run `apex workspaces` to list workspace names, prefixes, and IDs, then bind one with `apex workspace use \"<workspace name>\"`, or run `apex scan` to create or resolve one automatically.";
28
68
  const WORKSPACE_SELECTION_GUIDANCE = "Run `apex workspaces` to list available workspace names, prefixes, and IDs, then retry `apex workspace use \"<workspace name>\"`.";
@@ -996,6 +1036,21 @@ export async function commandTelemetry(flags, target) {
996
1036
  return payload;
997
1037
  }
998
1038
  export async function commandDoctor(client, cwd, flags) {
1039
+ const cliInstallation = await getCliInstallDiagnostics().catch(() => null);
1040
+ if (!isJsonMode(flags) && cliInstallation) {
1041
+ const manager = cliInstallation.packageManager
1042
+ ? ` via ${cliInstallation.packageManager}`
1043
+ : cliInstallation.installKind === "git-checkout"
1044
+ ? " from a Git checkout"
1045
+ : "";
1046
+ logLine(`CLI: ${escapeTerminalControlCharacters(cliInstallation.currentVersion)}${manager}`, flags);
1047
+ if (cliInstallation.warning) {
1048
+ logLine(`CLI installation warning: ${escapeTerminalControlCharacters(cliInstallation.warning)}`, flags);
1049
+ }
1050
+ if (cliInstallation.repairCommand) {
1051
+ logLine(`Repair: ${escapeTerminalControlCharacters(cliInstallation.repairCommand)}`, flags);
1052
+ }
1053
+ }
999
1054
  try {
1000
1055
  const serviceKeyGuidance = getServiceKeyCliAuthGuidance();
1001
1056
  const existingMe = serviceKeyGuidance ? await getMe(client) : null;
@@ -1005,6 +1060,22 @@ export async function commandDoctor(client, cwd, flags) {
1005
1060
  const me = existingMe ?? (await ensureAuthenticated(client, flags));
1006
1061
  const authState = await getCommandAuthState(client, me);
1007
1062
  setTelemetryAuthKind(authState.authKind);
1063
+ const workspaceSrPayload = getRequestedWorkspaceSrDoctorPayload(me, flags, cliInstallation);
1064
+ if (workspaceSrPayload) {
1065
+ if (isJsonMode(flags)) {
1066
+ printJson(workspaceSrPayload);
1067
+ return workspaceSrPayload;
1068
+ }
1069
+ logLine(`Authenticated as ${workspaceSrPayload.authenticatedAs}`, flags);
1070
+ logLine(`Company: ${workspaceSrPayload.company.handle ?? workspaceSrPayload.company.id}`, flags);
1071
+ logLine("Access: workspace-scoped SR", flags);
1072
+ for (const workspace of workspaceSrPayload.workspaces) {
1073
+ logLine(`Workspace: ${workspace.workspaceId}`, flags);
1074
+ logLine(`Findings: ${workspace.findingsCommand}`, flags);
1075
+ logLine(`Create sandbox: ${workspace.createSandboxCommand}`, flags);
1076
+ }
1077
+ return workspaceSrPayload;
1078
+ }
1008
1079
  const result = await withLoadingIndicator("Inspecting this directory and checking Apex setup...", flags, async () => {
1009
1080
  if (authState.authKind === "guest") {
1010
1081
  const selection = await selectWorkspaceTarget(cwd, me, flags);
@@ -1014,7 +1085,9 @@ export async function commandDoctor(client, cwd, flags) {
1014
1085
  });
1015
1086
  const localSnapshotCount = result.resolve.plannedSources.filter((source) => source.sourceKind === "local_archive").length;
1016
1087
  const payload = {
1088
+ cliInstallation,
1017
1089
  authenticatedAs: me.user.username ?? me.user.id,
1090
+ accessMode: "company",
1018
1091
  company: result.company,
1019
1092
  workspaceName: result.workspaceName,
1020
1093
  binding: result.binding,
@@ -1033,10 +1106,14 @@ export async function commandDoctor(client, cwd, flags) {
1033
1106
  return payload;
1034
1107
  }
1035
1108
  catch (error) {
1109
+ if (error instanceof Error) {
1110
+ error.cliInstallation = cliInstallation;
1111
+ }
1036
1112
  if (isJsonMode(flags)) {
1037
1113
  const payload = {
1038
1114
  ok: false,
1039
1115
  error: formatApiError(error),
1116
+ cliInstallation,
1040
1117
  };
1041
1118
  printJson(payload);
1042
1119
  if (error instanceof Error) {
package/dist/help.js CHANGED
@@ -27,7 +27,7 @@ export const CLI_HELP_TEXT = `Usage:
27
27
  Bind this directory to an existing Apex workspace
28
28
  apex cancel-scan [scan-id] Cancel a running scan
29
29
  apex status Show progress for the latest or selected scan
30
- apex doctor Validate auth, repos, connections, and workspace binding
30
+ apex doctor Validate CLI installation, auth, repos, connections, and workspace binding
31
31
  apex login Sign in to Apex
32
32
  apex redeem <code> Redeem a Standard-scan code without device login
33
33
  apex claim Link a guest workspace and findings to a Cantina account
@@ -178,7 +178,7 @@ Commands:
178
178
  /workspaces List accessible workspaces for the active company
179
179
  /cancel-scan [scan-id] Cancel a running or most recent scan
180
180
  /status [scan-id] Show progress for the most recent or selected scan
181
- /doctor Validate auth, repos, connections, and workspace binding
181
+ /doctor Validate CLI installation, auth, repos, connections, and workspace binding
182
182
  /telemetry [status|enable|disable]
183
183
  Show or change local anonymous usage telemetry settings
184
184
  /update Update the local Apex CLI install and exit the shell
package/dist/mcp.js CHANGED
@@ -56,6 +56,11 @@ const PR_MISSING_PROVIDER_CONNECTIONS_ERROR_PREFIX = "PR scans require connected
56
56
  const MULTIPLE_ACTIVE_SCANS_ERROR = "Multiple active scans are running. Run `apex scans` to list scan IDs, then re-run `apex cancel-scan <scan-id>`.";
57
57
  const DUPLICATE_SCAN_FORCE_GUIDANCE = "Re-run with --force to start another scan.";
58
58
  const MULTIPLE_COMPANIES_ERROR = "Multiple companies available. Re-run with --company <id-or-handle>.";
59
+ const LOCAL_NOT_AUTHENTICATED_ERROR = "Apex is not authenticated. Run `apex login` explicitly, then retry this command.";
60
+ const LOCAL_CLAIM_PROOF_ERROR = "Your guest trial session is no longer available, but its claim proof is stored on this machine. Run `apex claim` to keep the trial results.";
61
+ const SERVICE_KEY_LOCAL_AUTH_GUIDANCE = "service keys authenticate the hosted MCP and REST endpoints, not the local Apex CLI";
62
+ const MCP_NOT_AUTHENTICATED_ERROR = "Not authenticated. If the user supplied a Cantina trial code, run `apex redeem <code>` in a terminal instead of starting device login; never put the code in an MCP tool call. Otherwise call `apex-auth-start`, ask the user to approve the device login, then call `apex-auth-wait`.";
63
+ const MCP_CLAIM_PROOF_ERROR = "Your guest trial session is no longer available, but its claim proof is stored on this machine. Run `apex claim` in a terminal to keep the trial results before starting another login; claim is intentionally not exposed through MCP.";
59
64
  function resolveCwd(cwd) {
60
65
  return path.resolve(cwd && cwd.trim().length > 0 ? cwd : process.cwd());
61
66
  }
@@ -143,6 +148,15 @@ function successResult(summary, value) {
143
148
  }
144
149
  function formatMcpErrorMessage(toolName, error) {
145
150
  const message = formatApiError(error);
151
+ if (message === LOCAL_NOT_AUTHENTICATED_ERROR) {
152
+ return MCP_NOT_AUTHENTICATED_ERROR;
153
+ }
154
+ if (message === LOCAL_CLAIM_PROOF_ERROR) {
155
+ return MCP_CLAIM_PROOF_ERROR;
156
+ }
157
+ if (message.includes(SERVICE_KEY_LOCAL_AUTH_GUIDANCE)) {
158
+ return `${message} ${MCP_NOT_AUTHENTICATED_ERROR}`;
159
+ }
146
160
  if (message === WORKSPACE_BINDING_ERROR) {
147
161
  return "No workspace is bound to this directory. Call `apex-workspace-use` to bind an existing workspace, or call `apex-scan` to create or resolve one first.";
148
162
  }
@@ -165,16 +179,23 @@ function formatMcpErrorMessage(toolName, error) {
165
179
  }
166
180
  function errorResult(toolName, error) {
167
181
  const message = formatMcpErrorMessage(toolName, error);
182
+ const cliInstallation = error instanceof Error && "cliInstallation" in error
183
+ ? error.cliInstallation
184
+ : undefined;
185
+ const diagnosis = cliInstallation
186
+ ? `\n\nCLI installation diagnosis:\n${JSON.stringify(cliInstallation, null, 2)}`
187
+ : "";
168
188
  return {
169
189
  content: [
170
190
  {
171
191
  type: "text",
172
- text: message,
192
+ text: `${message}${diagnosis}`,
173
193
  },
174
194
  ],
175
195
  structuredContent: {
176
196
  ok: false,
177
197
  error: message,
198
+ ...(cliInstallation ? { cliInstallation } : {}),
178
199
  },
179
200
  isError: true,
180
201
  };
@@ -199,9 +220,9 @@ async function requireAuthenticated(client) {
199
220
  if (!me) {
200
221
  const credentials = await client.getCredentials();
201
222
  if (isClaimProofAvailable(credentials)) {
202
- throw new Error("Your guest trial session is no longer available, but its claim proof is stored on this machine. Run `apex claim` in a terminal to keep the trial results before starting another login; claim is intentionally not exposed through MCP.");
223
+ throw new Error(MCP_CLAIM_PROOF_ERROR);
203
224
  }
204
- throw new Error("Not authenticated. If the user supplied a Cantina trial code, run `apex redeem <code>` in a terminal instead of starting device login; never put the code in an MCP tool call. Otherwise call `apex-auth-start`, ask the user to approve the device login, then call `apex-auth-wait`.");
225
+ throw new Error(MCP_NOT_AUTHENTICATED_ERROR);
205
226
  }
206
227
  return me;
207
228
  }
@@ -396,7 +417,7 @@ function registerTools(server, auditSetupEnabled = false) {
396
417
  });
397
418
  server.registerTool("apex-doctor", {
398
419
  title: "Run Apex Doctor",
399
- description: "Validate auth, local source detection, workspace binding, and the planned remote-vs-local materialization strategy.",
420
+ description: "Validate the CLI installation, auth, local source detection, workspace binding, and the planned remote-vs-local materialization strategy.",
400
421
  inputSchema: {
401
422
  cwd: z.string().optional(),
402
423
  company: z.string().optional(),
@@ -406,7 +427,6 @@ function registerTools(server, auditSetupEnabled = false) {
406
427
  },
407
428
  }, async ({ cwd, company, workspaceName, repoPaths, sourceMode }) => runTool("apex-doctor", async () => {
408
429
  const client = new ApexApiClient();
409
- await requireAuthenticated(client);
410
430
  const targetCwd = resolveCwd(cwd);
411
431
  const payload = await commandDoctor(client, targetCwd, buildFlags({
412
432
  company,
package/dist/update.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { execFile as execFileCallback, spawn } from "node:child_process";
2
- import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
+ import { mkdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
@@ -51,19 +51,23 @@ async function readPackageVersion(packageRoot) {
51
51
  }
52
52
  return parsed.version.trim();
53
53
  }
54
- async function execText(command, args, cwd) {
54
+ async function canonicalPath(targetPath) {
55
+ return realpath(targetPath).catch(() => path.resolve(targetPath));
56
+ }
57
+ async function execText(command, args, cwd, timeout) {
55
58
  const result = await execFile(command, args, {
56
59
  cwd,
57
60
  encoding: "utf8",
61
+ timeout,
58
62
  });
59
63
  return {
60
64
  stdout: result.stdout.trim(),
61
65
  stderr: result.stderr.trim(),
62
66
  };
63
67
  }
64
- async function tryExecText(command, args, cwd) {
68
+ async function tryExecText(command, args, cwd, timeout) {
65
69
  try {
66
- return await execText(command, args, cwd);
70
+ return await execText(command, args, cwd, timeout);
67
71
  }
68
72
  catch {
69
73
  return null;
@@ -93,25 +97,121 @@ async function detectProjectPackageManager(packageRoot) {
93
97
  }
94
98
  return null;
95
99
  }
96
- async function detectGlobalInstaller() {
97
- if (await tryExecText("pnpm", ["--version"])) {
100
+ function globalInstallCommand(packageManager, packageSpec = REPO_SPEC) {
101
+ if (packageManager === "pnpm") {
98
102
  return {
99
103
  command: "pnpm",
100
- args: ["add", "-g", REPO_SPEC],
101
- label: `pnpm add -g ${REPO_SPEC}`,
104
+ args: ["add", "-g", packageSpec],
105
+ label: `pnpm add -g ${packageSpec}`,
102
106
  };
103
107
  }
104
- if (await tryExecText("npm", ["--version"])) {
108
+ return {
109
+ command: "npm",
110
+ args: ["install", "-g", packageSpec],
111
+ label: `npm install -g ${packageSpec}`,
112
+ };
113
+ }
114
+ function globalRemoveCommand(packageManager) {
115
+ if (packageManager === "pnpm") {
105
116
  return {
106
- command: "npm",
107
- args: ["install", "-g", REPO_SPEC],
108
- label: `npm install -g ${REPO_SPEC}`,
117
+ command: "pnpm",
118
+ args: ["remove", "-g", REPO_SPEC],
119
+ label: `pnpm remove -g ${REPO_SPEC}`,
109
120
  };
110
121
  }
111
122
  return {
112
- command: "pnpm",
113
- args: ["add", "-g", REPO_SPEC],
114
- label: `pnpm add -g ${REPO_SPEC}`,
123
+ command: "npm",
124
+ args: ["uninstall", "-g", REPO_SPEC],
125
+ label: `npm uninstall -g ${REPO_SPEC}`,
126
+ };
127
+ }
128
+ async function getGlobalPackageManagerRoots() {
129
+ const candidates = await Promise.all(["pnpm", "npm"].map(async (packageManager) => {
130
+ const result = await tryExecText(packageManager, ["root", "-g"], undefined, 2_000);
131
+ if (!result?.stdout)
132
+ return null;
133
+ return { packageManager, root: path.resolve(result.stdout) };
134
+ }));
135
+ return candidates.filter((candidate) => candidate !== null);
136
+ }
137
+ async function inspectGlobalInstallations(packageRoot) {
138
+ const [activeRoot, roots] = await Promise.all([
139
+ canonicalPath(packageRoot),
140
+ getGlobalPackageManagerRoots(),
141
+ ]);
142
+ const installations = (await Promise.all(roots.map(async ({ packageManager, root }) => {
143
+ const candidateRoot = path.join(root, ...PACKAGE_NAME.split("/"));
144
+ const version = await readPackageVersion(candidateRoot).catch(() => null);
145
+ if (!version)
146
+ return null;
147
+ const resolvedCandidateRoot = await canonicalPath(candidateRoot);
148
+ return {
149
+ packageManager,
150
+ version,
151
+ packageRoot: candidateRoot,
152
+ active: resolvedCandidateRoot === activeRoot,
153
+ };
154
+ }))).filter((installation) => installation !== null);
155
+ return { roots, installations };
156
+ }
157
+ async function detectGlobalInstaller(packageRoot) {
158
+ const { installations } = await inspectGlobalInstallations(packageRoot);
159
+ const owner = installations.find((installation) => installation.active);
160
+ if (owner) {
161
+ return {
162
+ ...globalInstallCommand(owner.packageManager),
163
+ packageRoot: owner.packageRoot,
164
+ };
165
+ }
166
+ throw new Error("Apex could not determine which package manager owns this installation. Run `npm install -g @cantinasecurity/apex-cli` or `pnpm add -g @cantinasecurity/apex-cli` explicitly.");
167
+ }
168
+ export async function getCliInstallDiagnostics(packageRoot = PACKAGE_ROOT) {
169
+ const currentVersion = await readPackageVersion(packageRoot);
170
+ const gitCheckout = await pathExists(path.join(packageRoot, ".git"));
171
+ if (gitCheckout) {
172
+ return {
173
+ currentVersion,
174
+ installKind: "git-checkout",
175
+ packageManager: null,
176
+ healthy: true,
177
+ installations: [],
178
+ warning: null,
179
+ repairCommand: null,
180
+ };
181
+ }
182
+ const { installations } = await inspectGlobalInstallations(packageRoot);
183
+ const active = installations.find((installation) => installation.active) ?? null;
184
+ const duplicates = installations.filter((installation) => !installation.active);
185
+ const newerDuplicate = duplicates.find((installation) => compareVersions(currentVersion, installation.version) < 0);
186
+ const mismatchedTransientCopies = active
187
+ ? []
188
+ : installations.filter((installation) => installation.version !== currentVersion);
189
+ const warning = active && newerDuplicate
190
+ ? `This process is Apex CLI ${currentVersion}, but ${newerDuplicate.packageManager} also has newer Apex CLI ${newerDuplicate.version}. Your shell is resolving the older installation.`
191
+ : active && duplicates.length > 0
192
+ ? `Multiple global Apex CLI installations were found (${installations.map((installation) => `${installation.packageManager} ${installation.version}`).join(", ")}). They can drift and cause the shell and MCP clients to run different versions.`
193
+ : mismatchedTransientCopies.length > 0
194
+ ? `This process is Apex CLI ${currentVersion} from a temporary package, but global Apex installations use ${installations.map((installation) => `${installation.packageManager} ${installation.version}`).join(", ")}. Terminal commands may resolve a different version.`
195
+ : null;
196
+ const repairCommand = active && duplicates.length > 0
197
+ ? [
198
+ globalInstallCommand(active.packageManager, `${REPO_SPEC}@latest`).label,
199
+ ...duplicates.map((installation) => globalRemoveCommand(installation.packageManager).label),
200
+ ].join(" && ")
201
+ : mismatchedTransientCopies.length > 0
202
+ ? installations
203
+ .map((installation) => globalInstallCommand(installation.packageManager, `${REPO_SPEC}@latest`).label)
204
+ .join(" && ")
205
+ : null;
206
+ const publicInstallations = installations.map(({ packageRoot: _packageRoot, ...installation }) => installation);
207
+ return {
208
+ currentVersion,
209
+ installKind: "package-install",
210
+ packageManager: active?.packageManager ?? null,
211
+ healthy: warning === null,
212
+ installations: publicInstallations,
213
+ warning,
214
+ repairCommand,
115
215
  };
116
216
  }
117
217
  async function resolveInstallContext(packageRoot = PACKAGE_ROOT) {
@@ -146,23 +246,27 @@ async function resolveInstallContext(packageRoot = PACKAGE_ROOT) {
146
246
  upstreamRef: upstream?.stdout ?? null,
147
247
  updateCommands,
148
248
  updateCommand: display.join(" && "),
249
+ verificationPackageRoot: null,
149
250
  };
150
251
  }
151
252
  }
152
- const installer = await detectGlobalInstaller();
253
+ const installer = await detectGlobalInstaller(packageRoot).catch(() => null);
153
254
  return {
154
255
  packageRoot,
155
256
  currentVersion,
156
257
  currentRevision: null,
157
258
  installKind: "package-install",
158
259
  upstreamRef: null,
159
- updateCommands: [
160
- {
161
- command: installer.command,
162
- args: installer.args,
163
- },
164
- ],
165
- updateCommand: installer.label,
260
+ updateCommands: installer
261
+ ? [
262
+ {
263
+ command: installer.command,
264
+ args: installer.args,
265
+ },
266
+ ]
267
+ : [],
268
+ updateCommand: installer?.label ?? null,
269
+ verificationPackageRoot: installer?.packageRoot ?? null,
166
270
  };
167
271
  }
168
272
  async function loadUpdateState() {
@@ -431,7 +535,7 @@ export async function commandUpdate(flags, packageRoot = PACKAGE_ROOT) {
431
535
  return status;
432
536
  }
433
537
  if (!context.updateCommand || context.updateCommands.length === 0) {
434
- throw new Error("No update command is available for this Apex CLI installation.");
538
+ throw new Error("Apex could not determine which package manager owns this installation. Run `npm install -g @cantinasecurity/apex-cli` or `pnpm add -g @cantinasecurity/apex-cli` explicitly.");
435
539
  }
436
540
  await ensureCleanGitCheckout(context);
437
541
  if (!isJsonMode(flags)) {
@@ -445,13 +549,24 @@ export async function commandUpdate(flags, packageRoot = PACKAGE_ROOT) {
445
549
  ...(await getUpdateStatus(packageRoot, true)),
446
550
  updated: true,
447
551
  }
448
- : {
449
- ...status,
450
- currentVersion: status.latestVersion ?? status.currentVersion,
451
- currentRevision: status.latestRevision ?? status.currentRevision,
452
- available: false,
453
- updated: true,
454
- };
552
+ : await (async () => {
553
+ const installedVersion = await readPackageVersion(context.verificationPackageRoot ?? packageRoot);
554
+ if (status.latestVersion &&
555
+ compareVersions(installedVersion, status.latestVersion) < 0) {
556
+ const diagnostics = await getCliInstallDiagnostics(packageRoot).catch(() => null);
557
+ const repair = diagnostics?.repairCommand
558
+ ? ` Run \`${diagnostics.repairCommand}\` to update the installation used by this shell.`
559
+ : " Update the package-manager installation selected by `which apex`.";
560
+ throw new Error(`The installer finished, but this Apex executable is still ${installedVersion}; expected ${status.latestVersion}.${repair}`);
561
+ }
562
+ return {
563
+ ...status,
564
+ currentVersion: installedVersion,
565
+ currentRevision: status.latestRevision ?? status.currentRevision,
566
+ available: false,
567
+ updated: true,
568
+ };
569
+ })();
455
570
  if (isJsonMode(flags)) {
456
571
  printJson(payload);
457
572
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cantinasecurity/apex-cli",
3
- "version": "0.1.41",
3
+ "version": "0.1.43",
4
4
  "description": "Standalone CLI and MCP server for Apex.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -76,7 +76,7 @@ Guidelines:
76
76
  - Hosted repository scan triggers require `Idempotency-Key`. Reuse one stable key only for an identical transport/timeout/5xx retry. After correcting a definitive 4xx response, or for any new intended repository scan, use a new key. Apex Black stored start failures follow the support escalation rule above; never change keys to bypass them.
77
77
  - `apex-credits` reports standard credits plus audit, fix review, and Lite scan entitlements when Bedrock returns those balances.
78
78
  - `apex-scan` scans the provided `cwd` by default; pass `repoPaths` only when the user asks to scan explicit alternate roots.
79
- - `apex-doctor` is read-only for guest sessions and reports the local snapshot plan without reserving a workspace. For account sessions it reports whether Apex will use remote materialization or a local snapshot upload for each selected source.
79
+ - `apex-doctor` reports the running CLI version and detects conflicting npm/pnpm global installations. It is read-only for guest sessions and reports the local snapshot plan without reserving a workspace. For account sessions it reports whether Apex will use remote materialization or a local snapshot upload for each selected source.
80
80
  - Apex can scan plain local directories and dirty git worktrees without provider connections by using local snapshot uploads.
81
81
  - Audit scans use `mode: "audit"` in user-facing instructions and request payloads. The legacy `ultra` mode remains accepted as an alias, but audit scans still require provider-backed GitHub, GitLab or company-enabled Forgejo sources.
82
82
  - Lite scans use `mode: "lite"`, require Lite Scan beta access, and require provider-backed GitHub or GitLab sources without local snapshot fallback.