@callstack/apex 0.2.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.
package/README.md ADDED
@@ -0,0 +1,71 @@
1
+ # Apex CLI
2
+
3
+ Configures the AI coding assistants installed on your machine to use
4
+ [Callstack Apex](https://api.callstack.ai) — an OpenAI-compatible endpoint
5
+ serving the `callstack/Apex` model.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ curl -fsSL https://apex.callstack.ai/install.sh | sh
11
+ ```
12
+
13
+ This checks for Node.js 18+ and then runs `npx @callstack/apex init` for you.
14
+
15
+ If you already have Node.js, you can skip the script entirely:
16
+
17
+ ```sh
18
+ npx @callstack/apex init
19
+ ```
20
+
21
+ ## What `init` does
22
+
23
+ It detects which of these are installed and writes (or updates) their config
24
+ to point at Apex, backing up any existing file first (`<file>.bak-<timestamp>`):
25
+
26
+ | Assistant | Config written |
27
+ | --- | --- |
28
+ | Claude Code | `~/.claude/settings.json` |
29
+ | Codex | `~/.codex/config.toml`, `~/.codex/callstack_ai.config.toml` |
30
+ | OpenCode | `~/.config/opencode/opencode.json` (v1 or v2 format, auto-detected), `~/.local/share/opencode/auth.json` (if absent, v1 only) |
31
+ | pi | `~/.pi/agent/models.json` |
32
+ | Cursor | manual steps (GUI-only, printed to the terminal) |
33
+ | VS Code + GitHub Copilot | manual steps (GUI-only, printed to the terminal) |
34
+ | Vercel AI SDK / Eve | `.env` in the current project, if it depends on `ai`/`eve` |
35
+
36
+ You'll be prompted for your Apex API key (input hidden), or pass it directly:
37
+
38
+ ```sh
39
+ npx @callstack/apex init --api-key sk-XXX
40
+ # or
41
+ APEX_API_KEY=sk-XXX npx @callstack/apex init --yes
42
+ ```
43
+
44
+ Other flags: `--dry-run` (show changes without writing), `--only <ids>` /
45
+ `--skip <ids>` (comma-separated, see `apex list` for ids).
46
+
47
+ ## Commands
48
+
49
+ ```sh
50
+ apex init # detect + configure
51
+ apex list # show which assistants are detected, without changing anything
52
+ ```
53
+
54
+ ## Security
55
+
56
+ Your Apex API key is written locally to config files under your home
57
+ directory (and, for JS projects, to a local `.env`). Never commit these
58
+ files or share the key. Rotate it if you suspect it leaked.
59
+
60
+ ## Troubleshooting
61
+
62
+ See the internal "Apex - how to use it?" Notion page, or ask in `#apex` on
63
+ Slack. Known issue: some `pi` users have hit Cloudflare 403s — this has come
64
+ up more than once and is being tracked there.
65
+
66
+ ## Local development
67
+
68
+ ```sh
69
+ npm link
70
+ apex --help
71
+ ```
package/bin/apex.js ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ require('../src/cli.js').main(process.argv.slice(2));
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@callstack/apex",
3
+ "version": "0.2.0",
4
+ "description": "Detects the AI coding assistants installed on your machine and configures them to use Callstack Apex.",
5
+ "license": "MIT",
6
+ "bin": {
7
+ "apex": "bin/apex.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "keywords": [
18
+ "callstack",
19
+ "apex",
20
+ "ai"
21
+ ]
22
+ }
@@ -0,0 +1,54 @@
1
+ 'use strict';
2
+
3
+ const path = require('node:path');
4
+ const fs = require('node:fs');
5
+ const { readJsonIfExists, pathExists, backupFile } = require('../util/fsHelpers');
6
+
7
+ const id = 'ai-sdk';
8
+ const name = 'Vercel AI SDK / Eve (programmatic)';
9
+
10
+ function projectDeps(cwd) {
11
+ const pkg = readJsonIfExists(path.join(cwd, 'package.json'));
12
+ if (!pkg) return null;
13
+ return { ...pkg.dependencies, ...pkg.devDependencies };
14
+ }
15
+
16
+ function detect({ cwd }) {
17
+ const deps = projectDeps(cwd);
18
+ if (!deps) return false;
19
+ return Boolean(deps['ai'] || deps['@ai-sdk/openai'] || deps['eve']);
20
+ }
21
+
22
+ function apply({ cwd, apiKey, dryRun, log }) {
23
+ const deps = projectDeps(cwd) || {};
24
+ const envPath = path.join(cwd, '.env');
25
+ const existingEnv = pathExists(envPath) ? fs.readFileSync(envPath, 'utf8') : '';
26
+
27
+ if (apiKey && !/^APEX_API_KEY=/m.test(existingEnv)) {
28
+ const separator = existingEnv && !existingEnv.endsWith('\n') ? '\n' : '';
29
+ const nextEnv = `${existingEnv}${separator}APEX_API_KEY=${apiKey}\n`;
30
+ if (!dryRun) {
31
+ backupFile(envPath);
32
+ fs.writeFileSync(envPath, nextEnv);
33
+ }
34
+ log(`wrote APEX_API_KEY to ${envPath} (make sure .env is gitignored)`);
35
+ } else if (!apiKey) {
36
+ log(`no API key provided — add APEX_API_KEY manually to ${envPath}`);
37
+ } else {
38
+ log(`${envPath} already has APEX_API_KEY, left untouched`);
39
+ }
40
+
41
+ log('connector snippet (Vercel AI SDK):');
42
+ log(" import { createOpenAI } from '@ai-sdk/openai';");
43
+ log(' const apex = createOpenAI({ apiKey: process.env.APEX_API_KEY, baseURL: "https://api.callstack.ai/v1", name: "callstack" });');
44
+ log(' // use apex(\'callstack/Apex\') with generateText / streamText');
45
+ log(' // reasoning effort (none/low/medium/xhigh): pass providerOptions: { callstack: { reasoningEffort: "medium" } }');
46
+
47
+ if (deps['eve']) {
48
+ log('Eve note: set modelContextWindowTokens: 262_144 on defineAgent, or compaction will fail to compile.');
49
+ }
50
+
51
+ return { configured: Boolean(apiKey) };
52
+ }
53
+
54
+ module.exports = { id, name, detect, apply };
@@ -0,0 +1,37 @@
1
+ 'use strict';
2
+
3
+ const path = require('node:path');
4
+ const { commandExists, dirExists, homeDir } = require('../util/detect');
5
+ const { readJsonIfExists, backupFile, writeJson } = require('../util/fsHelpers');
6
+
7
+ const id = 'claude-code';
8
+ const name = 'Claude Code';
9
+
10
+ function detect() {
11
+ return commandExists('claude') || dirExists(homeDir(), '.claude');
12
+ }
13
+
14
+ function apply({ apiKey, dryRun, log }) {
15
+ const settingsPath = path.join(homeDir(), '.claude', 'settings.json');
16
+ const settings = readJsonIfExists(settingsPath) || {};
17
+ settings.env = settings.env || {};
18
+ settings.env.CLAUDE_CODE_ATTRIBUTION_HEADER = '0';
19
+ settings.env.ANTHROPIC_BASE_URL = 'https://api.callstack.ai';
20
+ settings.env.ANTHROPIC_MODEL = 'callstack/Apex';
21
+ if (apiKey) {
22
+ settings.env.ANTHROPIC_AUTH_TOKEN = apiKey;
23
+ }
24
+
25
+ if (!dryRun) {
26
+ backupFile(settingsPath);
27
+ writeJson(settingsPath, settings);
28
+ }
29
+ log(`wrote ${settingsPath}`);
30
+ if (!apiKey) {
31
+ log('no API key provided — set ANTHROPIC_AUTH_TOKEN in that file manually');
32
+ }
33
+
34
+ return { configured: true };
35
+ }
36
+
37
+ module.exports = { id, name, detect, apply };
@@ -0,0 +1,56 @@
1
+ 'use strict';
2
+
3
+ const path = require('node:path');
4
+ const { commandExists, dirExists, homeDir } = require('../util/detect');
5
+ const { readTextIfExists, backupFile, writeFileEnsuringDir } = require('../util/fsHelpers');
6
+ const { upsertTomlSection } = require('../util/toml');
7
+
8
+ const id = 'codex';
9
+ const name = 'Codex';
10
+
11
+ function detect() {
12
+ return commandExists('codex') || dirExists(homeDir(), '.codex');
13
+ }
14
+
15
+ function apply({ dryRun, log }) {
16
+ const home = homeDir();
17
+ const configPath = path.join(home, '.codex', 'config.toml');
18
+ const profilePath = path.join(home, '.codex', 'callstack_ai.config.toml');
19
+
20
+ const providerBody = [
21
+ 'name = "callstack.ai"',
22
+ 'base_url = "https://api.callstack.ai/v1"',
23
+ 'env_key = "CALLSTACK_AUTH_TOKEN"',
24
+ 'wire_api = "responses"',
25
+ 'requires_openai_auth = false',
26
+ '',
27
+ ].join('\n');
28
+
29
+ const existingConfig = readTextIfExists(configPath);
30
+ const nextConfig = upsertTomlSection(existingConfig, 'model_providers.callstack_ai', providerBody);
31
+ if (!dryRun) {
32
+ backupFile(configPath);
33
+ writeFileEnsuringDir(configPath, nextConfig);
34
+ }
35
+ log(`wrote ${configPath}`);
36
+
37
+ const profileContents = [
38
+ 'model_provider = "callstack_ai"',
39
+ 'model = "callstack/Apex"',
40
+ 'model_context_window = 262144',
41
+ 'model_max_output_tokens = 16384',
42
+ 'model_reasoning_effort = "medium"',
43
+ '',
44
+ ].join('\n');
45
+ if (!dryRun) {
46
+ backupFile(profilePath);
47
+ writeFileEnsuringDir(profilePath, profileContents);
48
+ }
49
+ log(`wrote ${profilePath}`);
50
+
51
+ log('run with: export CALLSTACK_AUTH_TOKEN="<your Apex API key>" && codex -p callstack_ai');
52
+
53
+ return { configured: true };
54
+ }
55
+
56
+ module.exports = { id, name, detect, apply };
@@ -0,0 +1,30 @@
1
+ 'use strict';
2
+
3
+ const { commandExists, dirExists, anyAppPathExists, homeDir } = require('../util/detect');
4
+
5
+ const id = 'cursor';
6
+ const name = 'Cursor';
7
+
8
+ function detect() {
9
+ return (
10
+ commandExists('cursor') ||
11
+ dirExists(homeDir(), '.cursor') ||
12
+ anyAppPathExists(['/Applications/Cursor.app'])
13
+ );
14
+ }
15
+
16
+ // Cursor's model configuration lives behind its GUI and isn't a documented file
17
+ // format, so this can't be written automatically — print the manual steps instead.
18
+ function apply({ log }) {
19
+ log('Cursor requires manual setup (no config file to write automatically):');
20
+ log(' 1. Cursor Settings -> Models');
21
+ log(' 2. Enable API Keys -> OpenAI API Key, confirm the prompt');
22
+ log(' 3. Paste your Apex API key, and set "Override OpenAI Base URL" to https://api.callstack.ai/v1');
23
+ log(' 4. In Models, "Add Custom Model" -> enter callstack/Apex -> enable it');
24
+ log(' 5. Cmd+L -> pick "Apex" from the model dropdown');
25
+ log('Note: Cursor has no way to declare model capabilities (tool calling, vision, reasoning) for a custom model — it infers them, so the steps above are all there is to configure.');
26
+
27
+ return { configured: false, manual: true };
28
+ }
29
+
30
+ module.exports = { id, name, detect, apply };
@@ -0,0 +1,11 @@
1
+ 'use strict';
2
+
3
+ module.exports = [
4
+ require('./claudeCode'),
5
+ require('./codex'),
6
+ require('./opencode'),
7
+ require('./pi'),
8
+ require('./cursor'),
9
+ require('./vscodeCopilot'),
10
+ require('./aiSdk'),
11
+ ];
@@ -0,0 +1,103 @@
1
+ 'use strict';
2
+
3
+ const path = require('node:path');
4
+ const { commandExists, commandVersion, dirExists, homeDir } = require('../util/detect');
5
+ const { readJsonIfExists, backupFile, writeJson, pathExists } = require('../util/fsHelpers');
6
+
7
+ const id = 'opencode';
8
+ const name = 'OpenCode';
9
+
10
+ function detect() {
11
+ return commandExists('opencode') || dirExists(homeDir(), '.config', 'opencode');
12
+ }
13
+
14
+ // OpenCode 2 renamed the config's top-level `provider` map to `providers` and
15
+ // changed the per-model capability field names (tool_call -> capabilities.tools,
16
+ // etc). It's still invoked as `opencode`, so version is the only way to tell
17
+ // v1 and v2 apart. If detection fails, default to the v1 shape: OpenCode 2
18
+ // still reads and normalizes v1-style config, but v1 installs can't read v2-only fields.
19
+ function isV2() {
20
+ const version = commandVersion('opencode', ['--version']);
21
+ return Boolean(version && version.major >= 2);
22
+ }
23
+
24
+ function modelConfigV1() {
25
+ return {
26
+ name: 'Apex',
27
+ reasoning: true,
28
+ tool_call: true,
29
+ attachment: true,
30
+ modalities: { input: ['text', 'image'], output: ['text'] },
31
+ limit: { context: 262144, input: 240000, output: 16384 },
32
+ options: { reasoningEffort: 'medium' },
33
+ variants: {
34
+ none: { reasoningEffort: 'none' },
35
+ low: { reasoningEffort: 'low' },
36
+ medium: { reasoningEffort: 'medium' },
37
+ xhigh: { reasoningEffort: 'xhigh' },
38
+ },
39
+ };
40
+ }
41
+
42
+ function modelConfigV2() {
43
+ return {
44
+ name: 'Apex',
45
+ capabilities: { tools: true, input: ['text', 'image'], output: ['text'] },
46
+ limit: { context: 262144, input: 240000, output: 16384 },
47
+ settings: { reasoningEffort: 'medium' },
48
+ variants: [
49
+ { id: 'none', settings: { reasoningEffort: 'none' } },
50
+ { id: 'low', settings: { reasoningEffort: 'low' } },
51
+ { id: 'medium', settings: { reasoningEffort: 'medium' } },
52
+ { id: 'xhigh', settings: { reasoningEffort: 'xhigh' } },
53
+ ],
54
+ };
55
+ }
56
+
57
+ function apply({ apiKey, dryRun, log }) {
58
+ const home = homeDir();
59
+ const configPath = path.join(home, '.config', 'opencode', 'opencode.json');
60
+ const authPath = path.join(home, '.local', 'share', 'opencode', 'auth.json');
61
+ const v2 = isV2();
62
+
63
+ const config = readJsonIfExists(configPath) || { $schema: 'https://opencode.ai/config.json' };
64
+
65
+ if (v2) {
66
+ config.providers = config.providers || {};
67
+ config.providers['callstack.ai'] = {
68
+ name: 'callstack.ai',
69
+ package: '@opencode/ai/providers/openai-compatible',
70
+ settings: { baseURL: 'https://api.callstack.ai/v1' },
71
+ models: { 'callstack/Apex': modelConfigV2() },
72
+ };
73
+ } else {
74
+ config.provider = config.provider || {};
75
+ config.provider['callstack.ai'] = {
76
+ npm: '@ai-sdk/openai-compatible',
77
+ name: 'callstack.ai',
78
+ options: { baseURL: 'https://api.callstack.ai/v1' },
79
+ models: { 'callstack/Apex': modelConfigV1() },
80
+ };
81
+ }
82
+
83
+ if (!dryRun) {
84
+ backupFile(configPath);
85
+ writeJson(configPath, config);
86
+ }
87
+ log(`wrote ${configPath} (OpenCode ${v2 ? 'v2' : 'v1'} config format)`);
88
+
89
+ if (v2) {
90
+ log('run `/connect`, select `callstack.ai` -> "Manually enter API Key", then paste your Apex API key');
91
+ } else if (pathExists(authPath)) {
92
+ log(`skipped ${authPath} (already exists — leaving your existing credentials untouched)`);
93
+ } else if (apiKey) {
94
+ if (!dryRun) writeJson(authPath, { 'callstack.ai': { type: 'api', key: apiKey } });
95
+ log(`wrote ${authPath}`);
96
+ } else {
97
+ log(`skipped ${authPath} (no API key provided — create it manually, see docs)`);
98
+ }
99
+
100
+ return { configured: true };
101
+ }
102
+
103
+ module.exports = { id, name, detect, apply };
@@ -0,0 +1,55 @@
1
+ 'use strict';
2
+
3
+ const path = require('node:path');
4
+ const { dirExists, homeDir } = require('../util/detect');
5
+ const { readJsonIfExists, backupFile, writeJson } = require('../util/fsHelpers');
6
+
7
+ const id = 'pi';
8
+ const name = 'pi';
9
+
10
+ function detect() {
11
+ // ".pi" is checked instead of the "pi" binary, which collides with unrelated tools on PATH.
12
+ return dirExists(homeDir(), '.pi');
13
+ }
14
+
15
+ function apply({ apiKey, dryRun, log }) {
16
+ const configPath = path.join(homeDir(), '.pi', 'agent', 'models.json');
17
+ const config = readJsonIfExists(configPath) || { providers: {} };
18
+ config.providers = config.providers || {};
19
+ config.providers.callstack = {
20
+ baseUrl: 'https://api.callstack.ai/v1',
21
+ api: 'openai-completions',
22
+ apiKey: apiKey || 'XXX',
23
+ models: [
24
+ {
25
+ id: 'callstack/Apex',
26
+ reasoning: true,
27
+ input: ['text', 'image'],
28
+ thinkingLevelMap: {
29
+ off: 'none',
30
+ minimal: null,
31
+ low: 'low',
32
+ medium: 'medium',
33
+ high: null,
34
+ xhigh: 'xhigh',
35
+ max: null,
36
+ },
37
+ contextWindow: 262144,
38
+ },
39
+ ],
40
+ };
41
+
42
+ if (!dryRun) {
43
+ backupFile(configPath);
44
+ writeJson(configPath, config);
45
+ }
46
+ log(`wrote ${configPath}`);
47
+ if (!apiKey) {
48
+ log('no API key provided — replace the placeholder "XXX" in that file manually');
49
+ }
50
+ log('known issue: some users have hit Cloudflare 403s with pi — ask in #apex if this happens to you');
51
+
52
+ return { configured: true };
53
+ }
54
+
55
+ module.exports = { id, name, detect, apply };
@@ -0,0 +1,34 @@
1
+ 'use strict';
2
+
3
+ const { commandExists, dirExists, anyAppPathExists, homeDir } = require('../util/detect');
4
+
5
+ const id = 'vscode-copilot';
6
+ const name = 'VS Code (GitHub Copilot)';
7
+
8
+ function detect() {
9
+ return (
10
+ commandExists('code') ||
11
+ dirExists(homeDir(), '.vscode') ||
12
+ anyAppPathExists(['/Applications/Visual Studio Code.app'])
13
+ );
14
+ }
15
+
16
+ // Adding a custom Copilot model endpoint goes through VS Code's GUI and an
17
+ // interactive secret input, so this can't be written automatically.
18
+ function apply({ log }) {
19
+ log('VS Code + GitHub Copilot requires manual setup (make sure VS Code is up to date):');
20
+ log(' 1. Open the GitHub Copilot panel -> model selector -> "Manage Models..."');
21
+ log(' 2. "Add Models" -> "Custom Endpoint"');
22
+ log(' 3. Name it "callstack.ai", paste your Apex API key, choose "Chat Completions"');
23
+ log(' 4. In the JSON editor that opens, set the "models" array to:');
24
+ log(' [{');
25
+ log(' "id": "callstack/Apex", "name": "Apex", "url": "https://api.callstack.ai/v1",');
26
+ log(' "toolCalling": true, "vision": true, "thinking": true,');
27
+ log(' "contextWindow": 262144, "maxOutputTokens": 16384,');
28
+ log(' "supportsReasoningEffort": ["none", "low", "medium", "xhigh"], "reasoningEffortFormat": "chat-completions"');
29
+ log(' }]');
30
+
31
+ return { configured: false, manual: true };
32
+ }
33
+
34
+ module.exports = { id, name, detect, apply };
package/src/cli.js ADDED
@@ -0,0 +1,89 @@
1
+ 'use strict';
2
+
3
+ const { runInit, runList } = require('./init');
4
+
5
+ const VERSION = require('../package.json').version;
6
+
7
+ const HELP = `apex — configure your AI coding assistants to use Callstack Apex
8
+
9
+ Usage:
10
+ apex init [options] Detect installed assistants and configure them
11
+ apex list Show which supported assistants are detected
12
+ apex --help Show this help
13
+ apex --version Show the CLI version
14
+
15
+ Options for "init":
16
+ --api-key <key> Apex API key (else reads APEX_API_KEY / CALLSTACK_AUTH_TOKEN, else prompts)
17
+ --yes, -y Don't prompt; skip anything that needs interactive input
18
+ --dry-run Show what would change without writing any files
19
+ --only <ids> Comma-separated assistant ids to limit to (see "apex list")
20
+ --skip <ids> Comma-separated assistant ids to skip
21
+ `;
22
+
23
+ function parseArgs(argv) {
24
+ const opts = { _: [] };
25
+ for (let i = 0; i < argv.length; i += 1) {
26
+ const arg = argv[i];
27
+ switch (arg) {
28
+ case '--api-key':
29
+ opts.apiKey = argv[++i];
30
+ break;
31
+ case '--only':
32
+ opts.only = (argv[++i] || '').split(',').filter(Boolean);
33
+ break;
34
+ case '--skip':
35
+ opts.skip = (argv[++i] || '').split(',').filter(Boolean);
36
+ break;
37
+ case '--yes':
38
+ case '-y':
39
+ opts.yes = true;
40
+ break;
41
+ case '--dry-run':
42
+ opts.dryRun = true;
43
+ break;
44
+ case '--no-key':
45
+ opts.noKey = true;
46
+ break;
47
+ case '--help':
48
+ case '-h':
49
+ opts.help = true;
50
+ break;
51
+ case '--version':
52
+ case '-v':
53
+ opts.version = true;
54
+ break;
55
+ default:
56
+ opts._.push(arg);
57
+ }
58
+ }
59
+ return opts;
60
+ }
61
+
62
+ async function main(argv) {
63
+ const opts = parseArgs(argv);
64
+
65
+ if (opts.version) {
66
+ console.log(VERSION);
67
+ return;
68
+ }
69
+ if (opts.help || opts._.length === 0) {
70
+ console.log(HELP);
71
+ return;
72
+ }
73
+
74
+ const command = opts._[0];
75
+ switch (command) {
76
+ case 'init':
77
+ await runInit(opts);
78
+ break;
79
+ case 'list':
80
+ await runList(opts);
81
+ break;
82
+ default:
83
+ console.error(`Unknown command: ${command}\n`);
84
+ console.log(HELP);
85
+ process.exitCode = 1;
86
+ }
87
+ }
88
+
89
+ module.exports = { main, parseArgs };
package/src/init.js ADDED
@@ -0,0 +1,91 @@
1
+ 'use strict';
2
+
3
+ const assistants = require('./assistants');
4
+ const { askHidden } = require('./util/prompt');
5
+
6
+ async function resolveApiKey(opts) {
7
+ if (opts.apiKey) return opts.apiKey;
8
+ if (process.env.APEX_API_KEY) return process.env.APEX_API_KEY;
9
+ if (process.env.CALLSTACK_AUTH_TOKEN) return process.env.CALLSTACK_AUTH_TOKEN;
10
+ if (opts.noKey || opts.yes) return null;
11
+ return askHidden('Apex API key (leave blank to skip, add it manually later): ');
12
+ }
13
+
14
+ function selectAssistants(opts) {
15
+ let list = assistants;
16
+ if (opts.only && opts.only.length) {
17
+ list = list.filter((a) => opts.only.includes(a.id));
18
+ }
19
+ if (opts.skip && opts.skip.length) {
20
+ list = list.filter((a) => !opts.skip.includes(a.id));
21
+ }
22
+ return list;
23
+ }
24
+
25
+ async function runInit(opts) {
26
+ const cwd = process.cwd();
27
+ const apiKey = await resolveApiKey(opts);
28
+ const ctx = { cwd, apiKey, dryRun: Boolean(opts.dryRun) };
29
+
30
+ const candidates = selectAssistants(opts);
31
+ const detected = candidates.filter((a) => {
32
+ try {
33
+ return a.detect(ctx);
34
+ } catch {
35
+ return false;
36
+ }
37
+ });
38
+
39
+ if (!detected.length) {
40
+ console.log('No supported AI assistants were detected on this machine.');
41
+ console.log(`Supported: ${assistants.map((a) => a.name).join(', ')}`);
42
+ return;
43
+ }
44
+
45
+ console.log(`Detected: ${detected.map((a) => a.name).join(', ')}`);
46
+ if (opts.dryRun) console.log('(dry run — no files will be written)\n');
47
+
48
+ const results = [];
49
+ for (const assistant of detected) {
50
+ console.log(`\n${assistant.name}`);
51
+ const log = (line) => console.log(` ${line}`);
52
+ try {
53
+ const result = assistant.apply({ ...ctx, log });
54
+ results.push({ assistant, ...result });
55
+ } catch (err) {
56
+ log(`error: ${err.message}`);
57
+ results.push({ assistant, configured: false, error: err.message });
58
+ }
59
+ }
60
+
61
+ const configured = results.filter((r) => r.configured);
62
+ const manual = results.filter((r) => r.manual);
63
+ console.log('\nSummary:');
64
+ if (configured.length) {
65
+ console.log(` Configured automatically: ${configured.map((r) => r.assistant.name).join(', ')}`);
66
+ }
67
+ if (manual.length) {
68
+ console.log(` Needs manual steps: ${manual.map((r) => r.assistant.name).join(', ')}`);
69
+ }
70
+ if (!apiKey) {
71
+ console.log(' No API key was provided — some config files were written with a placeholder.');
72
+ console.log(' Never commit real API keys; keep them out of version control.');
73
+ }
74
+ console.log('\nDocs: see the internal "Apex - how to use it?" Notion page, or ask in #apex on Slack.');
75
+ }
76
+
77
+ async function runList(opts) {
78
+ const cwd = process.cwd();
79
+ const ctx = { cwd };
80
+ for (const assistant of assistants) {
81
+ let isDetected = false;
82
+ try {
83
+ isDetected = assistant.detect(ctx);
84
+ } catch {
85
+ isDetected = false;
86
+ }
87
+ console.log(`${isDetected ? '[x]' : '[ ]'} ${assistant.name}`);
88
+ }
89
+ }
90
+
91
+ module.exports = { runInit, runList };
@@ -0,0 +1,49 @@
1
+ 'use strict';
2
+
3
+ const os = require('node:os');
4
+ const path = require('node:path');
5
+ const { execFileSync } = require('node:child_process');
6
+ const { pathExists } = require('./fsHelpers');
7
+
8
+ function commandExists(cmd) {
9
+ const finder = process.platform === 'win32' ? 'where' : 'which';
10
+ try {
11
+ execFileSync(finder, [cmd], { stdio: 'ignore' });
12
+ return true;
13
+ } catch {
14
+ return false;
15
+ }
16
+ }
17
+
18
+ // Returns { major, minor, patch } parsed from `<cmd> <args>` output, or null
19
+ // if the command fails or its output doesn't contain a semver-like version.
20
+ function commandVersion(cmd, args = ['--version']) {
21
+ try {
22
+ const out = execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
23
+ const match = out.match(/(\d+)\.(\d+)\.(\d+)/);
24
+ if (!match) return null;
25
+ return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) };
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ function dirExists(...segments) {
32
+ return pathExists(path.join(...segments));
33
+ }
34
+
35
+ function anyAppPathExists(paths) {
36
+ return paths.some((p) => pathExists(p));
37
+ }
38
+
39
+ function homeDir() {
40
+ return os.homedir();
41
+ }
42
+
43
+ module.exports = {
44
+ commandExists,
45
+ commandVersion,
46
+ dirExists,
47
+ anyAppPathExists,
48
+ homeDir,
49
+ };
@@ -0,0 +1,57 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ const backedUpThisRun = new Set();
7
+
8
+ function pathExists(p) {
9
+ try {
10
+ fs.accessSync(p);
11
+ return true;
12
+ } catch {
13
+ return false;
14
+ }
15
+ }
16
+
17
+ function readJsonIfExists(filePath) {
18
+ if (!pathExists(filePath)) return null;
19
+ const raw = fs.readFileSync(filePath, 'utf8');
20
+ if (!raw.trim()) return {};
21
+ try {
22
+ return JSON.parse(raw);
23
+ } catch (err) {
24
+ throw new Error(`${filePath} exists but is not valid JSON: ${err.message}`);
25
+ }
26
+ }
27
+
28
+ function readTextIfExists(filePath) {
29
+ return pathExists(filePath) ? fs.readFileSync(filePath, 'utf8') : null;
30
+ }
31
+
32
+ function backupFile(filePath) {
33
+ if (!pathExists(filePath) || backedUpThisRun.has(filePath)) return null;
34
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
35
+ const backupPath = `${filePath}.bak-${stamp}`;
36
+ fs.copyFileSync(filePath, backupPath);
37
+ backedUpThisRun.add(filePath);
38
+ return backupPath;
39
+ }
40
+
41
+ function writeFileEnsuringDir(filePath, contents) {
42
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
43
+ fs.writeFileSync(filePath, contents);
44
+ }
45
+
46
+ function writeJson(filePath, data) {
47
+ writeFileEnsuringDir(filePath, `${JSON.stringify(data, null, 2)}\n`);
48
+ }
49
+
50
+ module.exports = {
51
+ pathExists,
52
+ readJsonIfExists,
53
+ readTextIfExists,
54
+ backupFile,
55
+ writeFileEnsuringDir,
56
+ writeJson,
57
+ };
@@ -0,0 +1,74 @@
1
+ 'use strict';
2
+
3
+ const readline = require('node:readline');
4
+
5
+ function ask(question) {
6
+ return new Promise((resolve) => {
7
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
8
+ rl.question(question, (answer) => {
9
+ rl.close();
10
+ resolve(answer.trim());
11
+ });
12
+ });
13
+ }
14
+
15
+ async function confirm(question, defaultYes = true) {
16
+ if (!process.stdin.isTTY) return defaultYes;
17
+ const hint = defaultYes ? 'Y/n' : 'y/N';
18
+ const answer = (await ask(`${question} (${hint}) `)).toLowerCase();
19
+ if (!answer) return defaultYes;
20
+ return answer === 'y' || answer === 'yes';
21
+ }
22
+
23
+ // Char codes, spelled out numerically to avoid embedding raw control bytes in source.
24
+ const CODE_LF = 10;
25
+ const CODE_CR = 13;
26
+ const CODE_CTRL_C = 3;
27
+ const CODE_CTRL_D = 4;
28
+ const CODE_BACKSPACE = 8;
29
+ const CODE_DEL = 127;
30
+
31
+ function askHidden(question) {
32
+ if (!process.stdin.isTTY) return ask(question);
33
+ return new Promise((resolve) => {
34
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
35
+ const stdin = process.stdin;
36
+ process.stdout.write(question);
37
+
38
+ let value = '';
39
+ stdin.setRawMode(true);
40
+ stdin.resume();
41
+ stdin.setEncoding('utf8');
42
+
43
+ const onData = (chunk) => {
44
+ const char = chunk.toString();
45
+ const code = char.charCodeAt(0);
46
+
47
+ if (code === CODE_LF || code === CODE_CR) {
48
+ stdin.setRawMode(false);
49
+ stdin.removeListener('data', onData);
50
+ process.stdout.write('\n');
51
+ rl.close();
52
+ resolve(value.trim());
53
+ return;
54
+ }
55
+
56
+ if (code === CODE_CTRL_C || code === CODE_CTRL_D) {
57
+ stdin.setRawMode(false);
58
+ process.stdout.write('\n');
59
+ process.exit(130);
60
+ return;
61
+ }
62
+
63
+ if (code === CODE_BACKSPACE || code === CODE_DEL) {
64
+ value = value.slice(0, -1);
65
+ return;
66
+ }
67
+
68
+ value += char;
69
+ };
70
+ stdin.on('data', onData);
71
+ });
72
+ }
73
+
74
+ module.exports = { ask, askHidden, confirm };
@@ -0,0 +1,35 @@
1
+ 'use strict';
2
+
3
+ // Minimal helper to idempotently add/replace one top-level [section] block in a
4
+ // TOML file's raw text, without pulling in a TOML parser dependency. Good enough
5
+ // for the narrow, known-shape config files this CLI writes into.
6
+ function upsertTomlSection(existingText, sectionHeader, sectionBody) {
7
+ const block = `[${sectionHeader}]\n${sectionBody}`;
8
+ if (!existingText) return block.endsWith('\n') ? block : `${block}\n`;
9
+
10
+ const headerLine = `[${sectionHeader}]`;
11
+ const lines = existingText.split('\n');
12
+ const startIdx = lines.findIndex((line) => line.trim() === headerLine);
13
+
14
+ if (startIdx === -1) {
15
+ const needsBlankLine = existingText.length > 0 && !existingText.endsWith('\n\n');
16
+ const separator = existingText.endsWith('\n') ? (needsBlankLine ? '\n' : '') : '\n\n';
17
+ const suffix = block.endsWith('\n') ? block : `${block}\n`;
18
+ return `${existingText}${separator}${suffix}`;
19
+ }
20
+
21
+ let endIdx = lines.length;
22
+ for (let i = startIdx + 1; i < lines.length; i += 1) {
23
+ if (lines[i].trim().startsWith('[')) {
24
+ endIdx = i;
25
+ break;
26
+ }
27
+ }
28
+
29
+ const before = lines.slice(0, startIdx);
30
+ const after = lines.slice(endIdx);
31
+ const newLines = [...before, ...block.split('\n').slice(0, -1), ...after];
32
+ return `${newLines.join('\n').replace(/\n+$/, '\n')}`;
33
+ }
34
+
35
+ module.exports = { upsertTomlSection };