@descix/cli 2.0.4 → 3.0.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.
@@ -303,18 +303,17 @@ User asks question
303
303
  When `--scope project` is used:
304
304
 
305
305
  1. CLI/MCP loads `workspace.json`
306
- 2. Extracts configured communities and apps
307
- 3. Filters `tell_me_how` results to only tools applicable to those apps
306
+ 2. Extracts the app this repo holds (`env.app`)
307
+ 3. Filters `tell_me_how` results to only tools applicable to that app
308
308
  4. Returns focused recommendations
309
309
 
310
310
  ```javascript
311
- // workspace.json (v2.1)
311
+ // workspace.json (v3 single-app)
312
312
  {
313
+ "version": "3.0",
313
314
  "env": {
314
- "apiUrl": "https://dev.descix.net",
315
- "products": [
316
- { "appId": "daita-agent", "communityId": "daita", "localPath": ".", "kbId": "General" }
317
- ]
315
+ "apiUrl": "https://descix.net",
316
+ "app": { "appId": "daita-agent", "communityId": "daita", "kbId": "General" }
318
317
  }
319
318
  }
320
319
 
@@ -338,11 +337,11 @@ descix tell-me-how "How do I sync my knowledge base?"
338
337
 
339
338
  ### 6.2 workspace.json from Hydration
340
339
 
341
- `descix config init --env …` creates `workspace.json`; `descix app init` adds the app's `env.products[]` entry; no verb writes `driveConfig.base_folder_id` — set it in `.descix/workspace.json` when you use `descix drive pull/push`:
340
+ `descix config init --env …` creates `workspace.json`; `descix app init` (from the repo root) writes the app's `env.app`; no verb writes `driveConfig.base_folder_id` — set it in `.descix/workspace.json` when you use `descix drive pull/push`:
342
341
 
343
342
  ```javascript
344
343
  {
345
- "env": { "apiUrl": "https://dev.descix.net", "products": [ /* one entry per app */ ] },
344
+ "env": { "apiUrl": "https://descix.net", "app": { /* THE app this repo holds */ } },
346
345
  "driveConfig": {
347
346
  "base_folder_id": "..." // Only used by `descix drive pull/push` — unrelated to tell_me_how scope
348
347
  }
@@ -1,33 +1,44 @@
1
1
  # Workspace Configuration Guide
2
2
 
3
- `.descix/workspace.json` is the CLI's only local configuration file. It is **version 2.1**, it is
4
- written **only by CLI verbs**, and the loader refuses the shapes that hand edits produce. This page
5
- is the schema and the verb that owns each key.
3
+ `.descix/workspace.json` is the CLI's only local configuration file. It is **version 3.0
4
+ (single-app)**: one repo, one app, and the repo root IS the workspace root. It is written **only by
5
+ CLI verbs**, and the loader refuses the shapes that hand edits produce. This page is the schema and
6
+ the verb that owns each key.
6
7
 
7
8
  ---
8
9
 
9
- ## 1. Format (v2.1 — the only format)
10
+ ## 1. Format (v3 — the only format)
11
+
12
+ An app repo's workspace holds exactly one app, `env.app`:
10
13
 
11
14
  ```json
12
15
  {
13
- "version": "2.1",
16
+ "version": "3.0",
14
17
  "type": "workspace",
15
- "workspaceRoot": "/path/to/workspace",
18
+ "workspaceRoot": "/path/to/my-app-repo",
16
19
  "env": {
17
- "environment": "DEV",
18
- "apiUrl": "https://dev.descix.net",
20
+ "environment": "PROD",
21
+ "apiUrl": "https://descix.net",
19
22
  "gateway": { "port": 5599 },
20
23
  "devCerts": { "dir": "/Users/you/.descix/dev-certs-san" },
21
- "powchUrl": "https://powch.dev.descix.net",
22
- "products": [
23
- { "appId": "egpt-mydocs", "communityId": "egpt", "localPath": ".", "kbId": "General", "site": { "static": "site" } },
24
- { "appId": "egpt-tool", "communityId": "egpt", "localPath": "tool", "kbId": "General", "site": { "port": 5174 }, "microservice": { "port": 4001 } }
25
- ]
24
+ "powchUrl": "https://powch.descix.net",
25
+ "app": {
26
+ "appId": "egpt-mydocs",
27
+ "communityId": "egpt",
28
+ "kbId": "General",
29
+ "site": { "static": "site" },
30
+ "microservice": { "port": 4001 }
31
+ }
26
32
  },
27
33
  "driveConfig": { "base_folder_id": "1ABC..." }
28
34
  }
29
35
  ```
30
36
 
37
+ Run `descix app init` **from the repo root** — it writes `env.app` and makes that directory the
38
+ workspace root. Inside another DeSciX workspace it refuses (`This directory is nested inside another
39
+ DeSciX workspace`) rather than registering into the parent: keep app repos as sibling checkouts. A
40
+ workspace that already holds a different app refuses a second one — that app gets its own repo.
41
+
31
42
  | Key | Meaning | Owned by |
32
43
  |---|---|---|
33
44
  | `env.environment` | `DEV`, `DEMO` or `PROD` | `descix config init --env dev\|demo\|prod` |
@@ -36,29 +47,53 @@ is the schema and the verb that owns each key.
36
47
  | `env.devCerts` | `dir`, or `cert` + `key` — the TLS pair for the gateway and every app behind it; unset means the SDK's shipped pair. Passkey sign-in needs it trusted: `descix dev-certs check`, then the human runs `descix dev-certs trust` (see `reference/local-dev.md` §3.3) | `descix config set-dev-certs --dir\|--cert\|--key\|--clear` |
37
48
  | `env.powchUrl` | Powch's own origin (optional; Powch is cross-origin from the shell by design) | `descix config set-powch-url <url>` |
38
49
  | `env.siteUrl` | the App Shell origin the gateway proxies `/` to (optional; default is the API origin) | `descix config set-site-url <url>` |
39
- | `env.products[]` | one entry per app in this workspace, see below | `descix app init`, `app set-site`, `app set-port`, `app set-localpath`, `app unmap` |
40
- | `env.platform` | the platform shell's own entry — platform contributors only; an app developer has none | platform runbook |
50
+ | `env.app` | THE app this repo holds, see below | `descix app init`, `app set-site`, `app set-port` |
41
51
  | `driveConfig.base_folder_id` | the Drive base folder for `descix drive pull/push` | no verb; set it in `.descix/workspace.json` |
42
52
 
43
- A product entry:
53
+ `env.app`:
44
54
 
45
55
  | Field | Meaning | Written by |
46
56
  |---|---|---|
47
57
  | `appId` | the platform app id (`<community>-<short>`) | `descix app init` |
48
58
  | `communityId` | the app's community; stored so context detection can name it | `descix app init` |
49
- | `localPath` | the app directory, relative to `workspaceRoot` (`.` when the workspace root is the app) | `descix app init -p`, `descix app set-localpath` |
50
59
  | `kbId` | the default knowledge base (default `General`) | `descix app init --kb` |
51
- | `site.static` | a directory under `localPath` served from disk at `/p/<appId>/` | `descix app set-site --static <dir>` |
60
+ | `site.static` | a directory relative to the workspace root (the repo root) served from disk at `/p/<appId>/` — `site`, `site/dist`, `.` (the root itself) or `../scratch`; an absolute path is refused at the write and on read | `descix app set-site --static <dir>` |
52
61
  | `site.port` | a framework dev server the gateway proxies `/p/<appId>` to (no path rewrite) | `descix app set-site --port <n>` |
62
+ | `site.protocol` | `http` or `https` — the dev server's scheme (dev-server mode only; default `https`) | `descix app set-site --port <n> --protocol <scheme>` |
53
63
  | `microservice.port` | the local port the gateway proxies `/s/<appId>` to; `descix microservice init` requires it | `descix app set-port -p <n>` |
54
64
 
55
65
  Sites and services are started by you (or your framework); the CLI does not store or run a dev command.
56
66
 
67
+ ### Gateway workspaces
68
+
69
+ A workspace that serves several local app repos behind one `descix serve` (platform contributors)
70
+ lists them under `env.gateway`, with paths relative to its own root:
71
+
72
+ ```json
73
+ "gateway": { "port": 5599, "shell": "../platform-shell", "appRepos": ["../my-app", "../other-app"] }
74
+ ```
75
+
76
+ | Key | Meaning | Verb |
77
+ |---|---|---|
78
+ | `env.gateway.shell` | the repo whose app the gateway serves at `/` | `descix config gateway set-shell <repo\|none>` |
79
+ | `env.gateway.appRepos[]` | repos whose apps the gateway serves at `/p/<appId>` | `descix config gateway add-repo <repo>`, `descix config gateway remove-repo <repo>` |
80
+
81
+ Run the verbs from the gateway workspace's root; `<repo>` is relative to that root. Each listed repo
82
+ holds its OWN v3 workspace (`descix app init` in that repo), and that file is the sole authority on
83
+ its app's id, site and microservice — the gateway workspace never restates them. The verbs refuse,
84
+ writing nothing: a run in a single-app workspace (one that holds `env.app`), an absolute path, a
85
+ repo with no `.descix/workspace.json` or no `env.app`, a repo already listed (or already the
86
+ shell), one appId listed twice, and `remove-repo` of a repo that is not listed. `descix serve`
87
+ applies the same checks when it reads the file.
88
+
57
89
  ### Refused shapes
58
90
 
59
91
  - A `communities` block with no `env` block — the v1 format — fails on load: `v1 workspace format is
60
- not supported. Migrate to v2.1.` The file is left untouched; re-create it with `descix config init
92
+ not supported. Migrate to v3.` The file is left untouched; re-create it with `descix config init
61
93
  --env …` and `descix app init`.
94
+ - An `env.platform` or `env.products` block — the multi-app format — fails on load: `v2.1 multi-app
95
+ workspace format is not supported. Migrate to v3 (single-app).` The file is left untouched; run
96
+ `descix config init --env …` and `descix app init -a <app_id>` from each app repo's own root.
62
97
  - A top-level `apiUrl` key is refused by name; the origin lives at `env.apiUrl` and nowhere else.
63
98
 
64
99
  ---
@@ -143,14 +178,13 @@ The git path (`descix kb corpus sync`) needs none of this.
143
178
 
144
179
  ## 6. CLI Context Resolution
145
180
 
146
- `WorkspaceConfig.detectContext()` matches your current directory against every product's resolved
147
- `localPath` (longest match wins) and returns `{ communityId, appId, kbId }` from that entry —
148
- `communityId` is `null` for an entry registered by a CLI that did not yet store it.
181
+ `WorkspaceConfig.detectContext()` returns `{ communityId, appId, kbId }` from `env.app` when your
182
+ current directory is the workspace root or anywhere below it.
149
183
  `resolveContextWithOptions()` lets explicit `-c` / `-a` / `-k` flags win over detection.
150
184
 
151
185
  ```bash
152
- # From inside an app directory — detected
153
- cd my-app
186
+ # From inside the app repo — detected
187
+ cd my-app-repo
154
188
  descix kb corpus sync
155
189
 
156
190
  # From anywhere — flags
@@ -164,7 +198,7 @@ descix site status -c egpt -a egpt-mydocs # site status/list need both when
164
198
 
165
199
  | Concept | Owner |
166
200
  |---|---|
167
- | Workspace loading, the v2.1 writer, app registration, context detection | `WorkspaceConfig`, internal to `@descix/cli` |
201
+ | Workspace loading, the v3 writer, app registration, context detection | `WorkspaceConfig`, internal to `@descix/cli` |
168
202
  | Origin resolution precedence | flag → `DESCIX_API_URL` → `env.apiUrl` → default (`descix config` commands) |
169
203
  | User-level settings (`~/.descixrc`) | `GlobalConfig`, internal to `@descix/cli` |
170
204
  | `.descix/wallet.json` | written by `descix login` |
package/bin/descix.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * Combines CLI and MCP functionality with unified authentication.
8
8
  */
9
9
 
10
- import { Command } from 'commander';
10
+ import { Command, Option } from 'commander';
11
11
  import chalk from 'chalk';
12
12
  import { DeSciXApiClient } from '../lib/api-client.js';
13
13
  import { requireAuth } from '../lib/auth-guard.js';
@@ -1482,7 +1482,7 @@ appCommand
1482
1482
  appCommand
1483
1483
  .command('set-port')
1484
1484
  .description('Set the microservice port for this workspace\'s app (writes env.app.microservice.port). Pass "n" to remove.')
1485
- .requiredOption('-a, --app <app_id>', 'App ID (must be mapped in workspace.json)')
1485
+ .requiredOption('-a, --app <app_id>', 'App ID (must be this workspace\'s app, env.app)')
1486
1486
  .requiredOption('-p, --port <port>', 'Microservice port number (1-65535), or "n" to remove')
1487
1487
  .action(async (options) => {
1488
1488
  try {
@@ -1503,7 +1503,7 @@ appCommand
1503
1503
  }
1504
1504
 
1505
1505
  // setMicroservicePort mutates the live env entry and saves — and hard-fails (canonical
1506
- // "not mapped in workspace.json" error) for an unmapped app, surfaced in the catch below.
1506
+ // "is not this workspace's app" error) for any other app id, surfaced in the catch below.
1507
1507
  await workspaceConfig.setMicroservicePort(appId, portNum);
1508
1508
 
1509
1509
  console.log(chalk.green(`\n✓ Microservice port set for ${appId}`));
@@ -1520,9 +1520,9 @@ appCommand
1520
1520
  //
1521
1521
  // Canonical write path for an app's site.{} slot in workspace.json. TWO MODES, one active
1522
1522
  // per write:
1523
- // static mode (--static): site.static — the relative dir (under the app's localPath;
1524
- // "." = the localPath itself) the gateway's staticSitePlugin
1525
- // serves at /p/{appId}/.
1523
+ // static mode (--static): site.static — the dir relative to the workspace root (the
1524
+ // repo root; "." = the root itself) the gateway's
1525
+ // staticSitePlugin serves at /p/{appId}/.
1526
1526
  // dev-server mode (--port): site.port [+ site.protocol] — the local dev server the
1527
1527
  // gateway proxies /p/{appId} to (vite-style hotserving).
1528
1528
  // Setting one mode CLEARS the other's fields; passing BOTH flags in one invocation is the
@@ -1533,8 +1533,8 @@ appCommand
1533
1533
  appCommand
1534
1534
  .command('set-site')
1535
1535
  .description("Set this workspace app's site config (env.app.site). One active mode per write: --static <dir> (built site served at /p/<app>/) or --port <n> [--protocol http|https] (dev server proxied at /p/<app>); setting one clears the other. Pass both flags together to keep both — the gateway then serves the dev server (site.port wins). --unset clears site.{} entirely.")
1536
- .requiredOption('-a, --app <app_id>', 'App ID (must be mapped in workspace.json)')
1537
- .option('--static <path>', 'Static mode: relative dir under the app localPath (e.g. "site"; "." = the localPath itself). Alone, it clears site.port and site.protocol (one active mode per write).')
1536
+ .requiredOption('-a, --app <app_id>', 'App ID (must be this workspace\'s app, env.app)')
1537
+ .option('--static <path>', 'Static mode: a dir relative to the workspace root (the repo root), e.g. "site" or "../scratch"; "." = the root itself. An absolute path is refused. Alone, it clears site.port and site.protocol (one active mode per write).')
1538
1538
  .option('--port <port>', 'Dev-server mode: proxy /p/<app> to your local dev server on this port (1-65535). Alone, it clears site.static. With --static in the same call, both are kept and the gateway serves the dev server.')
1539
1539
  .option('--protocol <scheme>', 'Dev-server upstream scheme: "http" or "https" (default https when unset). Dev-server mode only — requires site.port. A wrong scheme fails loud at the gateway (502); there is no auto-detection.')
1540
1540
  .option('--unset', 'Remove site.static, site.port and site.protocol for the app, clearing the site.{} slot')
@@ -1558,13 +1558,9 @@ appCommand
1558
1558
 
1559
1559
  const fields = {};
1560
1560
 
1561
- if (options.static !== undefined) {
1562
- if (typeof options.static !== 'string' || options.static.trim() === '') {
1563
- console.error(chalk.red('\n❌ --static requires a non-empty relative path (e.g. "site" or ".").\n'));
1564
- process.exit(1);
1565
- }
1566
- fields.static = options.static;
1567
- }
1561
+ // The --static path rule has ONE owner (assertSiteStatic, applied in setStaticSite);
1562
+ // a refusal surfaces through the catch below.
1563
+ if (options.static !== undefined) fields.static = options.static;
1568
1564
 
1569
1565
  if (options.port !== undefined) {
1570
1566
  const portNum = parseInt(options.port, 10);
@@ -1584,7 +1580,7 @@ appCommand
1584
1580
  }
1585
1581
 
1586
1582
  // setStaticSite applies ONE-ACTIVE-MODE-PER-WRITE and saves — it hard-fails (canonical
1587
- // "not mapped in workspace.json" error, or protocol-without-port) in the catch below.
1583
+ // "is not this workspace's app" error, or protocol-without-port) in the catch below.
1588
1584
  await workspaceConfig.setStaticSite(appId, fields);
1589
1585
 
1590
1586
  console.log(chalk.green(`\n✓ site config set for ${appId}`));
@@ -1622,7 +1618,7 @@ appCommand
1622
1618
  appCommand
1623
1619
  .command('open')
1624
1620
  .description('Resolve (and optionally open) an app\'s local gateway dev URL. Hard-fails if the app has no site config.')
1625
- .requiredOption('-a, --app <app_id>', 'App ID (must be mapped in workspace.json with a site)')
1621
+ .requiredOption('-a, --app <app_id>', 'App ID (an app this workspace serves, with a site)')
1626
1622
  .option('--open', 'Open the resolved URL in the default browser')
1627
1623
  .action(async (options) => {
1628
1624
  try {
@@ -1663,7 +1659,8 @@ appCommand
1663
1659
  // ============ App: set-default-model (WS-CONFIG-BOOTSTRAP-FIX item #10) ============
1664
1660
  //
1665
1661
  // Updates Community/{c}/Apps/{a}.default_app_model. Per CEO inheritance chain:
1666
- // options.model > kb.kb_model_override > app.default_app_model > levelConfig.model > DEFAULT_AI_MODEL
1662
+ // request intelligence_level > kb.kb_model_override > app.default_app_model > the level's model
1663
+ // (there is no separate default model: the default is the model of DEFAULT_INTELLIGENCE_LEVEL)
1667
1664
  //
1668
1665
  // --clear deletes the field via FieldValue.delete() (NOT null) so the resolver falls through
1669
1666
  // cleanly to per-level platform defaults.
@@ -1672,13 +1669,13 @@ appCommand
1672
1669
  .command('set-default-model')
1673
1670
  .description('Set or clear App.default_app_model (model used when no KB override is set)')
1674
1671
  .requiredOption('-a, --app <app_id>', 'App ID (e.g. unk-cos)')
1675
- .option('-m, --model <model_name>', 'Gemini model name (e.g. gemini-3.1-flash-lite). Mutually exclusive with --clear.')
1672
+ .option('-m, --model <model_name>', 'Model name (e.g. gemini-3.8-flash). Mutually exclusive with --clear.')
1676
1673
  .option('--clear', 'Delete App.default_app_model via FieldValue.delete() (resets to platform-default inheritance)')
1677
1674
  .addHelpText('after', `
1678
- NOTE: Pinning a "Pro" model (e.g., gemini-2.5-pro, gemini-3.1-pro-preview) as
1679
- an app or KB override will fail at L1 — these models require thinking mode and
1680
- reject L1's thinkingBudget=0. If you intend an L1-compatible override, use a
1681
- "Flash" model (gemini-2.5-flash, gemini-3.1-flash-lite, gemini-3-flash-preview).
1675
+ NOTE: A pinned model is paired with a thinking_level, and which levels exist is a
1676
+ property of the MODEL. The server refuses, by name, a (model, thinking_level) pair
1677
+ it does not support. The current intelligence levels (each names its own model and
1678
+ thinking_level) are printed under "Per-level" after this command runs.
1682
1679
  `)
1683
1680
  .action(async (options) => {
1684
1681
  try {
@@ -1820,12 +1817,12 @@ kbCommand
1820
1817
  .description('Set KB.kb_model_override (per-KB model selection)')
1821
1818
  .requiredOption('-a, --app <app_id>', 'App ID')
1822
1819
  .requiredOption('-k, --kb <kb_name>', 'KB name')
1823
- .requiredOption('-m, --model <model_name>', 'Gemini model name (e.g. gemini-2.5-pro)')
1820
+ .requiredOption('-m, --model <model_name>', 'Model name (e.g. gemini-3.8-flash)')
1824
1821
  .addHelpText('after', `
1825
- NOTE: Pinning a "Pro" model (e.g., gemini-2.5-pro, gemini-3.1-pro-preview) as
1826
- an app or KB override will fail at L1 — these models require thinking mode and
1827
- reject L1's thinkingBudget=0. If you intend an L1-compatible override, use a
1828
- "Flash" model (gemini-2.5-flash, gemini-3.1-flash-lite, gemini-3-flash-preview).
1822
+ NOTE: A pinned model is paired with a thinking_level, and which levels exist is a
1823
+ property of the MODEL. The server refuses, by name, a (model, thinking_level) pair
1824
+ it does not support. The current intelligence levels (each names its own model and
1825
+ thinking_level) are printed under "Per-level" after this command runs.
1829
1826
  `)
1830
1827
  .action(async (options) => {
1831
1828
  try {
@@ -3360,11 +3357,15 @@ program
3360
3357
  .option('--apps <ids>', 'Comma-separated app IDs for cross-app query')
3361
3358
  .option('--level <n>', 'Intelligence level (1-5)', parseInt)
3362
3359
  .option('--model <name>', 'Explicit model override')
3363
- .option('--thinking <budget>', 'Thinking token budget (-1=dynamic, 0=off, N=fixed)', parseInt)
3360
+ .option('--thinking-level <level>', 'Thinking level for this call: minimal, low, medium or high. The server refuses a level the resolved model does not support.')
3361
+ .addOption(new Option('--thinking <budget>').hideHelp())
3364
3362
  .option('--tokens', 'Show token usage in response footer')
3365
3363
  .option('--new', 'Start a new conversation (clear session)')
3366
3364
  .action(async (questionArgs, options) => {
3367
3365
  try {
3366
+ if (options.thinking !== undefined) {
3367
+ throw new Error('--thinking (a token budget) was removed. Use --thinking-level <level>, one of minimal, low, medium, high.');
3368
+ }
3368
3369
  const apiClient = new DeSciXApiClient();
3369
3370
  await requireAuth(apiClient);
3370
3371
 
@@ -3390,7 +3391,7 @@ program
3390
3391
  user_input: question,
3391
3392
  intelligence_level: options.level,
3392
3393
  model: options.model,
3393
- thinking_budget: options.thinking,
3394
+ thinking_level: options.thinkingLevel,
3394
3395
  streaming: false
3395
3396
  });
3396
3397
  const result = response.message || response;
@@ -3497,7 +3498,7 @@ program
3497
3498
  streaming: false,
3498
3499
  intelligence_level: options.level,
3499
3500
  model: options.model,
3500
- thinking_budget: options.thinking,
3501
+ thinking_level: options.thinkingLevel,
3501
3502
  };
3502
3503
 
3503
3504
  if (useMultiKb) {
@@ -3723,6 +3724,51 @@ configCommand
3723
3724
  }
3724
3725
  });
3725
3726
 
3727
+ // The gateway workspace's repo list (env.gateway.shell, env.gateway.appRepos[]). One writer:
3728
+ // WorkspaceConfig.setGatewayRepo(), which validates with the gateway's own reader.
3729
+ const configGatewayCommand = configCommand
3730
+ .command('gateway')
3731
+ .description('Write a gateway workspace\'s repo list (env.gateway.shell, env.gateway.appRepos[]) — never hand-edit it')
3732
+ .addHelpText('after', '\nRepo paths are relative to the gateway workspace root (sibling checkouts: ../<repo>).\n' +
3733
+ 'Each listed repo must carry its own v3 .descix/workspace.json with env.app (descix app init there).\n' +
3734
+ 'Refused in a single-app workspace (one that holds env.app).\n');
3735
+
3736
+ configGatewayCommand
3737
+ .command('set-shell')
3738
+ .description('Set the repo whose app the gateway serves at / (env.gateway.shell)')
3739
+ .argument('<repo>', 'Repo root relative to the workspace root, or "none" to remove')
3740
+ .action(async (repo) => {
3741
+ try {
3742
+ await configCommands.setGatewayRepo('set-shell', repo === 'none' ? null : repo);
3743
+ } catch (error) {
3744
+ fail(error);
3745
+ }
3746
+ });
3747
+
3748
+ configGatewayCommand
3749
+ .command('add-repo')
3750
+ .description('List a repo whose app the gateway serves at /p/<appId> (env.gateway.appRepos[])')
3751
+ .argument('<repo>', 'Repo root relative to the workspace root')
3752
+ .action(async (repo) => {
3753
+ try {
3754
+ await configCommands.setGatewayRepo('add-repo', repo);
3755
+ } catch (error) {
3756
+ fail(error);
3757
+ }
3758
+ });
3759
+
3760
+ configGatewayCommand
3761
+ .command('remove-repo')
3762
+ .description('Remove a listed repo from env.gateway.appRepos[]')
3763
+ .argument('<repo>', 'Repo root relative to the workspace root, as listed')
3764
+ .action(async (repo) => {
3765
+ try {
3766
+ await configCommands.setGatewayRepo('remove-repo', repo);
3767
+ } catch (error) {
3768
+ fail(error);
3769
+ }
3770
+ });
3771
+
3726
3772
  configCommand
3727
3773
  .command('set-dev-certs')
3728
3774
  .description('Point local dev servers at your own TLS cert pair (env.devCerts) — the gateway AND every app behind it')
@@ -308,3 +308,21 @@ export async function setSiteUrl(url) {
308
308
  'App Shell URL set — `descix serve` will proxy / here.',
309
309
  );
310
310
  }
311
+
312
+ /**
313
+ * `descix config gateway set-shell | add-repo | remove-repo` — the CLI face of the one writer,
314
+ * WorkspaceConfig.setGatewayRepo(). A refusal propagates to the bin's fail(), which prints it once.
315
+ *
316
+ * @param {'set-shell'|'add-repo'|'remove-repo'} op
317
+ * @param {string|null} repo
318
+ */
319
+ export async function setGatewayRepo(op, repo) {
320
+ const workspaceConfig = await WorkspaceConfig.load();
321
+ const configPath = await workspaceConfig.setGatewayRepo(op, repo);
322
+ const gateway = workspaceConfig.env?.gateway || {};
323
+ console.log(chalk.green(`\n✅ Gateway ${op}: ${repo === null ? '(shell removed)' : repo}\n`));
324
+ console.log(chalk.white(` env.gateway.shell: ${gateway.shell ?? '(none)'}`));
325
+ console.log(chalk.white(` env.gateway.appRepos: ${gateway.appRepos?.length ? gateway.appRepos.join(', ') : '(none)'}`));
326
+ console.log(chalk.gray(` Saved to: ${configPath}\n`));
327
+ return configPath;
328
+ }
@@ -7,8 +7,9 @@
7
7
  * - descix kb set-override-model -a <app_id> -k <kb_name> -m <model_name> [--env=dev|demo|prod]
8
8
  * - descix kb clear-override-model -a <app_id> -k <kb_name> [--env=dev|demo|prod]
9
9
  *
10
- * Per CEO 2026-05-26 inheritance chain:
11
- * options.model || kb.kb_model_override || app.default_app_model || levelConfig.model || utils.DEFAULT_AI_MODEL
10
+ * Resolver order (the server owns it; this CLI prints the chain the server reports):
11
+ * request intelligence_level > kb.kb_model_override (+ its thinking_level) > app.default_app_model > the level's model
12
+ * There is no separate default model: the default is the model of the platform's default intelligence level.
12
13
  *
13
14
  * --clear flows use FieldValue.delete() server-side (NOT null) per tripwire #2.
14
15
  *
@@ -110,12 +111,10 @@ function identityFromApiClient(apiClient) {
110
111
  function printResolution({ app_id, resolution }) {
111
112
  if (!resolution) return;
112
113
  const dam = resolution.default_app_model === null ? 'null' : `"${resolution.default_app_model}"`;
113
- const dai = resolution.default_ai_model ? `"${resolution.default_ai_model}"` : 'null';
114
114
 
115
115
  console.log(chalk.cyan('\nResolution chain that will apply going forward:'));
116
116
  console.log(chalk.gray(` ${resolution.chain}`));
117
117
  console.log(chalk.gray(` app.default_app_model = ${dam}`));
118
- console.log(chalk.gray(` utils.DEFAULT_AI_MODEL = ${dai}`));
119
118
 
120
119
  const levels = resolution.intelligence_levels || {};
121
120
  const levelKeys = Object.keys(levels).sort();
@@ -93,6 +93,9 @@
93
93
  "community rename": [
94
94
  "rename_community"
95
95
  ],
96
+ "config gateway add-repo": [],
97
+ "config gateway remove-repo": [],
98
+ "config gateway set-shell": [],
96
99
  "config init": [],
97
100
  "config set-dev-certs": [],
98
101
  "config set-env": [],
@@ -1,6 +1,6 @@
1
1
  import * as fs from 'fs/promises';
2
2
  import * as path from 'path';
3
- import { ENV_ORIGINS, resolveWorkspaceApps } from '@descix/app-sdk/dev';
3
+ import { ENV_ORIGINS, resolveWorkspaceApps, resolveWorkspaceAppsFrom, assertSiteStatic } from '@descix/app-sdk/dev';
4
4
  import { resolveOrigin } from './origin.js';
5
5
  // The one canonical KB-sync surface, from its owner. The example in requireContext() is a remedy
6
6
  // a stuck developer will copy verbatim, so it must resolve to the live verb rather than a literal
@@ -860,9 +860,9 @@ export class WorkspaceConfig {
860
860
  * Update the app's site config on env.app (v3 single-app).
861
861
  *
862
862
  * Parallel to setMicroservicePort(), but operates on the entry's site.{} slot. This is the
863
- * canonical write path for site.static — the relative path the dev gateway's
864
- * staticSitePlugin serves at /p/{appId}/ (see createViteProxyConfig: site.static is
865
- * resolved against the app's localPath; "." means the localPath itself) — and for
863
+ * canonical write path for site.static — the path, relative to the workspace root (the repo
864
+ * root; "." means the root itself), that the dev gateway's staticSitePlugin serves at
865
+ * /p/{appId}/; anything that is not a plain relative path is refused (assertSiteStatic) — and for
866
866
  * site.port / site.protocol, the dev-server upstream the gateway proxies /p/{appId} to.
867
867
  * Backs the `descix app set-site` command, closing the site.static workspace gap without
868
868
  * hand-editing workspace.json (the org rule forbids hand edits — CEO-D-2026-06-02-SSGPOD-SITE-PREPROD).
@@ -898,6 +898,12 @@ export class WorkspaceConfig {
898
898
  const settingPort = 'port' in fields && fields.port !== null && fields.port !== undefined;
899
899
  const settingProtocol = 'protocol' in fields && fields.protocol !== null && fields.protocol !== undefined;
900
900
 
901
+ // site.static path rule — ONE owner (@descix/app-sdk/dev siteStatic.js), applied before
902
+ // anything is computed so a refusal writes NOTHING.
903
+ if (settingStatic) {
904
+ assertSiteStatic(fields.static, { subject: '--static', remedy: `Nothing was written for ${appId}.` });
905
+ }
906
+
901
907
  if (settingProtocol && fields.protocol !== 'http' && fields.protocol !== 'https') {
902
908
  throw new Error(
903
909
  `site.protocol must be "http" or "https" (got ${JSON.stringify(fields.protocol)}). ` +
@@ -950,6 +956,97 @@ export class WorkspaceConfig {
950
956
  return this.save();
951
957
  }
952
958
 
959
+ /**
960
+ * THE ONE WRITER of a gateway workspace's repo list: env.gateway.shell and env.gateway.appRepos[]
961
+ * (DeSciXCore#9). Backs `descix config gateway set-shell | add-repo | remove-repo`, so a gateway
962
+ * workspace.json is never hand-edited.
963
+ *
964
+ * Paths are relative to THIS workspace's root (sibling checkouts: "../<repo>") and are stored in
965
+ * normalized form; two spellings of one directory are one repo.
966
+ *
967
+ * Refused loud, and a refusal writes NOTHING:
968
+ * - this workspace holds env.app — a single-app workspace is not a gateway;
969
+ * - add-repo of a repo already listed (or already the shell);
970
+ * - remove-repo of a repo that is not listed;
971
+ * - any resulting listed repo that the gateway could not read: the candidate config is resolved
972
+ * by @descix/app-sdk/dev resolveWorkspaceAppsFrom — the SAME function the gateway reads it with
973
+ * — so an absolute path, a repo without its own .descix/workspace.json, one with no env.app, a
974
+ * bad site.static, or one appId listed twice is refused by the reader's own rule.
975
+ *
976
+ * @param {'set-shell'|'add-repo'|'remove-repo'} op
977
+ * @param {string|null} repo - repo root relative to the workspace root; null with set-shell removes the shell
978
+ * @returns {Promise<string>} Path to saved config (from save())
979
+ */
980
+ async setGatewayRepo(op, repo) {
981
+ const OPS = ['set-shell', 'add-repo', 'remove-repo'];
982
+ if (!OPS.includes(op)) throw new Error(`Unknown gateway op "${op}" (expected one of: ${OPS.join(', ')}).`);
983
+ const root = this.workspaceRoot;
984
+ if (!root) throw new Error('Workspace root not set. Load the config with WorkspaceConfig.load() first.');
985
+ const configPath = path.join(root, '.descix', 'workspace.json');
986
+
987
+ if (this.env?.app?.appId) {
988
+ throw new Error(
989
+ `${configPath} is a single-app workspace (env.app "${this.env.app.appId}"), not a gateway workspace.\n` +
990
+ 'env.gateway.shell and env.gateway.appRepos belong to a gateway workspace, which holds no env.app of\n' +
991
+ 'its own and lists sibling app repos. Run this verb from the gateway workspace\'s root. Nothing was written.'
992
+ );
993
+ }
994
+
995
+ const removingShell = op === 'set-shell' && (repo === null || repo === undefined);
996
+ if (!removingShell && (typeof repo !== 'string' || repo.trim() === '')) {
997
+ throw new Error(`descix config gateway ${op} needs a repo path (relative to ${root}). Nothing was written.`);
998
+ }
999
+ // Normalized relative form: "../repo-a/", "./../repo-a" and "../repo-a" are one repo. An absolute
1000
+ // path is passed through untouched so the reader's own rule refuses it below.
1001
+ const normalize = (rel) => (path.isAbsolute(rel) ? rel : (path.normalize(rel).replace(/[\\/]+$/, '') || '.'));
1002
+ const dirOf = (rel) => path.resolve(root, rel);
1003
+ const rel = removingShell ? null : normalize(repo.trim());
1004
+
1005
+ const gateway = { ...(this.env?.gateway || {}) };
1006
+ const appRepos = [...(gateway.appRepos || [])];
1007
+
1008
+ if (op === 'set-shell') {
1009
+ if (rel === null) delete gateway.shell;
1010
+ else gateway.shell = rel;
1011
+ } else if (op === 'add-repo') {
1012
+ if (!path.isAbsolute(rel)) {
1013
+ const listed = appRepos.find((r) => dirOf(r) === dirOf(rel));
1014
+ if (listed) {
1015
+ throw new Error(`"${repo}" is already listed in env.gateway.appRepos (as "${listed}") in ${configPath}. Nothing was written.`);
1016
+ }
1017
+ if (gateway.shell && dirOf(gateway.shell) === dirOf(rel)) {
1018
+ throw new Error(
1019
+ `"${repo}" is this workspace's env.gateway.shell in ${configPath}; the shell is served at / and is not ` +
1020
+ 'also listed in appRepos. Nothing was written.'
1021
+ );
1022
+ }
1023
+ }
1024
+ appRepos.push(rel);
1025
+ } else {
1026
+ const at = path.isAbsolute(rel) ? -1 : appRepos.findIndex((r) => dirOf(r) === dirOf(rel));
1027
+ if (at < 0) {
1028
+ throw new Error(
1029
+ `"${repo}" is not listed in env.gateway.appRepos in ${configPath}.\n` +
1030
+ ` Listed: ${appRepos.length ? appRepos.join(', ') : '(none)'}\nNothing was written.`
1031
+ );
1032
+ }
1033
+ appRepos.splice(at, 1);
1034
+ }
1035
+
1036
+ if (appRepos.length) gateway.appRepos = appRepos;
1037
+ else delete gateway.appRepos;
1038
+
1039
+ const candidateEnv = { ...(this.env || {}) };
1040
+ if (Object.keys(gateway).length) candidateEnv.gateway = gateway;
1041
+ else delete candidateEnv.gateway;
1042
+
1043
+ // Validate the WHOLE resulting repo set with the reader's own function before anything is saved.
1044
+ resolveWorkspaceAppsFrom(root, { env: candidateEnv });
1045
+
1046
+ this.env = candidateEnv;
1047
+ return this.save();
1048
+ }
1049
+
953
1050
  /**
954
1051
  * Persistently set the target environment in workspace.json.
955
1052
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@descix/cli",
3
- "version": "2.0.4",
3
+ "version": "3.0.1",
4
4
  "description": "DeSciX CLI and MCP Server - Unified command-line interface for DeSciX platform",
5
5
  "repository": {
6
6
  "type": "git",
@@ -30,7 +30,7 @@
30
30
  "author": "DeSciX",
31
31
  "license": "SEE LICENSE IN LICENSE.md",
32
32
  "dependencies": {
33
- "@descix/app-sdk": "^0.3.0",
33
+ "@descix/app-sdk": "^0.3.3",
34
34
  "@descix/sdk": "^3.0.0",
35
35
  "@modelcontextprotocol/sdk": "^1.25.1",
36
36
  "axios": "^1.7.9",
@@ -40,7 +40,7 @@
40
40
  "googleapis": "^170.1.0",
41
41
  "inquirer": "^12.1.0",
42
42
  "ora": "^9.0.0",
43
- "@descix/platform-api": "^2.0.0"
43
+ "@descix/platform-api": "^4.0.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "acorn": "^8.18.0"