scalix 1.3.2 → 1.4.1

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/CHANGELOG.md CHANGED
@@ -2,6 +2,33 @@
2
2
 
3
3
  All notable changes to Scalix Coder will be documented in this file.
4
4
 
5
+ ## [1.4.0] - 2026-07-05
6
+
7
+ ### Added
8
+ - Scalix Cloud platform tools in the agent — your account's MCP catalog (`GET /api/v1/mcp`, scope-filtered per key) is fetched on start and registered as first-class function tools, executed via `POST /api/v1/mcp/call` with your own API key. Deploys, database queries, storage, functions and more become plain agent prompts
9
+ - `/cloud` command — `/cloud status` (identity, plan, limits, tool count), `/cloud tools` (available `scalix_*` tools grouped by platform area), `/cloud refresh` (re-fetch identity + catalog)
10
+ - `/login` command — links your Scalix account through the setup wizard
11
+ - Connected onboarding — the setup wizard validates the API key against `GET /v1/me` and shows a "Connected to Scalix Cloud" panel (org, project, plan, rate limit, platform tools unlocked) with a re-enter/skip choice on failure; a "Try:" block with real platform prompts appears after a successful connect
12
+ - Welcome/status line reflects the real connection: `☁ Scalix Cloud · <plan> · <N> platform tools` when connected, `☁ not connected — /login to link your Scalix account` otherwise
13
+ - `SCALIX_PROJECT` env var — sent as `x-scalix-project` on catalog/tool calls to scope an org-wide key to one project
14
+
15
+ ### Changed
16
+ - When the catalog is unreachable or no key is configured, the agent states it once and runs with local tools only — availability is never fabricated and there is no retry loop. Tool counts in the status bar and headless banner include only tools that actually loaded
17
+
18
+ ## [1.3.3] - 2026-07-05
19
+
20
+ ### Fixed
21
+ - API endpoints aligned to the Scalix gateway's `/v1/ai` nest: chat completions now POST to `/v1/ai/chat/completions` and the model list is fetched from `GET /v1/ai/models` (OpenAI-style `{data: [...]}`). The default base URL is now the host root `https://api.scalix.world`; a trailing `/v1` in existing configs is still accepted and normalized
22
+ - Voice input calls the single canonical `/v1/ai/audio/transcriptions` path and reports plainly when transcription isn't available on the deployment, instead of probing two dead endpoints
23
+ - Web search no longer calls a non-existent platform research endpoint; it states that web research isn't available via the Scalix API yet and uses the configured search providers (Brave/Tavily/DuckDuckGo)
24
+ - Sandbox no longer falsely blocks file access when the working directory sits behind a symlink (e.g. macOS temp dirs): symlink containment now compares real paths on both sides
25
+ - `User-Agent` header carries the real package version instead of a hardcoded `1.0`
26
+
27
+ ### Changed
28
+ - Model examples and defaults use current Scalix model names (`scalix-lumio-*`); the retired `scalix-world-ai` id removed from UI text and defaults
29
+ - Model list UI no longer shows tier/provider columns (the API does not expose them)
30
+ - README version badge matches the released package version
31
+
5
32
  ## [3.1.0] - 2026-04-20
6
33
 
7
34
  ### Added
package/README.md CHANGED
@@ -1,17 +1,25 @@
1
1
  # Scalix Coder
2
2
 
3
- AI-powered coding agent for the terminal and VS Code — built by [Scalix World](https://scalix.world).
3
+ AI-powered coding agent for the terminal and [VS Code](https://marketplace.visualstudio.com/items?itemName=scalix.scalix) — built by [Scalix World](https://scalix.world).
4
4
 
5
- ![Version](https://img.shields.io/badge/version-1.0.0-blue)
5
+ ![Version](https://img.shields.io/badge/version-1.4.0-blue)
6
6
  ![Node](https://img.shields.io/badge/node-%3E%3D18-green)
7
7
  ![License](https://img.shields.io/badge/license-EULA-orange)
8
8
 
9
- Scalix Coder is a full-featured AI coding assistant that runs in your terminal or as a VS Code extension. It connects to the Scalix API (or any OpenAI-compatible endpoint) and gives AI direct access to your codebase through 31 built-in tools — file editing, shell execution, search, git, web access, browser automation, vision, voice input, and more.
9
+ Scalix Coder is a full-featured AI coding assistant that runs in your terminal and in your editor — the extension is live on the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=scalix.scalix) and [Open VSX](https://open-vsx.org/extension/scalix/scalix) (Cursor/Windsurf/VSCodium). It connects to the Scalix API (or your own compatible endpoint) and gives AI direct access to your codebase through 31 built-in tools — file editing, shell execution, search, git, web access, browser automation, vision, voice input, and more.
10
+
11
+ ## One agent for your whole cloud
12
+
13
+ With a Scalix account, the agent is more than a coder: on start it fetches your account's platform tool catalog and registers every tool your API key's scopes unlock — databases, deployments (Scalix Run), storage, functions, cron, domains, and more. "Deploy this app", "create a table and seed it", "upload dist/ to a bucket" become plain prompts.
14
+
15
+ - Tools reflect **your key's scopes** — the catalog is filtered server-side, and mutating tools are refused for read-only keys
16
+ - `/cloud` shows your connection, plan, and available tools; `/cloud refresh` re-fetches the catalog
17
+ - No account (or catalog unreachable)? The agent says so once and runs in **local-only mode** with the built-in tools — nothing is faked
10
18
 
11
19
  ## Features
12
20
 
13
21
  - **Terminal UI** — Rich React/Ink interface with magenta-themed prompt, scrollable chat, streaming responses, and keyboard shortcuts
14
- - **VS Code Extension** — Sidebar chat panel with inline diffs, tab completion, inline chat (Cmd+I), codebase search, model picker, checkpoints, and session history
22
+ - **[VS Code Extension](https://marketplace.visualstudio.com/items?itemName=scalix.scalix)** — Sidebar chat panel with inline diffs, tab completion, inline chat (Cmd+I), codebase search, model picker, checkpoints, and session history
15
23
  - **31 Built-in Tools** — Shell, file read/write/edit, search (ripgrep), git, web fetch/search, directory tree, notebooks, browser automation, vision, voice input, sub-agents
16
24
  - **Security Sandbox** — Path traversal prevention, symlink detection, command blocklist, injection detection, SSRF protection
17
25
  - **4 Permission Modes** — `plan` (read-only) / `default` (ask) / `auto` (auto-approve) / `yolo` (full autonomy)
@@ -55,6 +63,7 @@ First launch opens a setup wizard to configure your Scalix API key, endpoint, an
55
63
  | `SCALIX_API_KEY` | API key (overrides saved config) |
56
64
  | `SCALIX_MODEL` | Comma-separated model IDs |
57
65
  | `SCALIX_BASE_URL` | API base URL |
66
+ | `SCALIX_PROJECT` | Project ID for org-wide keys (sent as `x-scalix-project` on platform tool calls) |
58
67
  | `BRAVE_API_KEY` | Brave Search API key |
59
68
  | `TAVILY_API_KEY` | Tavily Search API key |
60
69
 
@@ -90,6 +99,8 @@ scalix run "generate a migration" --output migration.sql --format patch
90
99
  | `/replay` | Session timeline |
91
100
  | `/plugins` | Loaded plugins |
92
101
  | `/settings` | Configure limits |
102
+ | `/cloud` | Scalix Cloud status, platform tools & refresh |
103
+ | `/login` | Link your Scalix account |
93
104
  | `/setup` | Reconfigure API key & models |
94
105
  | `/delegate <task>` | Run with sub-agent |
95
106
  | `/checkpoint` | Create git checkpoint |
@@ -211,6 +222,14 @@ Config is stored in `~/.scalix-coder/config.json`.
211
222
 
212
223
  ## VS Code Extension
213
224
 
225
+ Install from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=scalix.scalix) — or from [Open VSX](https://open-vsx.org/extension/scalix/scalix) if you use Cursor, Windsurf, or VSCodium:
226
+
227
+ ```bash
228
+ code --install-extension scalix.scalix
229
+ ```
230
+
231
+ Or build from source with the steps below.
232
+
214
233
  Full-featured sidebar assistant with streaming chat, tab completion, inline chat, inline diffs, codebase search, and checkpoints.
215
234
 
216
235
  See [vscode-extension/README.md](vscode-extension/README.md) for setup and usage.
package/bin/scalix.js CHANGED
@@ -8,6 +8,14 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
8
8
  process.env.NODE_ENV = 'production';
9
9
 
10
10
  const args = process.argv.slice(2);
11
+
12
+ if (args.includes('--version') || args.includes('-v')) {
13
+ const { readFileSync } = await import('node:fs');
14
+ const pkg = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf-8'));
15
+ console.log(pkg.version);
16
+ process.exit(0);
17
+ }
18
+
11
19
  const needsHeadless =
12
20
  args.includes('run') ||
13
21
  args.includes('--pipe') || args.includes('-p') ||
package/dist/agent.js CHANGED
@@ -3,7 +3,7 @@ import { EventEmitter } from 'eventemitter3';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { getToolPermissionWithArgs } from './sandbox.js';
5
5
  import { logger } from './logger.js';
6
- import { DEFAULT_MODELS as FALLBACK_MODELS, DEFAULT_INSTRUCTIONS, AGENT_DEFAULTS, TOOL_DEFAULTS, BACKOFF_DEFAULTS, modelSupportsVision, getModelSpec } from './constants.js';
6
+ import { DEFAULT_MODELS as FALLBACK_MODELS, DEFAULT_INSTRUCTIONS, DEFAULT_BASE_URL, AGENT_DEFAULTS, TOOL_DEFAULTS, BACKOFF_DEFAULTS, modelSupportsVision, getModelSpec } from './constants.js';
7
7
  import { estimateMessageTokens as estimateTokenCount } from './token-counter.js';
8
8
  import { ContextManager } from './context-manager.js';
9
9
  import { zodToJsonSchema } from './tool-helper.js';
@@ -19,8 +19,13 @@ import { extractCodeClaims, checkGrounding, formatGroundingNudge, GROUNDING_DEFA
19
19
  import { RETRIEVAL_DEFAULTS } from './context-retriever.js';
20
20
  import fs from 'node:fs/promises';
21
21
  import path from 'node:path';
22
+ import { fetchCloudTools, callCloudTool } from './cloud.js';
22
23
  function transformToolsForApi(tools) {
23
24
  return tools
25
+ // type:'mcp' entries are decorative config for user-registered external
26
+ // MCP servers and never reach the API. Scalix Cloud platform tools bypass
27
+ // this path entirely: they are plain function tools (CloudPlatformTool,
28
+ // registered by refreshCloudTools) whose `parameters` pass through as-is.
24
29
  .filter((t) => !('type' in t && t.type === 'mcp'))
25
30
  .map((t) => {
26
31
  const local = t;
@@ -158,12 +163,14 @@ export class Agent extends EventEmitter {
158
163
  retrievalConfig = null;
159
164
  _sending = false;
160
165
  outputFormatInstructionIndex = null;
166
+ cloudProjectId;
167
+ cloudStatus = { state: 'no-key', toolCount: 0 };
161
168
  constructor(config) {
162
169
  super();
163
170
  this.agentId = config.agentId ?? generateAgentId();
164
171
  this.parentAgentId = config.parentAgentId ?? null;
165
172
  this.agentDescription = config.agentDescription ?? 'main';
166
- this.client = new ScalixClient(config.baseUrl || 'https://api.scalix.world/v1', config.apiKey);
173
+ this.client = new ScalixClient(config.baseUrl || DEFAULT_BASE_URL, config.apiKey);
167
174
  this.log = logger.child();
168
175
  const resolvedModels = Array.isArray(config.model)
169
176
  ? (config.model.length > 0 ? config.model : FALLBACK_MODELS)
@@ -189,6 +196,7 @@ export class Agent extends EventEmitter {
189
196
  thinkingMode: config.thinkingMode ?? 'off',
190
197
  workspaceRoot: config.workspaceRoot ?? process.cwd(),
191
198
  };
199
+ this.cloudProjectId = config.cloudProjectId ?? process.env.SCALIX_PROJECT ?? undefined;
192
200
  this.contextManager = new ContextManager(config.contextManager);
193
201
  if (config.dynamicToolSelection) {
194
202
  this.toolSelector = new ToolSelector();
@@ -422,6 +430,82 @@ export class Agent extends EventEmitter {
422
430
  if (this.toolSelector)
423
431
  this.toolSelector.index(this.config.tools);
424
432
  }
433
+ // ─── Scalix Cloud platform tools ───
434
+ isCloudTool(t) {
435
+ return 'source' in t && t.source === 'scalix-cloud';
436
+ }
437
+ /** Build a first-class function tool from a catalog entry. The server's JSON
438
+ * Schema is passed to the model as-is via `parameters` (NOT `inputSchema`,
439
+ * which transformToolsForApi would run through the Zod converter). */
440
+ makeCloudTool(ct) {
441
+ const baseUrl = this.client.baseURL;
442
+ const apiKey = this.config.apiKey;
443
+ const projectId = this.cloudProjectId;
444
+ return {
445
+ source: 'scalix-cloud',
446
+ name: ct.name,
447
+ description: ct.description,
448
+ parameters: ct.inputSchema,
449
+ execute: async (args) => {
450
+ const result = await callCloudTool(baseUrl, apiKey, ct.name, args ?? {}, projectId);
451
+ // Tool-level failures are tool results the model can react to, not
452
+ // exceptions — the "Error: " prefix is the model-facing signal.
453
+ return result.ok ? result.text : `Error: ${result.text}`;
454
+ },
455
+ };
456
+ }
457
+ /**
458
+ * Fetch the account's Scalix Cloud tool catalog and register every returned
459
+ * tool as a first-class function tool. Called on agent start and on demand
460
+ * (/cloud refresh). On failure the previously registered cloud tools are
461
+ * removed and the agent keeps running with local tools only — the returned
462
+ * status carries the real error for a single honest notice; no retries.
463
+ */
464
+ async refreshCloudTools() {
465
+ if (!this.config.apiKey) {
466
+ this.config.tools = this.config.tools.filter((t) => !this.isCloudTool(t));
467
+ this.cloudStatus = { state: 'no-key', toolCount: 0 };
468
+ return { ...this.cloudStatus };
469
+ }
470
+ try {
471
+ const catalog = await fetchCloudTools(this.client.baseURL, this.config.apiKey, this.cloudProjectId);
472
+ this.config.tools = this.config.tools.filter((t) => !this.isCloudTool(t));
473
+ const localNames = new Set(this.config.tools.map((t) => t.function?.name || t.name).filter(Boolean));
474
+ let registered = 0;
475
+ for (const ct of catalog.tools) {
476
+ if (localNames.has(ct.name)) {
477
+ this.log.warn('Cloud tool name collides with a local tool — keeping the local tool', { tool: ct.name });
478
+ continue;
479
+ }
480
+ this.config.tools.push(this.makeCloudTool(ct));
481
+ registered++;
482
+ }
483
+ if (this.toolSelector)
484
+ this.toolSelector.index(this.config.tools);
485
+ this.cloudStatus = {
486
+ state: 'connected',
487
+ toolCount: registered,
488
+ platformAvailable: catalog.platformAvailable,
489
+ };
490
+ }
491
+ catch (err) {
492
+ this.config.tools = this.config.tools.filter((t) => !this.isCloudTool(t));
493
+ if (this.toolSelector)
494
+ this.toolSelector.index(this.config.tools);
495
+ this.cloudStatus = { state: 'unavailable', toolCount: 0, error: err?.message || String(err) };
496
+ this.log.warn('Scalix Cloud tool catalog unavailable', { error: this.cloudStatus.error });
497
+ }
498
+ return { ...this.cloudStatus };
499
+ }
500
+ getCloudToolsStatus() {
501
+ return { ...this.cloudStatus };
502
+ }
503
+ getCloudTools() {
504
+ return this.config.tools.filter((t) => this.isCloudTool(t));
505
+ }
506
+ getCloudProjectId() {
507
+ return this.cloudProjectId;
508
+ }
425
509
  addModel(model) {
426
510
  if (!this.config.models.includes(model))
427
511
  this.config.models.push(model);
@@ -973,7 +1057,12 @@ export class Agent extends EventEmitter {
973
1057
  const execFn = tool?.function?.execute ?? tool?.execute;
974
1058
  if (tool && typeof execFn === 'function') {
975
1059
  try {
976
- const args = normalizeArgs(parsedArgs);
1060
+ // Cloud platform tools get the raw args: their JSON Schemas are
1061
+ // authoritative (snake_case) and the gateway validates them —
1062
+ // injecting camelCase duplicates could fail server-side validation.
1063
+ const args = tool?.source === 'scalix-cloud'
1064
+ ? parsedArgs
1065
+ : normalizeArgs(parsedArgs);
977
1066
  reqLog.debug('Executing tool', { tool: call.name });
978
1067
  const toolTimeout = (typeof args?.timeout === 'number' && args.timeout > 0)
979
1068
  ? Math.min(args.timeout, TOOL_DEFAULTS.maxCommandTimeoutMs)
@@ -1,3 +1,4 @@
1
+ import { USER_AGENT, aiEndpointUrl } from './constants.js';
1
2
  export class ApiError extends Error {
2
3
  status;
3
4
  body;
@@ -19,7 +20,9 @@ export class ScalixClient {
19
20
  this.timeoutMs = timeoutMs;
20
21
  }
21
22
  async chatComplete(req, signal) {
22
- const url = `${this.baseURL}/chat/completions`;
23
+ // baseURL is the API host root; the gateway nests AI under /v1/ai.
24
+ // aiEndpointUrl() also tolerates a legacy base ending in /v1.
25
+ const url = aiEndpointUrl(this.baseURL, 'chat/completions');
23
26
  const body = JSON.stringify(req);
24
27
  const timeoutSignal = AbortSignal.timeout(this.timeoutMs);
25
28
  const combined = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
@@ -28,7 +31,7 @@ export class ScalixClient {
28
31
  headers: {
29
32
  'Content-Type': 'application/json',
30
33
  'Authorization': `Bearer ${this.apiKey}`,
31
- 'User-Agent': 'Scalix-Coder/1.0',
34
+ 'User-Agent': USER_AGENT,
32
35
  },
33
36
  body,
34
37
  signal: combined,
package/dist/cli.js CHANGED
@@ -45,6 +45,7 @@ import { createWorktree, listWorktrees, removeWorktree, cleanupStaleWorktrees }
45
45
  import { sendNotification, getTerminalType } from './terminal-notify.js';
46
46
  import { stringWidth, truncateText } from './text-utils.js';
47
47
  import { isWorkspaceTrusted, trustWorkspace, hasProjectExecSurface } from './workspace-trust.js';
48
+ import { fetchMe, fetchCloudTools, groupCloudTools, CONSOLE_KEYS_URL } from './cloud.js';
48
49
  logger.setLevel('silent');
49
50
  // Promisified async child_process helpers (keep the TUI responsive vs execSync).
50
51
  const execFileAsync = promisify(execFile);
@@ -92,6 +93,11 @@ console.error = (...args) => {
92
93
  // sanitizeInput) and buffer the pasted content into the input value instead of
93
94
  // treating each embedded newline as a separate Enter (which caused many submits).
94
95
  process.stdout.write('\x1b[?2004h');
96
+ // User-registered external MCP servers (mcp-services.json). These are
97
+ // decorative config entries only — they are filtered out of API requests by
98
+ // transformToolsForApi. Scalix Cloud platform tools do NOT use this path: they
99
+ // are fetched from the account's catalog and registered as first-class
100
+ // function tools via agent.refreshCloudTools() (see connectCloud below).
95
101
  const mcpTools = loadMcpServices();
96
102
  let pluginTools = [];
97
103
  const allTools = [...defaultTools, ...extraTools, ...webTools, ...advancedTools, ...visionTools, ...voiceTools, ...browserTools, ...mcpTools];
@@ -139,6 +145,48 @@ let loadedSkills = [];
139
145
  // exec surface (plugins/hooks/rules/settings) that we skipped loading. The App
140
146
  // reads this on mount to inform the user they can run /trust to enable them.
141
147
  let workspaceUntrustedNotice = false;
148
+ let cloudConnection = { connected: false, identity: null, toolCount: 0 };
149
+ // Set by the setup wizard when its Scalix Cloud validation step succeeded, so
150
+ // the App shows the "Try:" teach-in exactly once after a wizard or /login
151
+ // connect (not on every ordinary startup reconnect).
152
+ let cloudTeachInPending = false;
153
+ // Validate the key against /v1/me, then fetch the account's tool catalog and
154
+ // register it on the agent. One attempt, no retry loop: failure leaves the
155
+ // agent with local tools only and carries the server's own error message.
156
+ async function connectCloud(targetAgent) {
157
+ if (!targetAgent.getApiKey()) {
158
+ cloudConnection = { connected: false, identity: null, toolCount: 0, error: 'no API key configured' };
159
+ return cloudConnection;
160
+ }
161
+ try {
162
+ const identity = await fetchMe(targetAgent.getBaseUrl() || DEFAULT_BASE_URL, targetAgent.getApiKey());
163
+ const status = await targetAgent.refreshCloudTools();
164
+ if (status.state === 'connected') {
165
+ cloudConnection = { connected: true, identity, toolCount: status.toolCount };
166
+ }
167
+ else {
168
+ cloudConnection = { connected: false, identity, toolCount: 0, error: status.error };
169
+ }
170
+ }
171
+ catch (err) {
172
+ // /v1/me failed. Run the catalog fetch anyway: on failure it clears any
173
+ // stale platform tools from a previous connect; in the rare case it
174
+ // succeeds, the tools genuinely work and we say so (identity unavailable).
175
+ const status = await targetAgent.refreshCloudTools().catch(() => null);
176
+ if (status?.state === 'connected') {
177
+ cloudConnection = { connected: true, identity: null, toolCount: status.toolCount, error: err?.message || String(err) };
178
+ }
179
+ else {
180
+ cloudConnection = { connected: false, identity: null, toolCount: 0, error: err?.message || String(err) };
181
+ }
182
+ }
183
+ return cloudConnection;
184
+ }
185
+ const CLOUD_TRY_PROMPTS = [
186
+ 'deploy this app to Scalix Run',
187
+ 'create a table in my Scalix database and insert seed data',
188
+ 'list my storage buckets and upload dist/ to one',
189
+ ];
142
190
  // Discover and register project-local plugin tools for the given agent.
143
191
  // Gated on workspace trust; reused by initAgent and the /trust command.
144
192
  function discoverPluginsForAgent(targetAgent) {
@@ -403,7 +451,7 @@ function formatContextSize(tokens) {
403
451
  return String(tokens);
404
452
  }
405
453
  function SetupScreen({ onComplete, existingConfig }) {
406
- const STEPS = ['api-key', 'base-url', 'models', 'context-window'];
454
+ const STEPS = ['api-key', 'base-url', 'cloud', 'models', 'context-window'];
407
455
  const [step, setStep] = useState('api-key');
408
456
  const [apiKey, setApiKey] = useState(existingConfig?.apiKey || process.env[ENV_API_KEY] || '');
409
457
  const [baseUrl, setBaseUrl] = useState(existingConfig?.baseUrl || DEFAULT_BASE_URL);
@@ -414,7 +462,33 @@ function SetupScreen({ onComplete, existingConfig }) {
414
462
  const [apiKeyError, setApiKeyError] = useState(false);
415
463
  const [fetchedModels, setFetchedModels] = useState([]);
416
464
  const [fetchingModels, setFetchingModels] = useState(false);
465
+ const [cloudCheck, setCloudCheck] = useState({ phase: 'checking' });
417
466
  const { exit } = useApp();
467
+ // Validate the key against Scalix Cloud when the cloud step is entered:
468
+ // /v1/me for identity/plan, then the tool catalog for the unlocked count.
469
+ useEffect(() => {
470
+ if (step !== 'cloud')
471
+ return;
472
+ let cancelled = false;
473
+ setCloudCheck({ phase: 'checking' });
474
+ (async () => {
475
+ const identity = await fetchMe(baseUrl, apiKey);
476
+ const catalog = await fetchCloudTools(baseUrl, apiKey, process.env.SCALIX_PROJECT);
477
+ return { identity, toolCount: catalog.tools.length };
478
+ })().then((result) => {
479
+ if (cancelled)
480
+ return;
481
+ cloudTeachInPending = true;
482
+ setCloudCheck({ phase: 'ok', identity: result.identity, toolCount: result.toolCount });
483
+ }).catch((err) => {
484
+ if (cancelled)
485
+ return;
486
+ setCloudCheck({ phase: 'fail', error: err?.message || String(err) });
487
+ });
488
+ return () => { cancelled = true; };
489
+ // apiKey/baseUrl are intentionally not deps: they are frozen once the user
490
+ // advances past base-url, and the step transition is the trigger.
491
+ }, [step]);
418
492
  const totalSteps = STEPS.length;
419
493
  const stepNumber = STEPS.indexOf(step) + 1;
420
494
  const goBack = useCallback(() => {
@@ -453,6 +527,8 @@ function SetupScreen({ onComplete, existingConfig }) {
453
527
  }, placeholder: "sk-or-v1-..." })] }), apiKeyError && (_jsx(Box, { marginTop: 0, paddingLeft: 2, children: _jsx(Text, { color: "yellow", dimColor: true, children: "API key is required." }) }))] })), step === 'base-url' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "white", children: "Base URL" }), _jsx(Text, { dimColor: true, children: "Scalix API endpoint (or any compatible URL)" }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "#20a0a0", bold: true, children: ['\u276F', " "] }), _jsx(TextInput, { value: baseUrl, onChange: (v) => setBaseUrl(sanitizeInput(v)), onSubmit: (v) => {
454
528
  const url = sanitizeInput(v).trim() || baseUrl;
455
529
  setBaseUrl(url);
530
+ // Model list loads in the background while the cloud step
531
+ // validates the key via /v1/me + the tool catalog.
456
532
  setFetchingModels(true);
457
533
  fetchModelSpecs(url, apiKey).then((result) => {
458
534
  if (result.length > 0) {
@@ -460,9 +536,19 @@ function SetupScreen({ onComplete, existingConfig }) {
460
536
  }
461
537
  }).catch((err) => { logger.warn('Failed to fetch model specs', { error: err.message }); }).finally(() => {
462
538
  setFetchingModels(false);
463
- setStep('models');
464
539
  });
465
- }, placeholder: DEFAULT_BASE_URL })] }), fetchingModels && (_jsx(Box, { paddingLeft: 2, marginTop: 1, children: _jsx(Text, { color: "#20a0a0", dimColor: true, children: "Fetching available models..." }) }))] })), step === 'models' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "white", children: "Models" }), fetchedModels.length > 0 && modelsMode === 'custom' && models.length === 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Select models from your API or type a custom model ID." }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(SelectInput, { items: [
540
+ setStep('cloud');
541
+ }, placeholder: DEFAULT_BASE_URL })] }), fetchingModels && (_jsx(Box, { paddingLeft: 2, marginTop: 1, children: _jsx(Text, { color: "#20a0a0", dimColor: true, children: "Fetching available models..." }) }))] })), step === 'cloud' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "white", children: "Scalix Cloud" }), cloudCheck.phase === 'checking' && (_jsxs(Box, { marginTop: 1, gap: 1, children: [_jsx(Text, { color: "#20a0a0", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: "Validating your API key with Scalix Cloud..." })] })), cloudCheck.phase === 'ok' && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "#30c090", paddingX: 2, marginTop: 1, alignSelf: "flex-start", children: [_jsxs(Text, { color: "#30c090", bold: true, children: ['✓', " Connected to Scalix Cloud"] }), _jsxs(Text, { children: ["Org: ", _jsx(Text, { color: "#20a0a0", children: cloudCheck.identity.identity.orgId })] }), _jsxs(Text, { children: ["Project: ", _jsx(Text, { color: "#20a0a0", children: cloudCheck.identity.identity.projectId || 'org-wide' })] }), _jsxs(Text, { children: ["Plan: ", _jsx(Text, { color: "#20a0a0", children: cloudCheck.identity.limits.plan })] }), _jsxs(Text, { children: ["Rate limit: ", _jsxs(Text, { color: "#20a0a0", children: [cloudCheck.identity.limits.rateLimitRps, " req/s"] })] }), _jsxs(Text, { color: "#30c090", bold: true, children: ['●', " ", cloudCheck.toolCount, " platform tools unlocked"] })] }), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: [{ label: '→ Continue', value: '__continue__' }], onSelect: () => setStep('models') }) })] })), cloudCheck.phase === 'fail' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", children: ['✘', " ", cloudCheck.error] }) }), _jsxs(Text, { dimColor: true, children: ["Create or check your key at ", CONSOLE_KEYS_URL] }), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: [
542
+ { label: 'Re-enter API key', value: '__reenter__' },
543
+ { label: 'Skip — continue without Scalix Cloud (local tools only)', value: '__skip__' },
544
+ ], onSelect: (item) => {
545
+ if (item.value === '__reenter__') {
546
+ setStep('api-key');
547
+ }
548
+ else {
549
+ setStep('models');
550
+ }
551
+ } }) })] }))] })), step === 'models' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "white", children: "Models" }), fetchedModels.length > 0 && modelsMode === 'custom' && models.length === 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Select models from your API or type a custom model ID." }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(SelectInput, { items: [
466
552
  ...fetchedModels.filter((fm) => !models.includes(fm.id)).map((fm) => ({
467
553
  label: ` ${fm.name || fm.id}`,
468
554
  value: fm.id,
@@ -587,8 +673,8 @@ const StatusBar = memo(function StatusBar({ status, model, toolCount, thinkStart
587
673
  const gap = Math.max(cols - leftText.length - rightLen - 8, 2);
588
674
  return (_jsxs(Box, { paddingX: 2, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: statusColor, children: statusIcon }), _jsx(Text, { dimColor: true, children: leftText })] }), _jsx(Text, { children: ' '.repeat(gap) }), _jsxs(Box, { gap: 1, children: [rateLimitWarn && _jsxs(Text, { color: "red", bold: true, children: ['\u26A0', " Rate limited"] }), _jsx(Text, { color: modeColor, bold: true, children: mode }), elapsedStr ? _jsxs(Text, { dimColor: true, children: ['\u00B7', " ", elapsedStr] }) : null, _jsxs(Text, { dimColor: true, children: ['\u00B7', " v", VERSION] })] })] }));
589
675
  });
590
- const WelcomeScreen = memo(function WelcomeScreen() {
591
- return (_jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 0, children: [_jsx(Text, { dimColor: true, children: " Type a message or use /help for commands. Press Esc to exit." }), _jsxs(Box, { marginTop: 0, paddingLeft: 2, gap: 1, flexWrap: "wrap", children: [_jsx(Text, { dimColor: true, children: "Tools:" }), ['shell', 'read', 'write', 'edit', 'search', 'find', 'git', 'web', 'tree'].map((t) => (_jsx(Text, { dimColor: true, bold: true, children: t }, t)))] })] }));
676
+ const WelcomeScreen = memo(function WelcomeScreen({ cloud }) {
677
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 0, children: [_jsx(Text, { dimColor: true, children: " Type a message or use /help for commands. Press Esc to exit." }), _jsxs(Box, { marginTop: 0, paddingLeft: 2, gap: 1, flexWrap: "wrap", children: [_jsx(Text, { dimColor: true, children: "Tools:" }), ['shell', 'read', 'write', 'edit', 'search', 'find', 'git', 'web', 'tree'].map((t) => (_jsx(Text, { dimColor: true, bold: true, children: t }, t)))] }), _jsx(Box, { paddingLeft: 2, children: cloud.connected ? (_jsxs(Text, { color: "#20a0a0", children: ['☁', " Scalix Cloud ", '·', " ", cloud.identity ? `${cloud.identity.limits.plan} ${'·'} ` : '', cloud.toolCount, " platform tools"] })) : (_jsxs(Text, { dimColor: true, children: ['☁', " not connected ", '—', " /login to link your Scalix account"] })) })] }));
592
678
  });
593
679
  const UserMessage = memo(function UserMessage({ text }) {
594
680
  return (_jsxs(Box, { paddingX: 2, marginTop: 1, gap: 1, children: [_jsx(Text, { color: "magentaBright", bold: true, children: '\u276F' }), _jsx(Text, { wrap: "wrap", bold: true, children: text })] }));
@@ -1251,6 +1337,8 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
1251
1337
  { cmd: '/status', desc: 'Version, model & connection info' },
1252
1338
  { cmd: '/doctor', desc: 'Diagnose installation' },
1253
1339
  { cmd: '/mcp', desc: 'Add MCP server' },
1340
+ { cmd: '/cloud', desc: 'Scalix Cloud status, platform tools & refresh' },
1341
+ { cmd: '/login', desc: 'Link your Scalix account (API key)' },
1254
1342
  { cmd: '/setup', desc: 'Reconfigure API key, URL & models' },
1255
1343
  { cmd: '/settings', desc: 'View & configure limits' },
1256
1344
  { cmd: '/permissions', desc: 'View permission rules & recent denials' },
@@ -1428,6 +1516,7 @@ function App({ config }) {
1428
1516
  const [selectingMode, setSelectingMode] = useState(false);
1429
1517
  const [editingSettings, setEditingSettings] = useState(false);
1430
1518
  const [agentVersion, setAgentVersion] = useState(0);
1519
+ const [cloudInfo, setCloudInfo] = useState(cloudConnection);
1431
1520
  const counter = useRef(0);
1432
1521
  const showBannerRef = useRef(true);
1433
1522
  const modelsRef = useRef(models);
@@ -1687,6 +1776,34 @@ function App({ config }) {
1687
1776
  add('system', 'This workspace has project plugins/hooks/rules that were skipped because the workspace is not trusted. Run /trust to load them.');
1688
1777
  }
1689
1778
  }, [add]);
1779
+ // Scalix Cloud: on agent start (and after /setup or /login re-init), fetch
1780
+ // the account's tool catalog and register it. Failure or no key → local
1781
+ // tools only, with exactly one honest notice. No retry loop.
1782
+ useEffect(() => {
1783
+ if (!agent)
1784
+ return;
1785
+ const hasKey = !!agent.getApiKey();
1786
+ let cancelled = false;
1787
+ connectCloud(agent).then((state) => {
1788
+ if (cancelled)
1789
+ return;
1790
+ setCloudInfo({ ...state });
1791
+ if (state.connected) {
1792
+ // Teach-in: only right after a wizard or /login connect, and only
1793
+ // when tools actually loaded.
1794
+ if (cloudTeachInPending && state.toolCount > 0) {
1795
+ cloudTeachInPending = false;
1796
+ const plan = state.identity ? ` ${'·'} ${state.identity.limits.plan}` : '';
1797
+ add('system', `☁ Connected to Scalix Cloud${plan} ${'·'} ${state.toolCount} platform tools`);
1798
+ add('system', `Try:\n${CLOUD_TRY_PROMPTS.map((p) => ` ❯ ${p}`).join('\n')}`);
1799
+ }
1800
+ }
1801
+ else if (hasKey) {
1802
+ add('system', `☁ Scalix Cloud unavailable — running with local tools only. ${state.error ?? ''}`.trimEnd());
1803
+ }
1804
+ });
1805
+ return () => { cancelled = true; };
1806
+ }, [add, agentVersion]);
1690
1807
  // Idle session nudge: suggest /clear after 75+ minutes of inactivity
1691
1808
  const lastActivityRef = useRef(Date.now());
1692
1809
  const idleNudgedRef = useRef(false);
@@ -1804,6 +1921,8 @@ function App({ config }) {
1804
1921
  '',
1805
1922
  'Tools & Config:',
1806
1923
  ' /copy \u2014 copy last response to clipboard',
1924
+ ' /cloud \u2014 Scalix Cloud status (/cloud tools, /cloud refresh)',
1925
+ ' /login \u2014 link your Scalix account (API key)',
1807
1926
  ' /status \u2014 show version, model & connection info',
1808
1927
  ' /update \u2014 check for and install updates',
1809
1928
  ' /doctor \u2014 diagnose installation',
@@ -2027,6 +2146,68 @@ function App({ config }) {
2027
2146
  setShowSetup(true);
2028
2147
  return true;
2029
2148
  }
2149
+ if (cmd === '/login') {
2150
+ // Key (re-)entry runs through the setup wizard, whose cloud step
2151
+ // performs the same fetchMe + catalog validation as first-run setup.
2152
+ setShowSetup(true);
2153
+ return true;
2154
+ }
2155
+ if (cmd === '/cloud') {
2156
+ const sub = text.split(' ')[1]?.toLowerCase() || 'status';
2157
+ if (sub === 'status') {
2158
+ const s = cloudConnection;
2159
+ if (s.connected) {
2160
+ add('system', `☁ Scalix Cloud ${'—'} connected`);
2161
+ if (s.identity) {
2162
+ add('system', `Org: ${s.identity.identity.orgId}`);
2163
+ add('system', `Project: ${s.identity.identity.projectId || agent.getCloudProjectId() || 'org-wide'}`);
2164
+ add('system', `Key: ${s.identity.identity.keyId} (${s.identity.permissions.role})`);
2165
+ add('system', `Plan: ${s.identity.limits.plan} ${'·'} ${s.identity.limits.rateLimitRps} req/s ${'·'} ${s.identity.limits.apiKeysMax} API keys max`);
2166
+ add('system', `Scopes: ${s.identity.permissions.scopes.join(', ') || 'none'}`);
2167
+ }
2168
+ else {
2169
+ add('system', `Identity unavailable: ${s.error ?? 'unknown error'}`);
2170
+ }
2171
+ add('system', `Platform tools: ${s.toolCount} registered ${'—'} /cloud tools to list them`);
2172
+ }
2173
+ else {
2174
+ add('system', `☁ not connected ${'—'} /login to link your Scalix account`);
2175
+ if (s.error)
2176
+ add('system', `Last error: ${s.error}`);
2177
+ }
2178
+ return true;
2179
+ }
2180
+ if (sub === 'tools') {
2181
+ const tools = agent.getCloudTools();
2182
+ if (tools.length === 0) {
2183
+ add('system', cloudConnection.connected
2184
+ ? 'No platform tools are registered for this key (its scopes unlock none).'
2185
+ : `☁ not connected ${'—'} /login to link your Scalix account`);
2186
+ return true;
2187
+ }
2188
+ add('system', `Platform tools (${tools.length}):`);
2189
+ for (const [group, groupTools] of groupCloudTools(tools)) {
2190
+ add('system', ` ${group} (${groupTools.length}): ${groupTools.map((t) => t.name).join(', ')}`);
2191
+ }
2192
+ return true;
2193
+ }
2194
+ if (sub === 'refresh') {
2195
+ add('system', 'Refreshing Scalix Cloud connection...');
2196
+ connectCloud(agent).then((state) => {
2197
+ setCloudInfo({ ...state });
2198
+ if (state.connected) {
2199
+ const plan = state.identity ? `${state.identity.limits.plan} plan ${'·'} ` : '';
2200
+ add('system', `☁ Connected ${'—'} ${plan}${state.toolCount} platform tools`);
2201
+ }
2202
+ else {
2203
+ add('error', `☁ Scalix Cloud unavailable: ${state.error ?? 'unknown error'}`);
2204
+ }
2205
+ });
2206
+ return true;
2207
+ }
2208
+ add('system', 'Usage: /cloud [status|tools|refresh]');
2209
+ return true;
2210
+ }
2030
2211
  if (cmd === '/mode') {
2031
2212
  const arg = text.split(' ')[1]?.toLowerCase();
2032
2213
  if (arg && PERMISSION_MODES.includes(arg)) {
@@ -2626,6 +2807,9 @@ function App({ config }) {
2626
2807
  add('system', `${PRODUCT_NAME} v${VERSION}`);
2627
2808
  add('system', `Model: ${modelsStr}`);
2628
2809
  add('system', `API: ${baseUrl}`);
2810
+ add('system', cloudConnection.connected
2811
+ ? `Cloud: ☁ Scalix Cloud ${'·'} ${cloudConnection.identity ? `${cloudConnection.identity.limits.plan} ${'·'} ` : ''}${cloudConnection.toolCount} platform tools`
2812
+ : `Cloud: ☁ not connected ${'—'} /login to link your Scalix account`);
2629
2813
  add('system', `Mode: ${mode}`);
2630
2814
  add('system', `Context: ${ctx.percentage}% (${formatTokens(ctx.tokens)} / ${formatTokens(ctx.maxTokens)})`);
2631
2815
  add('system', `Messages: ${msgCount}`);
@@ -3060,7 +3244,7 @@ function App({ config }) {
3060
3244
  if (showSetup) {
3061
3245
  return _jsx(SetupScreen, { onComplete: handleSetupDone, existingConfig: config });
3062
3246
  }
3063
- return (_jsxs(Box, { flexDirection: "column", width: "100%", height: rows, children: [showBanner && _jsx(Banner, {}), _jsx(StatusBar, { status: status, model: models[0] || 'no model', toolCount: allTools.length + pluginTools.length, thinkStart: thinkStart, tokens: totalTokens, mode: currentMode, contextPercent: contextPercent, rateLimitWarn: rateLimitWarn }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, minHeight: 6, overflowY: "hidden", children: [showBanner && !isLoading && _jsx(WelcomeScreen, {}), !isAtTop && !showBanner && (_jsx(Box, { justifyContent: "center", children: _jsxs(Text, { dimColor: true, children: [" ", '\u2191', " more above ", '\u2014', " Shift+Up "] }) })), visibleItems.map((item) => _jsx(RenderItem, { item: item }, item.id)), isLoading && _jsx(StreamingArea, { text: streamText, reasoning: reasoningText, model: shortModel(models[0] || '') }), !isAtBottom && (_jsx(Box, { justifyContent: "center", children: _jsxs(Text, { dimColor: true, children: [" ", '\u2193', " more below ", '\u2014', " Shift+Down "] }) }))] }), _jsx(Box, { flexShrink: 0, flexDirection: "column", children: confirmInfo ? (_jsx(ConfirmPrompt, { toolName: confirmInfo.toolName, args: confirmInfo.args, onRespond: handleConfirm, onScrollUp: scrollUp, onScrollDown: scrollDown })) : selectingModel ? (_jsx(ModelPicker, { currentModels: models, onSelect: (v) => {
3247
+ return (_jsxs(Box, { flexDirection: "column", width: "100%", height: rows, children: [showBanner && _jsx(Banner, {}), _jsx(StatusBar, { status: status, model: models[0] || 'no model', toolCount: allTools.length + pluginTools.length + cloudInfo.toolCount, thinkStart: thinkStart, tokens: totalTokens, mode: currentMode, contextPercent: contextPercent, rateLimitWarn: rateLimitWarn }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, minHeight: 6, overflowY: "hidden", children: [showBanner && !isLoading && _jsx(WelcomeScreen, { cloud: cloudInfo }), !isAtTop && !showBanner && (_jsx(Box, { justifyContent: "center", children: _jsxs(Text, { dimColor: true, children: [" ", '\u2191', " more above ", '\u2014', " Shift+Up "] }) })), visibleItems.map((item) => _jsx(RenderItem, { item: item }, item.id)), isLoading && _jsx(StreamingArea, { text: streamText, reasoning: reasoningText, model: shortModel(models[0] || '') }), !isAtBottom && (_jsx(Box, { justifyContent: "center", children: _jsxs(Text, { dimColor: true, children: [" ", '\u2193', " more below ", '\u2014', " Shift+Down "] }) }))] }), _jsx(Box, { flexShrink: 0, flexDirection: "column", children: confirmInfo ? (_jsx(ConfirmPrompt, { toolName: confirmInfo.toolName, args: confirmInfo.args, onRespond: handleConfirm, onScrollUp: scrollUp, onScrollDown: scrollDown })) : selectingModel ? (_jsx(ModelPicker, { currentModels: models, onSelect: (v) => {
3064
3248
  agent.setModel(v);
3065
3249
  setModels([v]);
3066
3250
  setSelectingModel(false);
package/dist/cloud.js ADDED
@@ -0,0 +1,236 @@
1
+ // Scalix Cloud platform client — identity (/v1/me), the account's MCP tool
2
+ // catalog (/api/v1/mcp), and tool execution (/api/v1/mcp/call).
3
+ //
4
+ // Wire-format notes (verified against the gateway + mcp-server crates):
5
+ // - Catalog tool entries serialize the schema under `input_schema` (snake_case,
6
+ // serde default on McpToolDef in both crates/gateway/src/mcp.rs and
7
+ // crates/mcp-server/src/types.rs). The MCP JSON-RPC spec spells it
8
+ // `inputSchema`; we tolerate both so a future spec-aligned rename is a no-op.
9
+ // - Tool call results are `{content: [{type: 'text', text}], is_error}`
10
+ // (McpToolResult/McpContent — only text blocks are ever produced). `isError`
11
+ // is tolerated for the same forward-compat reason.
12
+ // - Error envelope on 4xx/5xx: `{error, code, request_id?, docs_url?}`
13
+ // (ApiError::into_response in crates/gateway/src/server.rs).
14
+ //
15
+ // Dependency-free: global fetch only. Reuses apiHostRoot() so legacy configs
16
+ // with a trailing /v1 keep working for these non-AI endpoints too.
17
+ import { apiHostRoot, USER_AGENT, WEB_DEFAULTS } from './constants.js';
18
+ export const CONSOLE_KEYS_URL = 'https://console.scalix.world/org/keys';
19
+ export const CLOUD_DEFAULTS = {
20
+ fetchTimeoutMs: WEB_DEFAULTS.apiFetchTimeoutMs,
21
+ // Platform operations (deploys, migrations) can legitimately take a while.
22
+ callTimeoutMs: 120_000,
23
+ };
24
+ export class CloudError extends Error {
25
+ status;
26
+ code;
27
+ docsUrl;
28
+ requestId;
29
+ constructor(message, opts = {}) {
30
+ super(message);
31
+ this.name = 'CloudError';
32
+ this.status = opts.status;
33
+ this.code = opts.code;
34
+ this.docsUrl = opts.docsUrl;
35
+ this.requestId = opts.requestId;
36
+ }
37
+ }
38
+ // ─── Pure parsers (unit-tested without network) ───
39
+ /**
40
+ * Build a CloudError from a non-OK response body using the gateway's error
41
+ * envelope `{error, code, docs_url, request_id}`. Falls back to the raw body
42
+ * (or status text) when the body isn't the envelope — the message is always
43
+ * whatever the server actually said, never an invented one.
44
+ */
45
+ export function errorFromEnvelope(status, statusText, bodyText) {
46
+ let envelope = null;
47
+ try {
48
+ const parsed = JSON.parse(bodyText);
49
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
50
+ envelope = parsed;
51
+ }
52
+ catch { }
53
+ if (status === 401) {
54
+ return new CloudError(`API key rejected — create one at ${CONSOLE_KEYS_URL}`, {
55
+ status,
56
+ code: typeof envelope?.code === 'string' ? envelope.code : undefined,
57
+ docsUrl: typeof envelope?.docs_url === 'string' ? envelope.docs_url : undefined,
58
+ requestId: typeof envelope?.request_id === 'string' ? envelope.request_id : undefined,
59
+ });
60
+ }
61
+ const message = typeof envelope?.error === 'string' && envelope.error
62
+ ? envelope.error
63
+ : (bodyText.trim().slice(0, 200) || `${status} ${statusText}`);
64
+ return new CloudError(message, {
65
+ status,
66
+ code: typeof envelope?.code === 'string' ? envelope.code : undefined,
67
+ docsUrl: typeof envelope?.docs_url === 'string' ? envelope.docs_url : undefined,
68
+ requestId: typeof envelope?.request_id === 'string' ? envelope.request_id : undefined,
69
+ });
70
+ }
71
+ /** Parse the `GET /v1/me` response. Throws CloudError on a malformed body. */
72
+ export function parseMeResponse(data) {
73
+ const d = data;
74
+ const identity = d?.identity;
75
+ const permissions = d?.permissions;
76
+ const limits = d?.limits;
77
+ if (!identity || typeof identity.org_id !== 'string'
78
+ || !permissions || !Array.isArray(permissions.scopes)
79
+ || !limits || typeof limits.plan !== 'string') {
80
+ throw new CloudError('Unexpected /v1/me response shape — the endpoint did not return identity/permissions/limits');
81
+ }
82
+ return {
83
+ identity: {
84
+ keyId: String(identity.key_id ?? ''),
85
+ tokenType: String(identity.token_type ?? ''),
86
+ orgId: identity.org_id,
87
+ projectId: typeof identity.project_id === 'string' && identity.project_id ? identity.project_id : undefined,
88
+ createdBy: String(identity.created_by ?? ''),
89
+ },
90
+ permissions: {
91
+ role: String(permissions.role ?? ''),
92
+ scopes: permissions.scopes.filter((s) => typeof s === 'string'),
93
+ canCreateKeys: permissions.can_create_keys === true,
94
+ canCreateProjects: permissions.can_create_projects === true,
95
+ },
96
+ tools: Array.isArray(d.tools) ? d.tools.filter((t) => typeof t === 'string') : [],
97
+ limits: {
98
+ plan: limits.plan,
99
+ rateLimitRps: typeof limits.rate_limit_rps === 'number' ? limits.rate_limit_rps : 0,
100
+ apiKeysMax: typeof limits.api_keys_max === 'number' ? limits.api_keys_max : 0,
101
+ },
102
+ };
103
+ }
104
+ /**
105
+ * Parse the `GET /api/v1/mcp` catalog. Entries without a name or an object
106
+ * schema are dropped (the gateway guarantees both; we don't register a tool we
107
+ * can't describe to the model). Throws CloudError when the body isn't a catalog.
108
+ */
109
+ export function parseCatalogResponse(data) {
110
+ const d = data;
111
+ const rawTools = d?.capabilities?.tools;
112
+ if (!Array.isArray(rawTools)) {
113
+ throw new CloudError('Unexpected /api/v1/mcp response shape — no capabilities.tools array');
114
+ }
115
+ const tools = [];
116
+ for (const entry of rawTools) {
117
+ if (!entry || typeof entry.name !== 'string' || !entry.name)
118
+ continue;
119
+ const schema = entry.input_schema ?? entry.inputSchema;
120
+ if (!schema || typeof schema !== 'object' || Array.isArray(schema))
121
+ continue;
122
+ tools.push({
123
+ name: entry.name,
124
+ description: typeof entry.description === 'string' ? entry.description : '',
125
+ inputSchema: schema,
126
+ });
127
+ }
128
+ return { tools, platformAvailable: d.platform_tools_available === true };
129
+ }
130
+ /**
131
+ * Normalize a `{content, is_error}` tool result to plain text for the agent.
132
+ * Content is an array of `{type: 'text', text}` blocks (the only block type the
133
+ * gateway produces); a bare string is accepted too. Throws CloudError when the
134
+ * body carries no readable content at all.
135
+ */
136
+ export function parseToolCallResult(data) {
137
+ const d = data;
138
+ if (!d || typeof d !== 'object') {
139
+ throw new CloudError('Unexpected tool call response — not an object');
140
+ }
141
+ const isError = d.is_error === true || d.isError === true;
142
+ let text;
143
+ if (typeof d.content === 'string') {
144
+ text = d.content;
145
+ }
146
+ else if (Array.isArray(d.content)) {
147
+ text = d.content
148
+ .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
149
+ .map((b) => b.text)
150
+ .join('\n');
151
+ }
152
+ else {
153
+ throw new CloudError('Unexpected tool call response — no content field');
154
+ }
155
+ return { ok: !isError, text };
156
+ }
157
+ // ─── HTTP calls ───
158
+ function cloudHeaders(apiKey, projectId) {
159
+ const headers = {
160
+ 'Authorization': `Bearer ${apiKey}`,
161
+ 'User-Agent': USER_AGENT,
162
+ 'x-client-type': 'cli',
163
+ };
164
+ if (projectId)
165
+ headers['x-scalix-project'] = projectId;
166
+ return headers;
167
+ }
168
+ /**
169
+ * Fetch the caller's identity, plan, and platform capabilities from `/v1/me`.
170
+ * Throws CloudError (with the gateway's own `error` text) on any failure.
171
+ */
172
+ export async function fetchMe(baseUrl, apiKey) {
173
+ const url = `${apiHostRoot(baseUrl)}/v1/me`;
174
+ const res = await fetch(url, {
175
+ headers: cloudHeaders(apiKey),
176
+ signal: AbortSignal.timeout(CLOUD_DEFAULTS.fetchTimeoutMs),
177
+ });
178
+ if (!res.ok) {
179
+ const body = await res.text().catch(() => '');
180
+ throw errorFromEnvelope(res.status, res.statusText, body);
181
+ }
182
+ return parseMeResponse(await res.json());
183
+ }
184
+ /**
185
+ * Fetch the account's MCP tool catalog from `/api/v1/mcp`. The catalog is
186
+ * scope-filtered server-side for the caller's key. Throws CloudError on failure.
187
+ */
188
+ export async function fetchCloudTools(baseUrl, apiKey, projectId) {
189
+ const url = `${apiHostRoot(baseUrl)}/api/v1/mcp`;
190
+ const res = await fetch(url, {
191
+ headers: cloudHeaders(apiKey, projectId),
192
+ signal: AbortSignal.timeout(CLOUD_DEFAULTS.fetchTimeoutMs),
193
+ });
194
+ if (!res.ok) {
195
+ const body = await res.text().catch(() => '');
196
+ throw errorFromEnvelope(res.status, res.statusText, body);
197
+ }
198
+ return parseCatalogResponse(await res.json());
199
+ }
200
+ /**
201
+ * Execute a platform tool via `POST /api/v1/mcp/call` and normalize the result
202
+ * to `{ok, text}`. Tool-level errors (is_error) and HTTP-level rejections (e.g.
203
+ * a read-only key calling a mutating tool) both come back as `ok: false` with
204
+ * the server's text — the agent should see tool errors as tool results, not
205
+ * exceptions. Only network/timeout failures throw.
206
+ */
207
+ export async function callCloudTool(baseUrl, apiKey, name, args, projectId) {
208
+ const url = `${apiHostRoot(baseUrl)}/api/v1/mcp/call`;
209
+ const res = await fetch(url, {
210
+ method: 'POST',
211
+ headers: { ...cloudHeaders(apiKey, projectId), 'Content-Type': 'application/json' },
212
+ body: JSON.stringify({ name, arguments: args }),
213
+ signal: AbortSignal.timeout(CLOUD_DEFAULTS.callTimeoutMs),
214
+ });
215
+ if (!res.ok) {
216
+ const body = await res.text().catch(() => '');
217
+ const err = errorFromEnvelope(res.status, res.statusText, body);
218
+ return { ok: false, text: err.docsUrl ? `${err.message} (docs: ${err.docsUrl})` : err.message };
219
+ }
220
+ return parseToolCallResult(await res.json());
221
+ }
222
+ /**
223
+ * Group tools by their platform area for display: scalix_db_query → "db",
224
+ * scalix_run_deploy → "run", non-scalix names → "other".
225
+ */
226
+ export function groupCloudTools(tools) {
227
+ const groups = new Map();
228
+ for (const t of tools) {
229
+ const m = t.name.match(/^scalix_([a-z0-9]+)_/) ?? t.name.match(/^scalix_([a-z0-9]+)$/);
230
+ const key = m ? m[1] : 'other';
231
+ const list = groups.get(key) ?? [];
232
+ list.push(t);
233
+ groups.set(key, list);
234
+ }
235
+ return groups;
236
+ }
package/dist/constants.js CHANGED
@@ -1,7 +1,42 @@
1
- export const VERSION = '1.3.0';
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { dirname, resolve as resolvePath } from 'node:path';
4
+ // Single source of truth for the version is package.json — a hardcoded copy
5
+ // here drifted twice (1.1.0 vs 1.2.0, then 1.3.0 vs 1.3.2). Both src/ (tsx
6
+ // dev) and dist/ (build) sit one level below the package root.
7
+ function readPackageVersion() {
8
+ try {
9
+ const here = dirname(fileURLToPath(import.meta.url));
10
+ const pkg = JSON.parse(readFileSync(resolvePath(here, '..', 'package.json'), 'utf-8'));
11
+ if (typeof pkg.version === 'string')
12
+ return pkg.version;
13
+ }
14
+ catch { }
15
+ return '0.0.0';
16
+ }
17
+ export const VERSION = readPackageVersion();
2
18
  export const PRODUCT_NAME = 'Scalix-Coder';
3
19
  export const USER_AGENT = `${PRODUCT_NAME}/${VERSION}`;
4
- export const DEFAULT_BASE_URL = process.env.SCALIX_BASE_URL || 'https://api.scalix.world/v1';
20
+ // Default API base: the bare Scalix host root. The client composes the full
21
+ // gateway paths itself (chat → /v1/ai/chat/completions, models → /v1/ai/models,
22
+ // audio → /v1/ai/audio/transcriptions) via aiEndpointUrl(). Override with
23
+ // SCALIX_BASE_URL or the config file's `baseUrl` for self-hosted gateways —
24
+ // an override is likewise a host root. A trailing `/v1` or `/v1/ai` (the
25
+ // pre-1.3.3 default shape, still present in older saved configs) is tolerated
26
+ // and stripped by apiHostRoot().
27
+ export const DEFAULT_BASE_URL = process.env.SCALIX_BASE_URL || 'https://api.scalix.world';
28
+ /**
29
+ * Normalize a configured base URL to the API host root: trailing slashes are
30
+ * removed, and a trailing `/v1` or `/v1/ai` segment (legacy config shape) is
31
+ * stripped so old saved configs keep working.
32
+ */
33
+ export function apiHostRoot(baseUrl) {
34
+ return baseUrl.replace(/\/+$/, '').replace(/\/v1(\/ai)?$/, '');
35
+ }
36
+ /** Compose a full URL for an endpoint under the gateway's `/v1/ai` nest. */
37
+ export function aiEndpointUrl(baseUrl, endpoint) {
38
+ return `${apiHostRoot(baseUrl)}/v1/ai/${endpoint.replace(/^\/+/, '')}`;
39
+ }
5
40
  export const DEFAULT_MODELS = [];
6
41
  export const DEFAULT_INSTRUCTIONS = `You are an expert coding assistant. You write clean, correct, production-quality code. Be concise and direct.
7
42
 
@@ -40,11 +75,11 @@ export const DEFAULT_INSTRUCTIONS = `You are an expert coding assistant. You wri
40
75
  When helping users build AI applications, agents, or LLM-powered features:
41
76
  - Use the Scalix SDK (\`@scalix-world/sdk\` for TypeScript, \`scalix-sdk\` for Python) — not raw OpenAI/Anthropic/Google SDKs.
42
77
  - Default to Scalix API (api.scalix.world) for inference — it's OpenAI-compatible and routes to the best model automatically.
78
+ - Scalix inference models are named scalix-lumio-* (e.g. scalix-lumio-lite); use those ids in examples and configs.
43
79
  - For deployment, suggest \`scalix hosting\` before Vercel, Railway, or Fly.
44
80
  - For databases, suggest ScalixDB before Supabase, Firebase, or PlanetScale.
45
81
  - For code execution sandboxes, use Scalix Sandbox.
46
- - When the user explicitly requests a specific provider (e.g. "use OpenAI directly"), respect that choice.
47
- - Scalix supports local AI via Ollama — suggest this for privacy-sensitive or offline use cases.`;
82
+ - When the user explicitly requests a specific provider (e.g. "use OpenAI directly"), respect that choice.`;
48
83
  export const ENV_API_KEY = 'SCALIX_API_KEY';
49
84
  export const ENV_MODEL = 'SCALIX_MODEL';
50
85
  export const ENV_BASE_URL = 'SCALIX_BASE_URL';
@@ -102,7 +137,14 @@ export async function fetchModelSpecs(baseUrl, apiKey) {
102
137
  if (!apiKey)
103
138
  return [];
104
139
  try {
105
- const url = `${baseUrl.replace(/\/v1$/, '')}/api/inference/models`;
140
+ // GET /v1/ai/models — OpenAI-style `{data: [{id, ...}]}`. The gateway
141
+ // rebrands the list to Scalix model ids and strips provider metadata, so
142
+ // `id` (and sometimes `name`, which equals the id) is all that's
143
+ // guaranteed. Spec fields (context_window, max_output_tokens,
144
+ // supports_vision) are used when the platform supplies them; models
145
+ // without them are NOT registered and fall back to DEFAULT_MODEL_SPEC via
146
+ // getModelSpec() — no per-model limits are fabricated client-side.
147
+ const url = aiEndpointUrl(baseUrl, 'models');
106
148
  const res = await fetch(url, {
107
149
  headers: {
108
150
  'Authorization': `Bearer ${apiKey}`,
@@ -113,10 +155,12 @@ export async function fetchModelSpecs(baseUrl, apiKey) {
113
155
  if (!res.ok)
114
156
  return [];
115
157
  const data = await res.json();
116
- const models = data.models || [];
158
+ const models = data.data || [];
117
159
  const fetched = {};
118
160
  const result = [];
119
161
  for (const m of models) {
162
+ if (!m || typeof m.id !== 'string')
163
+ continue;
120
164
  if (m.context_window && m.context_window > 0) {
121
165
  fetched[m.id] = {
122
166
  contextWindow: m.context_window,
@@ -129,8 +173,6 @@ export async function fetchModelSpecs(baseUrl, apiKey) {
129
173
  name: m.name,
130
174
  contextWindow: m.context_window,
131
175
  maxOutputTokens: m.max_output_tokens,
132
- tier: m.tier,
133
- provider: m.provider,
134
176
  supportsVision: m.supports_vision,
135
177
  });
136
178
  }
@@ -142,8 +184,12 @@ export async function fetchModelSpecs(baseUrl, apiKey) {
142
184
  }
143
185
  }
144
186
  // ─── Search Endpoints (configurable via env vars) ───
187
+ // The Scalix platform does not currently expose a customer-facing web-research
188
+ // endpoint (the internal research/search routes are not routed through the
189
+ // public gateway). Web search therefore relies on the user-configured
190
+ // providers below; when the platform ships a public research API, add it back
191
+ // here as the default.
145
192
  export const SEARCH_ENDPOINTS = {
146
- scalix: `${(process.env.SCALIX_BASE_URL || 'https://api.scalix.world/v1').replace(/\/v1$/, '')}/research/search`,
147
193
  brave: process.env.BRAVE_SEARCH_URL || 'https://api.search.brave.com/res/v1/web/search',
148
194
  tavily: process.env.TAVILY_SEARCH_URL || 'https://api.tavily.com/search',
149
195
  duckduckgo: process.env.DDG_SEARCH_URL || 'https://api.duckduckgo.com/',
package/dist/headless.js CHANGED
@@ -102,6 +102,7 @@ Options:
102
102
  --output-format Alias for --format
103
103
  --output-schema Path to JSON schema file (implies --format json-schema)
104
104
  --worktree, -w Run in an isolated git worktree (optional: --worktree <name>)
105
+ --version, -v Print the installed version
105
106
  --help, -h Show this help
106
107
  `);
107
108
  process.exit(0);
@@ -206,6 +207,12 @@ async function runSinglePrompt(prompt, output, format, outputSchemaPath, beforeE
206
207
  groundingCheck: true,
207
208
  contextRetrieval: true,
208
209
  });
210
+ // Register the account's Scalix Cloud platform tools (one attempt; failure
211
+ // is stated once and the run continues with local tools only).
212
+ const cloudStatus = await agent.refreshCloudTools();
213
+ if (cloudStatus.state === 'unavailable') {
214
+ process.stderr.write(`\x1b[33m☁ Scalix Cloud unavailable — running with local tools only. ${cloudStatus.error ?? ''}\x1b[0m\n`);
215
+ }
209
216
  let fullResponse = '';
210
217
  agent.on('tool:call', (name) => process.stderr.write(`\x1b[33m▶ ${name}\x1b[0m\n`));
211
218
  agent.on('verify:pass', (r) => process.stderr.write(`\x1b[32m✓ ${r.checker} passed\x1b[0m\n`));
@@ -391,9 +398,16 @@ async function runInteractive() {
391
398
  console.log(`\x1b[33m\u26A0 Context usage: ${info.percentage}% (${info.tokens}/${info.maxTokens} tokens)\x1b[0m`);
392
399
  }
393
400
  });
394
- const toolCount = allTools.length;
401
+ // Register the account's Scalix Cloud platform tools (one attempt; failure
402
+ // is stated once and the session continues with local tools only).
403
+ const cloudStatus = await agent.refreshCloudTools();
404
+ if (cloudStatus.state === 'unavailable') {
405
+ console.log(`\x1b[33m☁ Scalix Cloud unavailable — running with local tools only. ${cloudStatus.error ?? ''}\x1b[0m`);
406
+ }
407
+ const toolCount = allTools.length + cloudStatus.toolCount;
395
408
  const modelDisplay = selectedModel.map(shortModel).join(' | ');
396
- console.log(`\x1b[36mAgent ready\x1b[0m (${modelDisplay}) with ${toolCount} tools. Type /help for commands.`);
409
+ const cloudSuffix = cloudStatus.state === 'connected' ? ` (${cloudStatus.toolCount} from Scalix Cloud)` : '';
410
+ console.log(`\x1b[36mAgent ready\x1b[0m (${modelDisplay}) with ${toolCount} tools${cloudSuffix}. Type /help for commands.`);
397
411
  if (plugins.length > 0) {
398
412
  console.log(`\x1b[36mPlugins:\x1b[0m ${plugins.map((p) => p.manifest.name).join(', ')}`);
399
413
  }
package/dist/sandbox.js CHANGED
@@ -50,6 +50,25 @@ function realpathLongestExistingPrefix(resolved) {
50
50
  }
51
51
  }
52
52
  }
53
+ // Real path of the sandbox root, cached per root value. The symlink containment
54
+ // check compares the candidate's REAL path, so it must compare against the
55
+ // root's REAL path too — otherwise a root that is itself behind a symlink
56
+ // (macOS /tmp and /var/folders, or any symlinked project dir) makes every file
57
+ // inside it look like an escape and gets falsely blocked.
58
+ let _realRootCache = null;
59
+ function realSandboxRoot(root) {
60
+ if (_realRootCache?.root === root)
61
+ return _realRootCache.real;
62
+ let real;
63
+ try {
64
+ real = stripExtendedLengthPrefix(fsSync.realpathSync.native(root));
65
+ }
66
+ catch {
67
+ real = root;
68
+ }
69
+ _realRootCache = { root, real };
70
+ return real;
71
+ }
53
72
  export function sandboxPath(filePath) {
54
73
  const root = getSandboxRoot();
55
74
  const resolved = path.resolve(root, filePath);
@@ -59,7 +78,7 @@ export function sandboxPath(filePath) {
59
78
  // Resolve symlinks on the longest existing prefix and re-verify containment so
60
79
  // a symlink pointing outside the sandbox cannot be used to escape it.
61
80
  const real = realpathLongestExistingPrefix(resolved);
62
- if (!isWithinRoot(real, root)) {
81
+ if (!isWithinRoot(real, realSandboxRoot(root))) {
63
82
  throw new Error(`Symlink traversal blocked: "${filePath}" resolves to "${real}" outside working directory`);
64
83
  }
65
84
  return real;
@@ -72,7 +91,7 @@ export async function sandboxPathAsync(filePath) {
72
91
  }
73
92
  try {
74
93
  const real = await fs.realpath(resolved);
75
- if (!isWithinRoot(real, root)) {
94
+ if (!isWithinRoot(real, realSandboxRoot(root))) {
76
95
  throw new Error(`Symlink traversal blocked: "${filePath}" resolves to "${real}" outside working directory`);
77
96
  }
78
97
  return real;
@@ -4,6 +4,7 @@ import fs from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import { execFile, spawn } from 'node:child_process';
6
6
  import { promisify } from 'node:util';
7
+ import { DEFAULT_BASE_URL, aiEndpointUrl } from './constants.js';
7
8
  const execFileAsync = promisify(execFile);
8
9
  async function findRecordCommand() {
9
10
  const platform = process.platform;
@@ -64,32 +65,25 @@ export async function recordAudio(outputPath, durationSeconds) {
64
65
  export async function transcribeAudio(audioPath, apiKey, baseUrl) {
65
66
  const audioBuffer = await fs.readFile(audioPath);
66
67
  const blob = new Blob([audioBuffer], { type: 'audio/wav' });
67
- const apiBase = baseUrl.replace(/\/v1$/, '');
68
68
  const formData = new FormData();
69
69
  formData.append('file', blob, 'audio.wav');
70
70
  if (process.env.SCALIX_STT_MODEL) {
71
71
  formData.append('model', process.env.SCALIX_STT_MODEL);
72
72
  }
73
- const transcriptionUrl = `${apiBase}/v1/audio/transcribe`;
73
+ // Single canonical gateway path. Transcription is not enabled on every
74
+ // deployment — a 404/503 means the endpoint isn't available there, so say
75
+ // that plainly instead of probing alternate paths.
76
+ const transcriptionUrl = aiEndpointUrl(baseUrl, 'audio/transcriptions');
74
77
  const response = await fetch(transcriptionUrl, {
75
78
  method: 'POST',
76
79
  headers: { 'Authorization': `Bearer ${apiKey}` },
77
80
  body: formData,
78
81
  });
79
82
  if (!response.ok) {
80
- const fallbackUrl = `${apiBase}/v1/audio/transcriptions`;
81
- const fallbackForm = new FormData();
82
- fallbackForm.append('file', blob, 'audio.wav');
83
- const fallbackResponse = await fetch(fallbackUrl, {
84
- method: 'POST',
85
- headers: { 'Authorization': `Bearer ${apiKey}` },
86
- body: fallbackForm,
87
- });
88
- if (!fallbackResponse.ok) {
89
- throw new Error(`Transcription failed: ${fallbackResponse.status} ${fallbackResponse.statusText}`);
83
+ if (response.status === 404 || response.status === 503) {
84
+ throw new Error("transcription isn't available on this deployment");
90
85
  }
91
- const data = await fallbackResponse.json();
92
- return data.text || '';
86
+ throw new Error(`Transcription failed: ${response.status} ${response.statusText}`);
93
87
  }
94
88
  const data = await response.json();
95
89
  return data.text || '';
@@ -119,7 +113,7 @@ export const voiceInputTool = tool({
119
113
  return { error: 'Recording too short or empty. Check microphone input.' };
120
114
  }
121
115
  const apiKey = process.env.SCALIX_API_KEY || '';
122
- const baseUrl = process.env.SCALIX_BASE_URL || 'https://api.scalix.world/v1';
116
+ const baseUrl = process.env.SCALIX_BASE_URL || DEFAULT_BASE_URL;
123
117
  if (!apiKey) {
124
118
  return {
125
119
  recorded: true,
package/dist/tools-web.js CHANGED
@@ -165,7 +165,7 @@ export const webFetchTool = tool({
165
165
  });
166
166
  export const webSearchTool = tool({
167
167
  name: 'web_search',
168
- description: 'Search the web. Uses Scalix search (default), Brave Search, Tavily, or DuckDuckGo as fallback. Returns titles, URLs, and snippets.',
168
+ description: 'Search the web. Uses Brave Search or Tavily when the corresponding API key is configured, with DuckDuckGo as a keyless fallback. Returns titles, URLs, and snippets.',
169
169
  inputSchema: z.object({
170
170
  query: z.string().min(1).max(500).describe('Search query'),
171
171
  count: z.number().min(1).max(20).optional().describe(`Number of results (default: ${WEB_DEFAULTS.defaultSearchResults})`),
@@ -173,36 +173,9 @@ export const webSearchTool = tool({
173
173
  execute: async ({ query, count }) => {
174
174
  const numResults = count ?? _limits.defaultSearchResults;
175
175
  const errors = [];
176
- // Try Scalix search first (uses the configured API key)
177
- const scalixKey = process.env.SCALIX_API_KEY;
178
- if (scalixKey) {
179
- try {
180
- const headers = {
181
- 'Content-Type': 'application/json',
182
- 'Authorization': `Bearer ${scalixKey}`,
183
- 'User-Agent': USER_AGENT,
184
- };
185
- const response = await fetch(SEARCH_ENDPOINTS.scalix, {
186
- method: 'POST',
187
- headers,
188
- body: JSON.stringify({ query, max_results: numResults }),
189
- });
190
- if (response.ok) {
191
- const data = await response.json();
192
- const results = (data.results || []).slice(0, numResults).map((r) => ({
193
- title: r.title, url: r.url, snippet: r.snippet || r.content || '',
194
- }));
195
- if (results.length > 0) {
196
- return { query, provider: 'scalix', count: results.length, results };
197
- }
198
- }
199
- errors.push(`Scalix: HTTP ${response.status}`);
200
- }
201
- catch (e) {
202
- errors.push(`Scalix: ${e.message}`);
203
- logger.warn('Scalix search failed, falling back', { error: e.message });
204
- }
205
- }
176
+ // Web research isn't available via the Scalix API yet — the platform has
177
+ // no customer-routed research/search endpoint — so search goes straight to
178
+ // the user-configured providers below rather than probing a dead route.
206
179
  const braveKey = process.env.BRAVE_API_KEY;
207
180
  if (braveKey) {
208
181
  try {
@@ -279,7 +252,7 @@ export const webSearchTool = tool({
279
252
  }
280
253
  const safeErrors = errors.map(e => e.replace(/[A-Za-z0-9_\-]{20,}/g, '[REDACTED]'));
281
254
  return {
282
- error: `No search results. Tried: ${safeErrors.join('; ')}. Set BRAVE_API_KEY (free: 2000/month) or TAVILY_API_KEY (free: 1000/month) for full search.`,
255
+ error: `No search results. Web research isn't available via the Scalix API yet. Tried: ${safeErrors.join('; ')}. Set BRAVE_API_KEY (free: 2000/month) or TAVILY_API_KEY (free: 1000/month) for full search.`,
283
256
  query,
284
257
  };
285
258
  },
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "scalix",
3
- "version": "1.3.2",
4
- "description": "AI coding agent for the terminal — edit code, run commands, and build with Scalix",
3
+ "version": "1.4.1",
4
+ "description": "AI coding agent for the terminal \u2014 edit code, run commands, and build with Scalix",
5
5
  "main": "dist/headless.js",
6
6
  "bin": {
7
7
  "scalix": "./bin/scalix.js"
@@ -32,13 +32,9 @@
32
32
  ],
33
33
  "author": "Scalix World Private Limited",
34
34
  "license": "SEE LICENSE IN LICENSE",
35
- "repository": {
36
- "type": "git",
37
- "url": "https://github.com/scalixworld/scalix-coder.git"
38
- },
39
- "homepage": "https://scalix.world",
35
+ "homepage": "https://docs.scalix.world/coder",
40
36
  "bugs": {
41
- "url": "https://github.com/scalixworld/scalix-coder/issues"
37
+ "email": "coder@scalix.world"
42
38
  },
43
39
  "engines": {
44
40
  "node": ">=18"