@cantinasecurity/apex-cli 0.1.0

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.
@@ -0,0 +1,38 @@
1
+ ---
2
+ name: apex-cli
3
+ description: Use when a user wants to run Apex scans, inspect findings, export results, bind workspaces, or troubleshoot Apex auth and provider setup. Prefer the Apex MCP tools when they are available, and fall back to the local Apex CLI only when MCP is unavailable.
4
+ ---
5
+
6
+ # Apex CLI
7
+
8
+ This skill is bundled with Apex CLI and can be installed into the current repository with `apex setup claude`.
9
+
10
+ ## Instructions
11
+
12
+ Use Apex through the MCP server when the `apex-*` tools are available.
13
+
14
+ Recommended workflow:
15
+
16
+ 1. Start with `apex-auth-status`.
17
+ 2. If Apex is unauthenticated, call `apex-auth-start`, ask the user to approve the device-login URL, then call `apex-auth-wait` with the returned `deviceCode`.
18
+ 3. For repository-scoped work, pass `cwd` explicitly.
19
+ 4. Call `apex-doctor` before starting a scan if workspace binding or source planning might be unclear.
20
+ 5. If the directory is not bound to the right workspace, call `apex-workspaces` and `apex-workspace-use`.
21
+ 6. Use `apex-scan`, `apex-status`, `apex-scans`, `apex-findings`, and `apex-export-findings` for the scan lifecycle.
22
+
23
+ If the Apex MCP server is not configured, fall back to the local CLI:
24
+
25
+ - Prefer scripted commands over the interactive shell.
26
+ - Use `--non-interactive` and `--no-open` for automation-friendly CLI calls.
27
+ - Use `--json` whenever structured output is helpful.
28
+ - Work from the target repository directory so Apex can resolve `.apex/workspace.json`.
29
+ - `apex-doctor` reports whether Apex will use remote materialization or a local snapshot upload for each selected source.
30
+ - Plain local directories and dirty git worktrees can scan through local snapshot uploads without provider access.
31
+ - `apex-workspace-use` accepts a workspace name, prefix, or ID.
32
+ - Use `sourceMode: "remote"` only when the user explicitly wants to forbid local snapshot fallbacks.
33
+
34
+ ## Examples
35
+
36
+ - Start a scan for the current repository or directory: run `apex-doctor`, bind a workspace if needed, then call `apex-scan`.
37
+ - Check an active scan: call `apex-status`, then `apex-findings` when the scan completes.
38
+ - Export findings for review: call `apex-export-findings` with `format` set to `markdown`, `json`, or `gitlab-sast`.
package/README.md ADDED
@@ -0,0 +1,259 @@
1
+ # Apex CLI
2
+
3
+ Standalone CLI client for Apex.
4
+
5
+ ## Installing And Updating
6
+
7
+ For a public install, use a global package manager install:
8
+
9
+ ```bash
10
+ npm install -g @cantinasecurity/apex-cli
11
+ # or: pnpm add -g @cantinasecurity/apex-cli
12
+ ```
13
+
14
+ Then run:
15
+
16
+ ```bash
17
+ apex setup
18
+ ```
19
+
20
+ `apex setup` is the lowest-friction path for agent clients. It:
21
+
22
+ - registers Apex as an MCP server in any installed Codex and Claude Code CLIs
23
+ - installs the Codex skill into `$CODEX_HOME/skills/apex-cli`
24
+ - installs the Claude project skill into `.claude/skills/apex-cli` in the current repository
25
+
26
+ If you only want one client, run:
27
+
28
+ ```bash
29
+ apex setup codex
30
+ apex setup claude
31
+ ```
32
+
33
+ If one client is not installed yet, `apex setup` skips it automatically. If you target a client explicitly, its CLI must already be installed.
34
+
35
+ Update a global install with:
36
+
37
+ ```bash
38
+ apex update
39
+ ```
40
+
41
+ You can also update directly with your package manager:
42
+
43
+ ```bash
44
+ npm install -g @cantinasecurity/apex-cli
45
+ # or: pnpm add -g @cantinasecurity/apex-cli
46
+ ```
47
+
48
+ If you are running Apex CLI from a local checkout instead, update it with:
49
+
50
+ ```bash
51
+ git pull --ff-only
52
+ pnpm install
53
+ ```
54
+
55
+ MCP registrations keep working across package updates because they point at the stable `apex-mcp` shim. Re-run `apex setup` after upgrading if you want to refresh copied skill files, and run `apex setup claude` in each repository where you want the Claude project skill.
56
+
57
+ When `apex` is run in an interactive terminal, it checks for updates periodically and offers to install them.
58
+
59
+ ## Local Development
60
+
61
+ 1. Install dependencies:
62
+
63
+ ```bash
64
+ pnpm install
65
+ ```
66
+
67
+ 2. Run the CLI:
68
+
69
+ ```bash
70
+ pnpm apex
71
+ ```
72
+
73
+ By default, the CLI targets `https://ai.cantina.xyz/`.
74
+
75
+ Use `APEX_BASE_URL` only when testing against a non-production Apex host:
76
+
77
+ ```bash
78
+ APEX_BASE_URL=https://preview.cantina.xyz pnpm apex
79
+ ```
80
+
81
+ ## Interactive Shell
82
+
83
+ Bare `apex` opens the interactive shell:
84
+
85
+ ```text
86
+ $ apex
87
+
88
+ Apex CLI
89
+ Connected to https://ai.cantina.xyz/
90
+ Type /scan to start a scan for this directory, /workspaces to browse workspace names, /workspace use "<name>" to switch, /help for commands.
91
+ apex>
92
+ ```
93
+
94
+ In interactive terminals, Apex now shows a loading indicator while it resolves workspaces, loads scans, and starts commands.
95
+
96
+ If Apex asks for a workspace name, that is the Apex workspace name for the current directory. Press Enter to accept the current folder name, or pass `--workspace-name <name>` explicitly.
97
+
98
+ Supported shell commands:
99
+
100
+ - `/credits`
101
+ - `/scan [standard|ultra]`
102
+ - `/scans`
103
+ - `/findings [scan-id]`
104
+ - `/export [scan-id]`
105
+ - `/workspaces`
106
+ - `/cancel-scan [scan-id]`
107
+ - `/status`
108
+ - `/doctor`
109
+ - `/update`
110
+ - `/logout`
111
+ - `/repos`
112
+ - `/workspace`
113
+ - `/workspace use <workspace-name|workspace-prefix|workspace-id>`
114
+ - `/workspace name <name>`
115
+ - `/company [id|handle]`
116
+ - `/connect github`
117
+ - `/connect gitlab`
118
+ - `/open`
119
+ - `/clear`
120
+ - `/help`
121
+ - `/exit`
122
+
123
+ `/workspace use` accepts a workspace name, prefix, or ID. Quote workspace names that contain spaces, for example `/workspace use "Core Platform"`.
124
+
125
+ ## Scripted Commands
126
+
127
+ - `apex credits`
128
+ - `apex scan`
129
+ - `apex scans`
130
+ - `apex findings [--scan <scan-id>]`
131
+ - `apex export findings [--scan <scan-id>] [--format markdown|json|gitlab-sast] [--output <path>]`
132
+ - `apex workspaces`
133
+ - `apex workspace`
134
+ - `apex workspace use <workspace-name|workspace-prefix|workspace-id>`
135
+ - `apex cancel-scan [scan-id]`
136
+ - `apex status`
137
+ - `apex doctor`
138
+ - `apex login`
139
+ - `apex logout`
140
+ - `apex setup [all|codex|claude]`
141
+ - `apex update`
142
+ - `apex connect github`
143
+ - `apex connect gitlab`
144
+
145
+ Helpful workspace flags:
146
+
147
+ - `--company <id-or-handle>` to choose the Apex company when more than one is available
148
+ - `--workspace-name <name>` to set the Apex workspace name for this directory
149
+
150
+ ## Local Source Scans
151
+
152
+ `apex scan` now works against any local source root you point it at:
153
+
154
+ - clean GitHub or GitLab checkouts can stay on the remote-materialization path
155
+ - dirty git worktrees fall back to a local snapshot upload by default
156
+ - plain directories that are not git repositories are scanned through a local snapshot upload
157
+
158
+ Useful flags:
159
+
160
+ - `--repo <path>` to scan one or more explicit local roots
161
+ - `--source-mode auto|remote|local` to control remote-first fallback behavior
162
+
163
+ `auto` is the default. `remote` requires Apex to materialize from a remote repository. `local` forces a local snapshot upload even when a clean remote path is available.
164
+
165
+ Ultra scans still require provider-backed GitHub or GitLab repositories that Apex can materialize remotely without a local snapshot fallback.
166
+
167
+ ## LLM / MCP Usage
168
+
169
+ The CLI now ships an MCP server so LLM clients can drive Apex directly over stdio.
170
+
171
+ If Apex is installed globally, prefer:
172
+
173
+ ```bash
174
+ apex setup
175
+ ```
176
+
177
+ That registers Apex for installed Codex and Claude Code clients automatically.
178
+
179
+ If you want to wire clients manually instead, Apex ships a stable `apex-mcp` binary. For Codex:
180
+
181
+ ```bash
182
+ codex mcp add apex -- apex-mcp
183
+ ```
184
+
185
+ For Claude Code:
186
+
187
+ ```bash
188
+ claude mcp add --scope user apex -- apex-mcp
189
+ ```
190
+
191
+ For any other MCP client, configure it to launch:
192
+
193
+ ```json
194
+ {
195
+ "mcpServers": {
196
+ "apex": {
197
+ "command": "apex-mcp"
198
+ }
199
+ }
200
+ }
201
+ ```
202
+
203
+ From a local checkout during development, prefer the repo-local binary so the MCP stream stays clean:
204
+
205
+ ```json
206
+ {
207
+ "mcpServers": {
208
+ "apex": {
209
+ "command": "/path/to/apex-cli/bin/apex-mcp"
210
+ }
211
+ }
212
+ }
213
+ ```
214
+
215
+ If you need to launch through `pnpm`, use `--silent`:
216
+
217
+ ```json
218
+ {
219
+ "mcpServers": {
220
+ "apex": {
221
+ "command": "pnpm",
222
+ "args": ["--silent", "mcp"],
223
+ "cwd": "/path/to/apex-cli"
224
+ }
225
+ }
226
+ }
227
+ ```
228
+
229
+ Do not point an MCP client at plain `pnpm mcp`. `pnpm` writes its script banner to `stdout` before the protocol stream, which can break the `initialize` handshake.
230
+
231
+ The MCP server exposes Apex-specific tools for:
232
+
233
+ - auth status and device login
234
+ - doctor, credits, and provider connection URLs
235
+ - workspace inspection and workspace binding
236
+ - scan start, status, cancellation, findings, and findings export
237
+
238
+ For repository-scoped operations, pass `cwd` explicitly so the server can resolve the right `.apex/workspace.json` binding and repository roots.
239
+
240
+ For Codex-style clients, the packaged skill can be installed with `apex setup codex`. The repo-local source lives at `skills/apex-cli/SKILL.md`.
241
+
242
+ For Claude Code, the packaged project skill can be installed into the current repository with `apex setup claude`. The repo-local source lives at `.claude/skills/apex-cli/SKILL.md`. Anthropic documents project skills as filesystem directories under `.claude/skills/<name>/SKILL.md`, and the Claude Agent SDK uses the same location when the `Skill` tool is enabled.
243
+
244
+ ## Development Notes
245
+
246
+ The CLI uses the Apex `/api/cli/v2/**` local-source routes for scan planning and snapshot uploads, with legacy `/api/cli/v1/**` routes still used for provider-backed flows such as ultra scans. Local state is stored under:
247
+
248
+ - `~/.config/apex/config.json`
249
+ - `~/.config/apex/credentials.json`
250
+ - `.apex/workspace.json`
251
+
252
+ If a scan is already running in the current workspace, `apex scan` and `/scan` now require confirmation before starting another one. Scripted usage can opt in explicitly with `--force`.
253
+
254
+ To move between existing Apex workspaces from the CLI:
255
+
256
+ 1. Run `apex workspaces` to list the workspaces available to your active company.
257
+ 2. Run `apex workspace use <workspace-name|workspace-prefix|workspace-id>` to bind the current directory.
258
+ 3. If the workspace name contains spaces, quote it, for example `apex workspace use "Core Platform"`.
259
+ 4. Use `apex scans`, `apex findings`, and `apex export findings` against that binding.
package/dist/apex.js ADDED
@@ -0,0 +1,94 @@
1
+ import { ApexApiClient, formatApiError } from "./api-client.js";
2
+ import { parseArgs } from "./args.js";
3
+ import { commandCancelScan, commandConnect, commandCredits, commandDoctor, commandExportFindings, commandFindings, commandLogin, commandLogout, commandScan, commandScans, commandSetup, commandStatus, commandUpdate, commandWorkspace, commandWorkspaceUse, commandWorkspaces, } from "./commands.js";
4
+ import { CLI_HELP_TEXT } from "./help.js";
5
+ import { runMcpServer } from "./mcp.js";
6
+ import { runInteractiveShell } from "./shell.js";
7
+ import { maybePromptForUpdate } from "./update.js";
8
+ async function main() {
9
+ const parsed = parseArgs(process.argv.slice(2));
10
+ if (parsed.flags.help === true || parsed.command === "help") {
11
+ process.stdout.write(CLI_HELP_TEXT);
12
+ return;
13
+ }
14
+ if (parsed.command === "mcp") {
15
+ await runMcpServer();
16
+ return;
17
+ }
18
+ const client = new ApexApiClient();
19
+ const cwd = process.cwd();
20
+ if (await maybePromptForUpdate(parsed.flags, parsed.command)) {
21
+ return;
22
+ }
23
+ switch (parsed.command) {
24
+ case null:
25
+ await runInteractiveShell(client, cwd, parsed.flags);
26
+ return;
27
+ case "scan":
28
+ await commandScan(client, cwd, parsed.flags);
29
+ return;
30
+ case "scans":
31
+ await commandScans(client, cwd, parsed.flags);
32
+ return;
33
+ case "cancel-scan":
34
+ await commandCancelScan(client, cwd, parsed.flags, parsed.subcommand);
35
+ return;
36
+ case "login":
37
+ await commandLogin(client, parsed.flags);
38
+ return;
39
+ case "logout":
40
+ await commandLogout(client, parsed.flags);
41
+ return;
42
+ case "update":
43
+ await commandUpdate(parsed.flags);
44
+ return;
45
+ case "setup":
46
+ await commandSetup(cwd, parsed.flags, parsed.subcommand);
47
+ return;
48
+ case "doctor":
49
+ await commandDoctor(client, cwd, parsed.flags);
50
+ return;
51
+ case "credits":
52
+ await commandCredits(client, cwd, parsed.flags);
53
+ return;
54
+ case "workspaces":
55
+ await commandWorkspaces(client, cwd, parsed.flags);
56
+ return;
57
+ case "workspace":
58
+ if (parsed.subcommand === "use") {
59
+ const workspaceRef = parsed.args.join(" ");
60
+ await commandWorkspaceUse(client, cwd, parsed.flags, workspaceRef);
61
+ return;
62
+ }
63
+ await commandWorkspace(cwd, parsed.flags);
64
+ return;
65
+ case "findings":
66
+ await commandFindings(client, cwd, parsed.flags);
67
+ return;
68
+ case "export":
69
+ if (parsed.subcommand && parsed.subcommand !== "findings") {
70
+ throw new Error("Usage: apex export [findings]");
71
+ }
72
+ await commandExportFindings(client, cwd, parsed.flags);
73
+ return;
74
+ case "status":
75
+ await commandStatus(client, cwd, parsed.flags);
76
+ return;
77
+ case "connect":
78
+ if (parsed.subcommand !== "github" && parsed.subcommand !== "gitlab") {
79
+ throw new Error("Usage: apex connect <github|gitlab>");
80
+ }
81
+ await commandConnect(client, cwd, parsed.flags, parsed.subcommand);
82
+ return;
83
+ default:
84
+ throw new Error(`Unknown command: ${parsed.command}`);
85
+ }
86
+ }
87
+ main().catch((error) => {
88
+ if (error instanceof Error && error.reported) {
89
+ process.exitCode = 1;
90
+ return;
91
+ }
92
+ process.stderr.write(`${formatApiError(error)}\n`);
93
+ process.exitCode = 1;
94
+ });
@@ -0,0 +1,125 @@
1
+ import { clearCredentials, loadConfig, loadCredentials, saveCredentials } from "./config.js";
2
+ export class ApiError extends Error {
3
+ status;
4
+ body;
5
+ constructor(message, status, body) {
6
+ super(message);
7
+ this.name = "ApiError";
8
+ this.status = status;
9
+ this.body = body;
10
+ }
11
+ }
12
+ function getApiErrorBodyMessage(body) {
13
+ if (!body || typeof body !== "object") {
14
+ return null;
15
+ }
16
+ const error = body.error;
17
+ return typeof error === "string" && error.trim().length > 0 ? error : null;
18
+ }
19
+ export function formatApiError(error) {
20
+ if (error instanceof ApiError) {
21
+ return getApiErrorBodyMessage(error.body) ?? error.message;
22
+ }
23
+ return error instanceof Error ? error.message : String(error);
24
+ }
25
+ function expiresSoon(timestamp) {
26
+ if (!timestamp)
27
+ return true;
28
+ const time = new Date(timestamp).getTime();
29
+ if (!Number.isFinite(time))
30
+ return true;
31
+ return time <= Date.now() + 30_000;
32
+ }
33
+ async function parseResponse(response) {
34
+ const contentType = response.headers.get("content-type") ?? "";
35
+ if (contentType.includes("application/json")) {
36
+ return response.json().catch(() => null);
37
+ }
38
+ return response.text().catch(() => "");
39
+ }
40
+ export class ApexApiClient {
41
+ async getBaseUrl() {
42
+ const config = await loadConfig();
43
+ return config.baseUrl;
44
+ }
45
+ async getCredentials() {
46
+ return loadCredentials();
47
+ }
48
+ async clearCredentials() {
49
+ await clearCredentials();
50
+ }
51
+ async refreshIfNeeded() {
52
+ const credentials = await loadCredentials();
53
+ if (!credentials)
54
+ return null;
55
+ if (!expiresSoon(credentials.accessTokenExpiresAt)) {
56
+ return credentials;
57
+ }
58
+ return this.refreshSession(credentials.refreshToken);
59
+ }
60
+ async refreshSession(refreshToken) {
61
+ const existing = await loadCredentials();
62
+ const effectiveRefreshToken = refreshToken ?? existing?.refreshToken ?? null;
63
+ if (!effectiveRefreshToken)
64
+ return null;
65
+ const payload = (await this.request("/api/cli/v1/auth/refresh", {
66
+ method: "POST",
67
+ auth: false,
68
+ json: { refreshToken: effectiveRefreshToken },
69
+ retryOnUnauthorized: false,
70
+ }));
71
+ const nextCredentials = {
72
+ accessToken: payload.accessToken,
73
+ refreshToken: payload.refreshToken,
74
+ accessTokenExpiresAt: payload.accessTokenExpiresAt,
75
+ refreshTokenExpiresAt: payload.refreshTokenExpiresAt,
76
+ };
77
+ await saveCredentials(nextCredentials);
78
+ return nextCredentials;
79
+ }
80
+ async request(path, options = {}) {
81
+ const baseUrl = await this.getBaseUrl();
82
+ const headers = new Headers(options.headers ?? {});
83
+ let credentials = options.auth === false ? null : await this.refreshIfNeeded().catch(() => null);
84
+ if (options.json !== undefined) {
85
+ headers.set("Content-Type", "application/json");
86
+ }
87
+ if (credentials?.accessToken) {
88
+ headers.set("Authorization", `Bearer ${credentials.accessToken}`);
89
+ }
90
+ const response = await fetch(new URL(path, baseUrl), {
91
+ ...options,
92
+ headers,
93
+ body: options.json !== undefined ? JSON.stringify(options.json) : options.body,
94
+ });
95
+ if (response.status === 401 &&
96
+ options.auth !== false &&
97
+ options.retryOnUnauthorized !== false) {
98
+ credentials = await this.refreshSession();
99
+ if (credentials?.accessToken) {
100
+ headers.set("Authorization", `Bearer ${credentials.accessToken}`);
101
+ const retryResponse = await fetch(new URL(path, baseUrl), {
102
+ ...options,
103
+ headers,
104
+ body: options.json !== undefined ? JSON.stringify(options.json) : options.body,
105
+ });
106
+ const retryBody = await parseResponse(retryResponse);
107
+ if (!retryResponse.ok) {
108
+ if (retryResponse.status === 401) {
109
+ await clearCredentials();
110
+ }
111
+ throw new ApiError(`Request failed with ${retryResponse.status}`, retryResponse.status, retryBody);
112
+ }
113
+ return retryBody;
114
+ }
115
+ }
116
+ const body = await parseResponse(response);
117
+ if (!response.ok) {
118
+ if (response.status === 401) {
119
+ await clearCredentials();
120
+ }
121
+ throw new ApiError(`Request failed with ${response.status}`, response.status, body);
122
+ }
123
+ return body;
124
+ }
125
+ }
package/dist/args.js ADDED
@@ -0,0 +1,56 @@
1
+ const BOOLEAN_FLAGS = new Set(["force", "non-interactive", "json", "no-open", "help"]);
2
+ export function parseArgs(argv) {
3
+ const flags = {};
4
+ const positional = [];
5
+ for (let index = 0; index < argv.length; index += 1) {
6
+ const value = argv[index];
7
+ if (!value.startsWith("--")) {
8
+ positional.push(value);
9
+ continue;
10
+ }
11
+ const key = value.slice(2);
12
+ if (BOOLEAN_FLAGS.has(key)) {
13
+ flags[key] = true;
14
+ continue;
15
+ }
16
+ const next = argv[index + 1];
17
+ if (!next || next.startsWith("--")) {
18
+ throw new Error(`Missing value for --${key}`);
19
+ }
20
+ if (key === "repo") {
21
+ const existing = flags[key] ?? [];
22
+ existing.push(next);
23
+ flags[key] = existing;
24
+ }
25
+ else {
26
+ flags[key] = next;
27
+ }
28
+ index += 1;
29
+ }
30
+ return {
31
+ command: positional[0] ?? null,
32
+ subcommand: positional[1] ?? null,
33
+ args: positional.slice(2),
34
+ flags,
35
+ };
36
+ }
37
+ export function isJsonMode(flags) {
38
+ return flags.json === true;
39
+ }
40
+ export function isNonInteractive(flags) {
41
+ return flags["non-interactive"] === true;
42
+ }
43
+ export function getFlagString(flags, key) {
44
+ const value = flags[key];
45
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
46
+ }
47
+ export function getFlagList(flags, key) {
48
+ const value = flags[key];
49
+ return Array.isArray(value) ? value : [];
50
+ }
51
+ export function withFlag(flags, key, value) {
52
+ return {
53
+ ...flags,
54
+ [key]: value,
55
+ };
56
+ }