@descix/cli 1.0.11 → 1.0.13

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/bin/descix.js CHANGED
@@ -2537,6 +2537,11 @@ siteCommand
2537
2537
  return;
2538
2538
  }
2539
2539
 
2540
+ // 1b. Refuse to publish backend source, an agent's persona, credentials or workspace
2541
+ // state — BEFORE any token is requested, so a dry run refuses too (SiteDenyLint).
2542
+ const { assertSitePublishable } = await import('../lib/core/SiteDenyLint.js');
2543
+ assertSitePublishable(fileList.map((f) => f.path));
2544
+
2540
2545
  // 2. Request deploy token with file list
2541
2546
  const tokenResponse = await apiClient.invoke('get_site_deploy_token', {
2542
2547
  community_id: communityId,
@@ -33,6 +33,7 @@ import {
33
33
  } from '../core/Syncer.js';
34
34
  import { lintManifestForDenyClasses, assertNoViolations } from '../core/CorpusDenyLint.js';
35
35
  import { pickCanaryChunk, runRetrievalCanary, DEFAULT_CANARY_DELAYS_MS } from '../core/RetrievalCanary.js';
36
+ import { loadSyncState, saveSyncState, syncStatePath, syncFailureLogPath } from '../core/syncState.js';
36
37
 
37
38
  /**
38
39
  * Resolve community_id for corpus sync — same precedence as site upload / update kb:
@@ -207,35 +208,12 @@ export function renderCount(value, unknownText = 'an unreported number of') {
207
208
  }
208
209
 
209
210
  /**
210
- * Save sync state after a successful corpus sync.
211
- *
212
- * @param {string} appRoot - App root directory
213
- * @param {string} kbName - Knowledge base name
214
- * @param {Object} state - { last_sync_commit, sources_provenance, synced_files_count, total_chunks, timestamp }
211
+ * Sync state is owned by lib/core/syncState.js and keyed by the ORIGIN the sync talked to.
212
+ * The unscoped local helpers that used to live here are DELETED, not wrapped: they were the
213
+ * defect (one file per KB name, no environment anywhere in it), and a compat path that still
214
+ * resolved `.descix/sync-state/<KB>.json` would keep producing the silent under-sync this
215
+ * module exists to end. See that file's header for the measurement.
215
216
  */
216
- async function saveSyncState(appRoot, kbName, state) {
217
- const stateDir = path.join(appRoot, '.descix', 'sync-state');
218
- await fs.mkdir(stateDir, { recursive: true });
219
- const statePath = path.join(stateDir, `${kbName}.json`);
220
- await fs.writeFile(statePath, JSON.stringify(state, null, 2));
221
- }
222
-
223
- /**
224
- * Load sync state from a previous corpus sync.
225
- *
226
- * @param {string} appRoot - App root directory
227
- * @param {string} kbName - Knowledge base name
228
- * @returns {Promise<Object|null>} Sync state or null if no previous sync
229
- */
230
- async function loadSyncState(appRoot, kbName) {
231
- const statePath = path.join(appRoot, '.descix', 'sync-state', `${kbName}.json`);
232
- try {
233
- const raw = await fs.readFile(statePath, 'utf-8');
234
- return JSON.parse(raw);
235
- } catch {
236
- return null;
237
- }
238
- }
239
217
 
240
218
  /**
241
219
  * Detect the current git branch in workspaceRoot.
@@ -398,6 +376,13 @@ export async function runCorpusSync(apiClient, options) {
398
376
 
399
377
  spinner.succeed(`Found ${manifests.length} manifest(s)`);
400
378
 
379
+ // THE ORIGIN THIS SYNC IS TALKING TO — resolved once, from the client that will actually
380
+ // carry the upserts, and used to key every sync-state read and write below. Taken from the
381
+ // client rather than re-derived from the workspace: a re-derivation could name a different
382
+ // origin than the one receiving the chunks, which is precisely the lie that state file
383
+ // told for months. Awaited because baseUrl is loaded lazily.
384
+ const syncOrigin = await apiClient.ensureBaseUrl();
385
+
401
386
  // Deliverable B: branch-mismatch advisory. We only emit this when:
402
387
  // - no --ref override was supplied, AND
403
388
  // - every loaded manifest's every source uses the default "main", AND
@@ -649,7 +634,16 @@ export async function runCorpusSync(apiClient, options) {
649
634
  // Deliverable A note: in dry-run + rebuild we ALSO null previousState so
650
635
  // the count below reflects what a real rebuild would upsert (all files),
651
636
  // not what an incremental sync would upsert.
652
- let previousState = await loadSyncState(appRoot, kbName);
637
+ // State is per-ORIGIN. The blob-SHA skip below is only sound against the origin that
638
+ // actually received those SHAs — see lib/core/syncState.js.
639
+ const loadedState = await loadSyncState(appRoot, kbName, syncOrigin);
640
+ let previousState = loadedState.state;
641
+ if (loadedState.reason === 'unkeyed-legacy' || loadedState.reason === 'origin-mismatch') {
642
+ // LOUD, because the consequence of not saying it is a full re-walk the operator did not
643
+ // ask for, and silence here is what made the original defect invisible for months.
644
+ console.log(chalk.yellow(` ⚠ No sync state for ${syncOrigin} — walking the FULL corpus.`));
645
+ console.log(chalk.gray(` ${loadedState.detail}`));
646
+ }
653
647
  if (options.rebuild) {
654
648
  previousState = null;
655
649
  }
@@ -657,9 +651,9 @@ export async function runCorpusSync(apiClient, options) {
657
651
  const previousChunkCount = readPreviousChunkCount(previousState);
658
652
 
659
653
  if (previousBlobShas.size > 0) {
660
- console.log(chalk.gray(` Previous sync: ${previousBlobShas.size} files, ${previousChunkCount ?? 'an unknown number of'} chunks`));
654
+ console.log(chalk.gray(` Previous sync to ${syncOrigin}: ${previousBlobShas.size} files, ${previousChunkCount ?? 'an unknown number of'} chunks`));
661
655
  } else {
662
- console.log(chalk.gray(' First sync (no previous state)'));
656
+ console.log(chalk.gray(` First sync to ${syncOrigin} (no previous state for this origin)`));
663
657
  }
664
658
 
665
659
  // 3c. Compute delta — compare by blob SHA against local sync state
@@ -822,10 +816,10 @@ export async function runCorpusSync(apiClient, options) {
822
816
 
823
817
  // Write failure log to .descix if any batches failed
824
818
  if (syncFailures.length > 0) {
825
- const failLogDir = path.join(appRoot, '.descix', 'sync-state');
826
- await fs.mkdir(failLogDir, { recursive: true });
827
- const failLogPath = path.join(failLogDir, `${kbName}-failures.json`);
819
+ const failLogPath = syncFailureLogPath(appRoot, kbName, syncOrigin);
820
+ await fs.mkdir(path.dirname(failLogPath), { recursive: true });
828
821
  await fs.writeFile(failLogPath, JSON.stringify({
822
+ origin: syncOrigin,
829
823
  timestamp: new Date().toISOString(),
830
824
  commit: commitSha,
831
825
  failures: syncFailures,
@@ -928,7 +922,7 @@ export async function runCorpusSync(apiClient, options) {
928
922
 
929
923
  // 3g. Save sync state with blob SHAs for future delta computation
930
924
  const allSyncedBlobShas = [...localBlobShas]; // All current files
931
- await saveSyncState(appRoot, kbName, {
925
+ await saveSyncState(appRoot, kbName, syncOrigin, {
932
926
  last_sync_commit: commitSha,
933
927
  // Per-source provenance: WHICH repo, at WHICH ref, and the RESOLVED COMMIT SHA actually
934
928
  // synced. `ref` may be a mutable branch, so the ref alone is not a record of what was
@@ -1048,6 +1042,19 @@ export async function runCorpusStatus(apiClient, options) {
1048
1042
 
1049
1043
  const appRoot = appConfig.absolutePath;
1050
1044
 
1045
+ // Sync state is per-ORIGIN, so status cannot report it without knowing which origin is
1046
+ // being asked about. Resolved from the same client the sync would use — never re-derived
1047
+ // here, which would let status describe one origin while sync wrote another.
1048
+ if (!apiClient) {
1049
+ spinner.fail('Origin required');
1050
+ throw new Error(
1051
+ 'corpus status needs an API client to know WHICH origin\'s sync state to report. '
1052
+ + 'Sync state is keyed by origin (see lib/core/syncState.js), so there is no '
1053
+ + 'origin-independent status to print.',
1054
+ );
1055
+ }
1056
+ const statusOrigin = await apiClient.ensureBaseUrl();
1057
+
1051
1058
  // Load manifests
1052
1059
  const manifests = await loadManifests(appRoot, workspaceRoot, options.kb || null);
1053
1060
 
@@ -1064,11 +1071,20 @@ export async function runCorpusStatus(apiClient, options) {
1064
1071
  const refResolution = resolveRef(rawManifest, options.ref || null);
1065
1072
  const manifest = refResolution.manifest;
1066
1073
  const kbName = manifest.kb_name;
1067
- const syncState = await loadSyncState(appRoot, kbName);
1074
+ // Status reports state for ONE origin — the one this invocation is pointed at. A status
1075
+ // that merged or guessed across origins would be the same unscoped claim the state file
1076
+ // used to make.
1077
+ const loadedState = await loadSyncState(appRoot, kbName, statusOrigin);
1078
+ const syncState = loadedState.state;
1068
1079
 
1069
1080
  console.log(chalk.cyan(`\nCorpus Status: ${appId} / ${kbName}`));
1070
1081
  console.log(chalk.gray('─'.repeat(50)));
1082
+ console.log(chalk.white(` Origin: ${statusOrigin}`));
1071
1083
  console.log(chalk.white(` Resolved ref: ${refResolution.resolvedRef} (source=${refResolution.source})`));
1084
+ if (loadedState.reason === 'unkeyed-legacy' || loadedState.reason === 'origin-mismatch') {
1085
+ console.log(chalk.yellow(` ⚠ No sync state for this origin.`));
1086
+ console.log(chalk.gray(` ${loadedState.detail}`));
1087
+ }
1072
1088
 
1073
1089
  // File count from a fresh walk so the operator sees the LIVE file count
1074
1090
  // at the resolved ref, not the count from the last sync. This is the
@@ -33,6 +33,7 @@ import * as driveADC from '../google-storage-adc.js';
33
33
  // commands' next steps are read by a developer as instructions, so they must name the live verb
34
34
  // and nothing else.
35
35
  import { CANONICAL_KB_SYNC } from './retired-kb-sync.js';
36
+ import { loadSyncState, syncStatePath } from '../core/syncState.js';
36
37
 
37
38
  // ============ Pull Command ============
38
39
 
@@ -346,24 +347,24 @@ export async function runKbDoctor(apiClient, options) {
346
347
  }
347
348
  }
348
349
 
349
- // (b) Local sync-state total_chunks
350
- const syncStatePath = path.join(appRoot, '.descix', 'sync-state', `${kbName}.json`);
351
- let localChunks = null;
352
- let syncStateRaw = null;
353
- try {
354
- const raw = await fs.readFile(syncStatePath, 'utf-8');
355
- syncStateRaw = JSON.parse(raw);
356
- localChunks = syncStateRaw?.total_chunks;
357
- if (typeof localChunks !== 'number') {
358
- throw new Error(`sync-state has no total_chunks: ${syncStatePath}`);
359
- }
360
- } catch (err) {
361
- if (err.code === 'ENOENT') {
362
- console.log(chalk.yellow(` ⚠ No local sync-state at ${syncStatePath}`));
363
- console.log(chalk.gray(` Run 'descix kb corpus sync -a ${appId} -k ${kbName}' first.`));
364
- process.exit(2);
365
- }
366
- throw err;
350
+ // (b) Local sync-state total_chunks, for THIS ORIGIN.
351
+ //
352
+ // The path is resolved by its owner (lib/core/syncState.js), never rebuilt here: doctor
353
+ // compares a LOCAL record against a LIVE store, so reading one origin's state while querying
354
+ // another's store would manufacture the very drift this command exists to detect.
355
+ const doctorOrigin = await apiClient.ensureBaseUrl();
356
+ const statePathForOrigin = syncStatePath(appRoot, kbName, doctorOrigin);
357
+ const loadedState = await loadSyncState(appRoot, kbName, doctorOrigin);
358
+ const syncStateRaw = loadedState.state;
359
+ if (!syncStateRaw) {
360
+ console.log(chalk.yellow(` ⚠ No local sync-state for ${doctorOrigin} at ${statePathForOrigin}`));
361
+ if (loadedState.detail) console.log(chalk.gray(` ${loadedState.detail}`));
362
+ console.log(chalk.gray(` Run 'descix kb corpus sync -a ${appId} -k ${kbName}' against this origin first.`));
363
+ process.exit(2);
364
+ }
365
+ const localChunks = syncStateRaw.total_chunks;
366
+ if (typeof localChunks !== 'number') {
367
+ throw new Error(`sync-state has no total_chunks: ${statePathForOrigin}`);
367
368
  }
368
369
 
369
370
  // (c) Report
@@ -0,0 +1,69 @@
1
+ /**
2
+ * What a site upload must never publish. Hard deny — the upload fails loud before any file
3
+ * leaves the machine, dry-run included.
4
+ *
5
+ * WHY (measured, 2026-09-18): egpt-godsworld's DEV site had been publicly serving, as static
6
+ * files, the app's persona (`assets/system_instructions.md`) and its whole backend source tree
7
+ * (`microservice/app.js`, `Dockerfile`, `services/*`, config examples). An earlier upload used a
8
+ * broad manifest, and `site upload` publishes whatever its manifest names. The KB sync already
9
+ * refuses private content (CorpusDenyLint); the site upload had no equivalent.
10
+ *
11
+ * A DIFFERENT PATTERN SET from the corpus, on purpose: the corpus lint is about editorial
12
+ * publishability (drafts, patents, test dumps), and a site legitimately ships some of those. This
13
+ * set is about what is private at RUNTIME — backend source, an agent's persona, credentials,
14
+ * workspace and tooling state. The MATCHER is shared (CorpusDenyLint.matchPathPattern), so there
15
+ * is one definition of how a pattern meets a path.
16
+ *
17
+ * No exemption flag: every class here is repaired by not naming the file in the manifest, which
18
+ * is always possible. An escape hatch would be the one path the next leak takes.
19
+ */
20
+
21
+ import { matchPathPattern } from './CorpusDenyLint.js';
22
+
23
+ export const SITE_DENY_PATH_PATTERNS = [
24
+ { id: 1, class: 'app-source', patterns: ['**/microservice/**', 'microservice'],
25
+ reason: 'backend source — an app\'s microservice is deployed as a service, never served as files' },
26
+ { id: 2, class: 'agent-persona', patterns: ['system_instructions.md'],
27
+ reason: 'the agent\'s persona / system instructions — its prompt is not a public asset' },
28
+ { id: 3, class: 'credential', caseFold: true,
29
+ patterns: ['wallet*.json', '.env', '.env.*', 'dev-overrides*.json', 'secret*.json', '*.pem', '*.key',
30
+ 'service-account*.json', 'credentials*.json', 'id_rsa*', '*.p12'],
31
+ reason: 'credential or credential-shaped config (a placeholder copy teaches the real layout)' },
32
+ { id: 4, class: 'workspace-state', patterns: ['**/.descix/**', '.descix'],
33
+ reason: 'DeSciX workspace / session state' },
34
+ { id: 5, class: 'tooling-state', patterns: ['**/.claude/**', '**/.git/**', '.claude', '.git'],
35
+ reason: 'agent or VCS internals' },
36
+ ];
37
+
38
+ /**
39
+ * Every published path that matches a deny class.
40
+ * @param {string[]} publishedPaths - paths relative to the site root, as they would be served
41
+ * @returns {Array<{path:string, class:string, glob:string, reason:string}>}
42
+ */
43
+ export function lintSiteFiles(publishedPaths) {
44
+ const violations = [];
45
+ for (const p of publishedPaths) {
46
+ for (const pattern of SITE_DENY_PATH_PATTERNS) {
47
+ const glob = matchPathPattern(p, pattern);
48
+ if (glob) {
49
+ violations.push({ path: p, class: pattern.class, glob, reason: pattern.reason });
50
+ break;
51
+ }
52
+ }
53
+ }
54
+ return violations;
55
+ }
56
+
57
+ /** Throw, naming every offending file and the repair, when any path is denied. */
58
+ export function assertSitePublishable(publishedPaths) {
59
+ const violations = lintSiteFiles(publishedPaths);
60
+ if (violations.length === 0) return;
61
+ const lines = violations.map((v) => ` - ${v.path} [${v.class}] ${v.reason}`);
62
+ const err = new Error(
63
+ `site upload REFUSED: ${violations.length} file(s) must never be publicly served:\n${lines.join('\n')}\n` +
64
+ 'Nothing was uploaded. Remove these from the site manifest\'s sources (.descix/manifests/site.json) ' +
65
+ 'or from the build output, then upload again.');
66
+ err.code = 'SITE_DENY_PATH';
67
+ err.violations = violations;
68
+ throw err;
69
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * syncState — THE owner of where a KB's local sync state lives, and which ORIGIN it describes.
3
+ *
4
+ * ── The defect this closes (measured 2026-09-17, two apps) ───────────────────
5
+ * State used to live at `.descix/sync-state/<KB>.json` — one file per KB NAME, with no
6
+ * environment and no origin anywhere in the path or the payload. `corpus sync` skips a file
7
+ * when its blob SHA appears in that state, and nothing asked WHICH ORIGIN had received those
8
+ * SHAs. Syncing one KB to two environments therefore produced two failures from one cause:
9
+ *
10
+ * UNDER-SYNC the second origin skips files it never received, as "unchanged"
11
+ * BLINDING the first origin's drift becomes invisible, because the second origin's
12
+ * commit was written over the shared state — a stale KB reporting healthy
13
+ *
14
+ * Measured: GodsWorld on DEV took 2 of 8 files and then reported PROD clean ("Drift: NONE")
15
+ * over a PROD KB two files behind. Independently, daita/General's one state file described
16
+ * DEV (886 chunks, matching DEV's 886 vectors) while PROD sat on a different corpus (2545
17
+ * vectors, 2026-05-29); a PROD dry-run against it declared 43 files "Unchanged" that PROD had
18
+ * never received at those SHAs.
19
+ *
20
+ * ── The fix ─────────────────────────────────────────────────────────────────
21
+ * State is keyed by the ORIGIN the sync actually talked to — the same fact `lib/origin.js`
22
+ * owns and that every MCP result is stamped with. The origin is used because it is the fact
23
+ * that is ALWAYS available: an env NAME is not (a workspace `env.apiUrl` or a DESCIX_API_URL
24
+ * carries no label), and keying on a name would also miss a repointed workspace — same label,
25
+ * different origin — which is the same lie one level up. The full origin is recorded INSIDE
26
+ * the file as well, so a slug collision is caught rather than trusted.
27
+ *
28
+ * ── Migration: an unkeyed file belongs to NOBODY ─────────────────────────────
29
+ * A legacy `.descix/sync-state/<KB>.json` records a sync to an origin nobody wrote down.
30
+ * Adopting it for whichever origin asks first would hard-code today's accident as tomorrow's
31
+ * truth — exactly the bug, re-created by the fix. So it is reported as UNKNOWN-ORIGIN and
32
+ * forces one full walk, for every origin, and is never read as state and never deleted: it is
33
+ * the only surviving record of what was synced before keying existed.
34
+ */
35
+
36
+ import path from 'path';
37
+ import fs from 'fs/promises';
38
+
39
+ /** Directory holding every origin's state for an app. */
40
+ export function syncStateRoot(appRoot) {
41
+ return path.join(appRoot, '.descix', 'sync-state');
42
+ }
43
+
44
+ /**
45
+ * A filename-safe token for an origin. ONE derivation, used for the directory name; the exact
46
+ * origin is recorded in the file, so this only has to be stable and readable, not injective.
47
+ *
48
+ * `https://dev.descix.net` -> `dev.descix.net`
49
+ * `https://localhost:4000` -> `localhost-4000`
50
+ *
51
+ * @param {string} origin an absolute http(s) origin, already normalized by lib/origin.js
52
+ * @returns {string}
53
+ * @throws {Error} on an empty or non-string origin — a state file keyed by "undefined" is
54
+ * exactly the unscoped file this module exists to abolish, so it must never be writable.
55
+ */
56
+ export function originKey(origin) {
57
+ if (typeof origin !== 'string' || origin.trim() === '') {
58
+ throw new Error(
59
+ 'syncState: refusing to key sync state by an empty origin. The caller must pass the '
60
+ + 'origin the sync actually talked to (apiClient.baseUrl) — an unkeyed state file is the '
61
+ + 'defect this module exists to prevent.',
62
+ );
63
+ }
64
+ return origin
65
+ .trim()
66
+ .replace(/^https?:\/\//, '')
67
+ .replace(/\/+$/, '')
68
+ .replace(/[^A-Za-z0-9._-]/g, '-');
69
+ }
70
+
71
+ /** Where this origin's state for this KB lives. */
72
+ export function syncStatePath(appRoot, kbName, origin) {
73
+ return path.join(syncStateRoot(appRoot), originKey(origin), `${kbName}.json`);
74
+ }
75
+
76
+ /**
77
+ * Where a failed-batch log for this origin lives. Keyed for the same reason the state is: a
78
+ * failure log named only by KB let a DEV run overwrite the record of which PROD batches failed,
79
+ * so the surviving file described an environment nobody could identify from it.
80
+ */
81
+ export function syncFailureLogPath(appRoot, kbName, origin) {
82
+ return path.join(syncStateRoot(appRoot), originKey(origin), `${kbName}-failures.json`);
83
+ }
84
+
85
+ /** Where an unkeyed (pre-origin-keying) file would be. Read only to DETECT it. */
86
+ export function legacySyncStatePath(appRoot, kbName) {
87
+ return path.join(syncStateRoot(appRoot), `${kbName}.json`);
88
+ }
89
+
90
+ /**
91
+ * Load the state for THIS origin, or report why there is none.
92
+ *
93
+ * @returns {Promise<{state: Object|null, reason: 'loaded'|'first-sync'|'unkeyed-legacy'|'origin-mismatch', detail?: string}>}
94
+ * `state` is null for every reason but 'loaded'. A null state means a FULL walk, which is
95
+ * always safe: it re-upserts rather than purging, and it is self-correcting.
96
+ */
97
+ export async function loadSyncState(appRoot, kbName, origin) {
98
+ const statePath = syncStatePath(appRoot, kbName, origin);
99
+ try {
100
+ const parsed = JSON.parse(await fs.readFile(statePath, 'utf-8'));
101
+ // Defensive: the directory is a slug, the payload is exact. They can only disagree through
102
+ // a slug collision, and trusting the slug in that case would resurrect the original bug.
103
+ if (parsed.origin && parsed.origin !== origin) {
104
+ return {
105
+ state: null,
106
+ reason: 'origin-mismatch',
107
+ detail: `${statePath} records origin ${parsed.origin}, but this sync is talking to ${origin}. `
108
+ + 'Treating it as no state and walking the full corpus.',
109
+ };
110
+ }
111
+ return { state: parsed, reason: 'loaded' };
112
+ } catch {
113
+ // No state for this origin. Is there an unkeyed file from before keying existed?
114
+ const legacy = legacySyncStatePath(appRoot, kbName);
115
+ try {
116
+ await fs.access(legacy);
117
+ return {
118
+ state: null,
119
+ reason: 'unkeyed-legacy',
120
+ detail: `${legacy} was written before sync state was keyed by origin, so it does not say `
121
+ + `WHICH environment it describes. It is not being read — this sync walks the full corpus `
122
+ + `once for ${origin}, and writes its own state at ${statePath}. The old file is left `
123
+ + `alone as the only record of what came before.`,
124
+ };
125
+ } catch {
126
+ return { state: null, reason: 'first-sync' };
127
+ }
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Persist state for THIS origin. The origin is written into the payload as well as the path.
133
+ *
134
+ * @param {string} appRoot
135
+ * @param {string} kbName
136
+ * @param {string} origin
137
+ * @param {Object} state the same shape as before, minus provenance this function adds
138
+ */
139
+ export async function saveSyncState(appRoot, kbName, origin, state) {
140
+ const statePath = syncStatePath(appRoot, kbName, origin);
141
+ await fs.mkdir(path.dirname(statePath), { recursive: true });
142
+ await fs.writeFile(statePath, JSON.stringify({ origin, ...state }, null, 2));
143
+ return statePath;
144
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@descix/cli",
3
- "version": "1.0.11",
3
+ "version": "1.0.13",
4
4
  "description": "DeSciX CLI and MCP Server - Unified command-line interface for DeSciX platform",
5
5
  "repository": {
6
6
  "type": "git",