@khotan/cli 0.5.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +22 -0
  2. package/dist/khotan.js +945 -53
  3. package/package.json +4 -4
package/dist/khotan.js CHANGED
@@ -19,7 +19,7 @@ var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
19
19
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
20
20
 
21
21
  // ../khotan-core/src/version.ts
22
- var CATALOG_SCHEMA_VERSION = 1, CATALOG_VERSION = "2026-06-23", KHOTAN_ADAPTER_NAME = "khotan", KHOTAN_ADAPTER_VERSION = "0.5.0", SUPPORTED_CATALOG_SCHEMA_VERSIONS;
22
+ var CATALOG_SCHEMA_VERSION = 1, CATALOG_VERSION = "2026-06-28", KHOTAN_ADAPTER_NAME = "khotan", KHOTAN_ADAPTER_VERSION = "0.7.0", SUPPORTED_CATALOG_SCHEMA_VERSIONS;
23
23
  var init_version = __esm(() => {
24
24
  SUPPORTED_CATALOG_SCHEMA_VERSIONS = [
25
25
  CATALOG_SCHEMA_VERSION
@@ -115,6 +115,12 @@ function platformDomain(config) {
115
115
  const idArg = idField(resourceParam, `The ${noun} id.`);
116
116
  const cap = domain;
117
117
  const a = article(noun);
118
+ const envWriteTargetField = {
119
+ name: "target",
120
+ location: "body",
121
+ type: "string[]",
122
+ description: "Environments to write, any of: production, preview, development (comma-separated or repeatable). Omit to write all environments."
123
+ };
118
124
  const tool = (suffix) => `khotan_${domain}_${suffix}`;
119
125
  const operations = [
120
126
  {
@@ -220,14 +226,15 @@ function platformDomain(config) {
220
226
  safety: "write",
221
227
  auth: true,
222
228
  title: `Bulk upsert ${noun} environment variables`,
223
- description: `Create or update many env vars in one request.`,
229
+ description: `Create or update many env vars in one request. Writes every environment unless you pass target.`,
224
230
  input: [
225
231
  idArg,
226
- { name: "variables", location: "body", type: "json", required: true, description: "Array of { key, value } to upsert." }
232
+ { name: "variables", location: "body", type: "json", required: true, description: "Array of { key, value } to upsert." },
233
+ envWriteTargetField
227
234
  ],
228
235
  http: { method: "POST", path: `${base}/{${resourceParam}}/env`, operationId: operationIds.envBulkSet, bodyMode: "json" },
229
236
  cli: { command: [domain, "env", "bulk-set"], summary: "Bulk upsert env vars" },
230
- mcp: { tool: { name: tool("env_bulk_set"), title: "Bulk upsert env vars", description: "Create or update many env vars in one request." } }
237
+ mcp: { tool: { name: tool("env_bulk_set"), title: "Bulk upsert env vars", description: "Create or update many env vars in one request. Writes every environment unless target is provided." } }
231
238
  },
232
239
  {
233
240
  id: `${cap}.env.reveal`,
@@ -236,8 +243,17 @@ function platformDomain(config) {
236
243
  safety: "secret",
237
244
  auth: true,
238
245
  title: `Reveal one ${noun} environment variable value`,
239
- description: `Reveal a single decrypted env var value. Secret-bearing; explicit-only.`,
240
- input: [idArg, idField("key", "The env var key to reveal.")],
246
+ description: `Reveal a single decrypted env var value for one environment. Secret-bearing; explicit-only.`,
247
+ input: [
248
+ idArg,
249
+ idField("key", "The env var key to reveal."),
250
+ {
251
+ name: "target",
252
+ location: "query",
253
+ type: "string",
254
+ description: "Environment to read: production, preview, or development. Omit to default to production."
255
+ }
256
+ ],
241
257
  http: { method: "GET", path: `${base}/{${resourceParam}}/env/{key}`, operationId: operationIds.envReveal },
242
258
  cli: { command: [domain, "env", "reveal"], summary: "Reveal one env var value (secret)", defaultOutput: "raw" },
243
259
  mcp: { tool: { name: tool("env_reveal"), title: "Reveal one env var value (secret)", description: "Reveal one decrypted env var value. Secret-bearing — returns a credential, not a list." } }
@@ -249,15 +265,16 @@ function platformDomain(config) {
249
265
  safety: "write",
250
266
  auth: true,
251
267
  title: `Set one ${noun} environment variable`,
252
- description: `Create or update one env var across all targets.`,
268
+ description: `Create or update one env var. Writes every environment unless you pass target.`,
253
269
  input: [
254
270
  idArg,
255
271
  idField("key", "The env var key."),
256
- { name: "value", location: "body", type: "string", required: true, secret: true, description: "The env var value." }
272
+ { name: "value", location: "body", type: "string", required: true, secret: true, description: "The env var value." },
273
+ envWriteTargetField
257
274
  ],
258
275
  http: { method: "PUT", path: `${base}/{${resourceParam}}/env/{key}`, operationId: operationIds.envSet, bodyMode: "json" },
259
276
  cli: { command: [domain, "env", "set"], summary: "Set one env var" },
260
- mcp: { tool: { name: tool("env_set"), title: "Set one env var", description: "Create or update one env var across all targets." } }
277
+ mcp: { tool: { name: tool("env_set"), title: "Set one env var", description: "Create or update one env var. Writes every environment unless target is provided." } }
261
278
  },
262
279
  {
263
280
  id: `${cap}.env.unset`,
@@ -266,11 +283,20 @@ function platformDomain(config) {
266
283
  safety: "destructive",
267
284
  auth: true,
268
285
  title: `Unset one ${noun} environment variable`,
269
- description: `Delete one env var across all targets.`,
270
- input: [idArg, idField("key", "The env var key to delete.")],
286
+ description: `Delete one env var. Removes it from every environment unless you pass target.`,
287
+ input: [
288
+ idArg,
289
+ idField("key", "The env var key to delete."),
290
+ {
291
+ name: "target",
292
+ location: "query",
293
+ type: "string",
294
+ description: "Environment to remove from: production, preview, or development. Omit to remove from all environments."
295
+ }
296
+ ],
271
297
  http: { method: "DELETE", path: `${base}/{${resourceParam}}/env/{key}`, operationId: operationIds.envDelete },
272
298
  cli: { command: [domain, "env", "unset"], summary: "Unset one env var" },
273
- mcp: { tool: { name: tool("env_unset"), title: "Unset one env var", description: "Delete one env var across all targets. Requires confirmation." } }
299
+ mcp: { tool: { name: tool("env_unset"), title: "Unset one env var", description: "Delete one env var. Removes it from every environment unless target is provided. Requires confirmation." } }
274
300
  }
275
301
  ];
276
302
  const resource = {
@@ -296,6 +322,71 @@ function platformDomain(config) {
296
322
  };
297
323
  return [...operations, resource];
298
324
  }
325
+ function emailSubscriptionDomain(config) {
326
+ const { domain, noun, resourceParam, cap } = config;
327
+ const base = `/api/v1/${domain}/{${resourceParam}}/email-subscriptions`;
328
+ const idArg = idField(resourceParam, `The ${noun} id.`);
329
+ const subArg = idField("subscriptionId", "The email subscription id.");
330
+ const tool = (suffix) => `khotan_${domain}_email_subscriptions_${suffix}`;
331
+ return [
332
+ {
333
+ id: `${domain}.email-subscriptions.list`,
334
+ kind: "operation",
335
+ domain,
336
+ safety: "read",
337
+ auth: true,
338
+ title: `List ${noun} email subscriptions`,
339
+ description: `List the connected mailboxes this ${noun} watches.`,
340
+ input: [idArg],
341
+ http: { method: "GET", path: base, operationId: `list${cap}EmailSubscriptions` },
342
+ cli: { command: [domain, "email-subscriptions", "list"], summary: "List email subscriptions", defaultOutput: "table" },
343
+ mcp: { tool: { name: tool("list"), title: "List email subscriptions", description: `List the connected mailboxes this ${noun} watches.` } }
344
+ },
345
+ {
346
+ id: `${domain}.email-subscriptions.create`,
347
+ kind: "operation",
348
+ domain,
349
+ safety: "write",
350
+ auth: true,
351
+ title: `Subscribe ${article(noun)} ${noun} to a mailbox`,
352
+ description: `Watch a connected Gmail mailbox for this ${noun}; Google pushes notifications directly to the delivery URL.`,
353
+ input: [
354
+ idArg,
355
+ { name: "emailIntegrationId", location: "body", type: "string", required: true, description: "The connected Gmail integration id." },
356
+ { name: "deliveryUrl", location: "body", type: "string", required: true, description: "https URL on the target's primary hostname." }
357
+ ],
358
+ http: { method: "POST", path: base, operationId: `create${cap}EmailSubscription`, bodyMode: "json" },
359
+ cli: { command: [domain, "email-subscriptions", "create"], summary: "Subscribe to a mailbox", defaultOutput: "detail" },
360
+ mcp: { tool: { name: tool("create"), title: "Subscribe to a mailbox", description: `Watch a connected Gmail mailbox for this ${noun}.` } }
361
+ },
362
+ {
363
+ id: `${domain}.email-subscriptions.get`,
364
+ kind: "operation",
365
+ domain,
366
+ safety: "read",
367
+ auth: true,
368
+ title: `Get ${article(noun)} ${noun} email subscription`,
369
+ description: "Get one email subscription by id.",
370
+ input: [idArg, subArg],
371
+ http: { method: "GET", path: `${base}/{subscriptionId}`, operationId: `get${cap}EmailSubscription` },
372
+ cli: { command: [domain, "email-subscriptions", "get"], summary: "Get an email subscription", defaultOutput: "detail" },
373
+ mcp: { tool: { name: tool("get"), title: "Get an email subscription", description: "Get one email subscription by id." } }
374
+ },
375
+ {
376
+ id: `${domain}.email-subscriptions.delete`,
377
+ kind: "operation",
378
+ domain,
379
+ safety: "destructive",
380
+ auth: true,
381
+ title: `Unsubscribe ${article(noun)} ${noun} from a mailbox`,
382
+ description: "Remove an email subscription (deletes the Pub/Sub push subscription).",
383
+ input: [idArg, subArg],
384
+ http: { method: "DELETE", path: `${base}/{subscriptionId}`, operationId: `delete${cap}EmailSubscription` },
385
+ cli: { command: [domain, "email-subscriptions", "delete"], summary: "Unsubscribe from a mailbox" },
386
+ mcp: { tool: { name: tool("delete"), title: "Unsubscribe from a mailbox", description: "Remove an email subscription. Requires confirmation." } }
387
+ }
388
+ ];
389
+ }
299
390
  function getCapability(id) {
300
391
  return capabilitiesById.get(id);
301
392
  }
@@ -317,7 +408,7 @@ var idField = (name, description) => ({
317
408
  type: "string",
318
409
  required: true,
319
410
  description
320
- }), article = (noun) => /^[aeiou]/i.test(noun) ? "an" : "a", identityCapabilities, databaseCapabilities, fileCapabilities, folderCapabilities, contextCapabilities, appsCapabilities, pipelinesCapabilities, capabilities, khotanCatalog, capabilitiesById;
411
+ }), article = (noun) => /^[aeiou]/i.test(noun) ? "an" : "a", identityCapabilities, databaseCapabilities, redisCapabilities, fileCapabilities, folderCapabilities, contextCapabilities, appsCapabilities, pipelinesCapabilities, integrationsCapabilities, emailSubscriptionCapabilities, capabilities, khotanCatalog, capabilitiesById;
321
412
  var init_catalog = __esm(() => {
322
413
  init_version();
323
414
  identityCapabilities = [
@@ -556,6 +647,54 @@ var init_catalog = __esm(() => {
556
647
  cli: { command: ["databases", "branches", "rotate-credentials"], summary: "Rotate branch credentials (secret)", defaultOutput: "raw" },
557
648
  mcp: { tool: { name: "khotan_databases_branches_rotate_credentials", title: "Rotate branch credentials (secret)", description: "Rotate a branch's credentials and return the new connection. Secret-bearing." } }
558
649
  },
650
+ {
651
+ id: "databases.schema-promotions.plan",
652
+ kind: "operation",
653
+ domain: "databases",
654
+ safety: "write",
655
+ auth: true,
656
+ title: "Plan a database schema promotion",
657
+ description: "Generate and validate a schema-only promotion plan from a ready branch to the production database. Data is never copied.",
658
+ input: [
659
+ idField("databaseId", "The database id."),
660
+ idField("branchId", "The branch id.")
661
+ ],
662
+ http: { method: "POST", path: "/api/v1/databases/{databaseId}/branches/{branchId}/schema-promotions", operationId: "planDatabaseSchemaPromotion", bodyMode: "none" },
663
+ cli: { command: ["databases", "schema-promotions", "plan"], summary: "Plan a schema-only promotion", defaultOutput: "detail" },
664
+ mcp: { tool: { name: "khotan_databases_schema_promotions_plan", title: "Plan a schema promotion", description: "Generate and validate a schema-only promotion plan from a ready branch to production. Data is never copied." } }
665
+ },
666
+ {
667
+ id: "databases.schema-promotions.get",
668
+ kind: "operation",
669
+ domain: "databases",
670
+ safety: "read",
671
+ auth: true,
672
+ title: "Get a database schema promotion",
673
+ description: "Read one schema promotion record, including summary, blocked changes, failure state, and SQL.",
674
+ input: [
675
+ idField("databaseId", "The database id."),
676
+ idField("promotionId", "The schema promotion id.")
677
+ ],
678
+ http: { method: "GET", path: "/api/v1/databases/{databaseId}/schema-promotions/{promotionId}", operationId: "getDatabaseSchemaPromotion" },
679
+ cli: { command: ["databases", "schema-promotions", "get"], summary: "Get a schema promotion", defaultOutput: "detail" },
680
+ mcp: { tool: { name: "khotan_databases_schema_promotions_get", title: "Get a schema promotion", description: "Read one schema promotion record, including generated SQL." } }
681
+ },
682
+ {
683
+ id: "databases.schema-promotions.apply",
684
+ kind: "operation",
685
+ domain: "databases",
686
+ safety: "destructive",
687
+ auth: true,
688
+ title: "Apply a database schema promotion",
689
+ description: "Apply the exact reviewed schema-only promotion SQL to the production database. Fails if branch or production schema hashes changed after planning.",
690
+ input: [
691
+ idField("databaseId", "The database id."),
692
+ idField("promotionId", "The schema promotion id.")
693
+ ],
694
+ http: { method: "POST", path: "/api/v1/databases/{databaseId}/schema-promotions/{promotionId}/apply", operationId: "applyDatabaseSchemaPromotion", bodyMode: "none" },
695
+ cli: { command: ["databases", "schema-promotions", "apply"], summary: "Apply a schema promotion" },
696
+ mcp: { tool: { name: "khotan_databases_schema_promotions_apply", title: "Apply a schema promotion", description: "Apply reviewed schema-only SQL to production. Requires confirmation." } }
697
+ },
559
698
  {
560
699
  id: "databases.branches.resource",
561
700
  kind: "resource",
@@ -581,6 +720,84 @@ var init_catalog = __esm(() => {
581
720
  }
582
721
  }
583
722
  ];
723
+ redisCapabilities = [
724
+ {
725
+ id: "redis.list",
726
+ kind: "operation",
727
+ domain: "redis",
728
+ safety: "read",
729
+ auth: true,
730
+ title: "List Redis databases",
731
+ description: "List the organization's Redis (KV) databases (no credentials).",
732
+ input: [],
733
+ http: { method: "GET", path: "/api/v1/redis-databases", operationId: "listRedisDatabases" },
734
+ cli: { command: ["redis", "list"], summary: "List Redis (KV) databases", defaultOutput: "table" },
735
+ mcp: { tool: { name: "khotan_redis_list", title: "List Redis databases", description: "List the organization's Redis (KV) databases (no credentials)." } }
736
+ },
737
+ {
738
+ id: "redis.get",
739
+ kind: "operation",
740
+ domain: "redis",
741
+ safety: "read",
742
+ auth: true,
743
+ title: "Get a Redis database",
744
+ description: "Get one Redis (KV) database by id (no credentials).",
745
+ input: [idField("databaseId", "The Redis database id.")],
746
+ http: { method: "GET", path: "/api/v1/redis-databases/{databaseId}", operationId: "getRedisDatabase" },
747
+ cli: { command: ["redis", "get"], summary: "Get a Redis (KV) database", defaultOutput: "detail" },
748
+ mcp: { tool: { name: "khotan_redis_get", title: "Get a Redis database", description: "Get one Redis (KV) database by id (no credentials)." } }
749
+ },
750
+ {
751
+ id: "redis.create",
752
+ kind: "operation",
753
+ domain: "redis",
754
+ safety: "write",
755
+ auth: true,
756
+ title: "Create a Redis database",
757
+ description: "Provision a global Upstash Redis (KV) database. Not idempotent: retrying a succeeded create provisions a second database. Omit primaryRegion for the default region.",
758
+ input: [
759
+ { name: "name", location: "body", type: "string", required: true, description: "The database name." },
760
+ { name: "primaryRegion", location: "body", type: "string", description: "Optional primary region id; omit for the default region." }
761
+ ],
762
+ http: { method: "POST", path: "/api/v1/redis-databases", operationId: "createRedisDatabase", bodyMode: "json" },
763
+ cli: { command: ["redis", "create"], summary: "Create a Redis (KV) database", defaultOutput: "detail" },
764
+ mcp: { tool: { name: "khotan_redis_create", title: "Create a Redis database", description: "Provision a global Upstash Redis (KV) database. Not idempotent — retrying provisions a second database." } }
765
+ },
766
+ {
767
+ id: "redis.delete",
768
+ kind: "operation",
769
+ domain: "redis",
770
+ safety: "destructive",
771
+ auth: true,
772
+ title: "Delete a Redis database",
773
+ description: "Permanently delete a Redis (KV) database and tear down its Upstash resources. Fails if deletion protection is enabled; remove protection from the dashboard first.",
774
+ input: [idField("databaseId", "The Redis database id.")],
775
+ http: { method: "DELETE", path: "/api/v1/redis-databases/{databaseId}", operationId: "deleteRedisDatabase" },
776
+ cli: { command: ["redis", "delete"], summary: "Delete a Redis (KV) database" },
777
+ mcp: { tool: { name: "khotan_redis_delete", title: "Delete a Redis database", description: "Permanently delete a Redis (KV) database. Requires confirmation; fails if deletion protection is enabled." } }
778
+ },
779
+ {
780
+ id: "redis.resource",
781
+ kind: "resource",
782
+ domain: "redis",
783
+ safety: "read",
784
+ auth: true,
785
+ title: "Redis database summary",
786
+ description: "Credential-free Redis (KV) database summary addressed by id.",
787
+ input: [idField("databaseId", "The Redis database id.")],
788
+ readsVia: "redis.get",
789
+ http: { method: "GET", path: "/api/v1/redis-databases/{databaseId}", operationId: "getRedisDatabase" },
790
+ mcp: {
791
+ resource: {
792
+ uriTemplate: "khotan://redis/{databaseId}",
793
+ name: "redis-database-summary",
794
+ title: "Redis database summary",
795
+ description: "Credential-free Redis (KV) database summary as JSON.",
796
+ mimeType: "application/json"
797
+ }
798
+ }
799
+ }
800
+ ];
584
801
  fileCapabilities = [
585
802
  {
586
803
  id: "files.list",
@@ -947,14 +1164,127 @@ var init_catalog = __esm(() => {
947
1164
  envDelete: "deletePipelineEnvVar"
948
1165
  }
949
1166
  });
1167
+ integrationsCapabilities = [
1168
+ {
1169
+ id: "integrations.list",
1170
+ kind: "operation",
1171
+ domain: "integrations",
1172
+ safety: "read",
1173
+ auth: true,
1174
+ title: "List integrations",
1175
+ description: "List the organization's connected mailbox integrations (no token material).",
1176
+ input: [],
1177
+ http: { method: "GET", path: "/api/v1/integrations", operationId: "listIntegrations" },
1178
+ cli: { command: ["integrations", "list"], summary: "List integrations", defaultOutput: "table" },
1179
+ mcp: { tool: { name: "khotan_integrations_list", title: "List integrations", description: "List the organization's connected mailbox integrations (no token material)." } }
1180
+ },
1181
+ {
1182
+ id: "integrations.get",
1183
+ kind: "operation",
1184
+ domain: "integrations",
1185
+ safety: "read",
1186
+ auth: true,
1187
+ title: "Get an integration",
1188
+ description: "Get one integration by id (no token material).",
1189
+ input: [idField("integrationId", "The integration id.")],
1190
+ http: { method: "GET", path: "/api/v1/integrations/{integrationId}", operationId: "getIntegration" },
1191
+ cli: { command: ["integrations", "get"], summary: "Get an integration", defaultOutput: "detail" },
1192
+ mcp: { tool: { name: "khotan_integrations_get", title: "Get an integration", description: "Get one integration by id (no token material)." } }
1193
+ },
1194
+ {
1195
+ id: "integrations.connect",
1196
+ kind: "operation",
1197
+ domain: "integrations",
1198
+ safety: "write",
1199
+ auth: true,
1200
+ title: "Start connecting an integration",
1201
+ description: "Begin a mailbox connection and return a URL a human opens in a browser to authorize (OAuth consent cannot be completed headlessly).",
1202
+ input: [
1203
+ { name: "provider", location: "body", type: "string", description: "The provider to connect. Defaults to google.", default: "google" }
1204
+ ],
1205
+ http: { method: "POST", path: "/api/v1/integrations", operationId: "connectIntegration", bodyMode: "json" },
1206
+ cli: { command: ["integrations", "connect"], summary: "Start connecting a mailbox", defaultOutput: "detail" },
1207
+ mcp: { tool: { name: "khotan_integrations_connect", title: "Start connecting an integration", description: "Begin a mailbox connection and return a browser URL to complete OAuth consent." } }
1208
+ },
1209
+ {
1210
+ id: "integrations.rename",
1211
+ kind: "operation",
1212
+ domain: "integrations",
1213
+ safety: "write",
1214
+ auth: true,
1215
+ title: "Rename an integration",
1216
+ description: "Set an integration's display name. Pass an empty value to clear it.",
1217
+ input: [
1218
+ idField("integrationId", "The integration id."),
1219
+ { name: "name", location: "body", type: "string", required: true, description: "The display name (empty to clear)." }
1220
+ ],
1221
+ http: { method: "PATCH", path: "/api/v1/integrations/{integrationId}", operationId: "updateIntegration", bodyMode: "json" },
1222
+ cli: { command: ["integrations", "rename"], summary: "Rename an integration", defaultOutput: "detail" },
1223
+ mcp: { tool: { name: "khotan_integrations_rename", title: "Rename an integration", description: "Set an integration's display name." } }
1224
+ },
1225
+ {
1226
+ id: "integrations.delete",
1227
+ kind: "operation",
1228
+ domain: "integrations",
1229
+ safety: "destructive",
1230
+ auth: true,
1231
+ title: "Disconnect an integration",
1232
+ description: "Permanently disconnect a mailbox integration (revokes the stored credentials).",
1233
+ input: [idField("integrationId", "The integration id.")],
1234
+ http: { method: "DELETE", path: "/api/v1/integrations/{integrationId}", operationId: "deleteIntegration" },
1235
+ cli: { command: ["integrations", "delete"], summary: "Disconnect an integration" },
1236
+ mcp: { tool: { name: "khotan_integrations_delete", title: "Disconnect an integration", description: "Permanently disconnect a mailbox integration. Requires confirmation." } }
1237
+ },
1238
+ {
1239
+ id: "integrations.token",
1240
+ kind: "operation",
1241
+ domain: "integrations",
1242
+ safety: "secret",
1243
+ auth: true,
1244
+ title: "Mint an access token (secret)",
1245
+ description: "Mint a fresh OAuth access token for the integration so a service can call the provider on the user's behalf. Secret-bearing; explicit-only.",
1246
+ input: [idField("integrationId", "The integration id.")],
1247
+ http: { method: "POST", path: "/api/v1/integrations/{integrationId}/token", operationId: "mintIntegrationToken", bodyMode: "none" },
1248
+ cli: { command: ["integrations", "token"], summary: "Mint an access token (secret)", defaultOutput: "detail" },
1249
+ mcp: { tool: { name: "khotan_integrations_token", title: "Mint an access token (secret)", description: "Mint a fresh OAuth access token for the integration. Secret-bearing — returns a credential." } }
1250
+ },
1251
+ {
1252
+ id: "integrations.resource",
1253
+ kind: "resource",
1254
+ domain: "integrations",
1255
+ safety: "read",
1256
+ auth: true,
1257
+ title: "Integration summary",
1258
+ description: "Credential-free integration summary addressed by id.",
1259
+ input: [idField("integrationId", "The integration id.")],
1260
+ readsVia: "integrations.get",
1261
+ http: { method: "GET", path: "/api/v1/integrations/{integrationId}", operationId: "getIntegration" },
1262
+ mcp: {
1263
+ resource: {
1264
+ uriTemplate: "khotan://integrations/{integrationId}",
1265
+ name: "integration-summary",
1266
+ title: "Integration summary",
1267
+ description: "Credential-free integration summary as JSON.",
1268
+ mimeType: "application/json"
1269
+ }
1270
+ }
1271
+ }
1272
+ ];
1273
+ emailSubscriptionCapabilities = [
1274
+ ...emailSubscriptionDomain({ domain: "apps", noun: "deployment", resourceParam: "appId", cap: "App" }),
1275
+ ...emailSubscriptionDomain({ domain: "pipelines", noun: "pipeline", resourceParam: "pipelineId", cap: "Pipeline" })
1276
+ ];
950
1277
  capabilities = [
951
1278
  ...identityCapabilities,
952
1279
  ...appsCapabilities,
953
1280
  ...pipelinesCapabilities,
954
1281
  ...databaseCapabilities,
1282
+ ...redisCapabilities,
955
1283
  ...fileCapabilities,
956
1284
  ...folderCapabilities,
957
- ...contextCapabilities
1285
+ ...contextCapabilities,
1286
+ ...integrationsCapabilities,
1287
+ ...emailSubscriptionCapabilities
958
1288
  ];
959
1289
  khotanCatalog = {
960
1290
  schemaVersion: CATALOG_SCHEMA_VERSION,
@@ -1315,6 +1645,66 @@ var init_profiles = __esm(() => {
1315
1645
  init_errors();
1316
1646
  });
1317
1647
 
1648
+ // ../khotan-core/src/profiles/repo-env.ts
1649
+ import { existsSync, readFileSync as readFileSync2 } from "node:fs";
1650
+ import { dirname as dirname2, join as join2 } from "node:path";
1651
+ function findRepoEnvFile(startDir) {
1652
+ let dir = startDir;
1653
+ for (;; ) {
1654
+ const candidate = join2(dir, REPO_ENV_FILENAME);
1655
+ if (existsSync(candidate))
1656
+ return candidate;
1657
+ if (existsSync(join2(dir, ".git")))
1658
+ return;
1659
+ const parent = dirname2(dir);
1660
+ if (parent === dir)
1661
+ return;
1662
+ dir = parent;
1663
+ }
1664
+ }
1665
+ function parseRepoEnvFile(contents) {
1666
+ const map = new Map;
1667
+ for (const rawLine of contents.split(`
1668
+ `)) {
1669
+ const line = rawLine.trim();
1670
+ if (!line || line.startsWith("#"))
1671
+ continue;
1672
+ const eq = line.indexOf("=");
1673
+ if (eq === -1)
1674
+ continue;
1675
+ const key = line.slice(0, eq).trim();
1676
+ let value = line.slice(eq + 1).trim();
1677
+ if (value.startsWith("'") && value.endsWith("'") || value.startsWith('"') && value.endsWith('"')) {
1678
+ value = value.slice(1, -1);
1679
+ }
1680
+ map.set(key, value);
1681
+ }
1682
+ return map;
1683
+ }
1684
+ function overlayRepoEnv(env, options) {
1685
+ const filePath = findRepoEnvFile(options.cwd);
1686
+ if (!filePath)
1687
+ return { env };
1688
+ let parsed;
1689
+ try {
1690
+ parsed = parseRepoEnvFile(readFileSync2(filePath, "utf8"));
1691
+ } catch {
1692
+ return { env, filePath };
1693
+ }
1694
+ const merged = { ...env };
1695
+ for (const key of OVERLAY_KEYS) {
1696
+ const value = parsed.get(key)?.trim();
1697
+ if (value)
1698
+ merged[key] = value;
1699
+ }
1700
+ return { env: merged, filePath, orgId: merged[ENV_ORG_ID]?.trim() || undefined };
1701
+ }
1702
+ var ENV_ORG_ID = "KHOTAN_ORG_ID", REPO_ENV_FILENAME = "env.khotan.local", OVERLAY_KEYS;
1703
+ var init_repo_env = __esm(() => {
1704
+ init_profiles();
1705
+ OVERLAY_KEYS = [ENV_API_URL, ENV_API_KEY, ENV_PROFILE, ENV_ORG_ID];
1706
+ });
1707
+
1318
1708
  // ../khotan-core/src/index.ts
1319
1709
  var exports_src = {};
1320
1710
  __export(exports_src, {
@@ -1325,6 +1715,8 @@ __export(exports_src, {
1325
1715
  saveProfileStore: () => saveProfileStore,
1326
1716
  resolveProfile: () => resolveProfile,
1327
1717
  requiresConfirmation: () => requiresConfirmation,
1718
+ parseRepoEnvFile: () => parseRepoEnvFile,
1719
+ overlayRepoEnv: () => overlayRepoEnv,
1328
1720
  loadProfileStore: () => loadProfileStore,
1329
1721
  listDomains: () => listDomains,
1330
1722
  listCapabilities: () => listCapabilities,
@@ -1338,12 +1730,14 @@ __export(exports_src, {
1338
1730
  indexOpenApiOperations: () => indexOpenApiOperations,
1339
1731
  getProfileStorePath: () => getProfileStorePath,
1340
1732
  getCapability: () => getCapability,
1733
+ findRepoEnvFile: () => findRepoEnvFile,
1341
1734
  createApiClient: () => createApiClient,
1342
1735
  capabilitiesByOperationId: () => capabilitiesByOperationId,
1343
1736
  buildHttpRequest: () => buildHttpRequest,
1344
1737
  assertSupportedCatalogSchemaVersion: () => assertSupportedCatalogSchemaVersion,
1345
1738
  SUPPORTED_CATALOG_SCHEMA_VERSIONS: () => SUPPORTED_CATALOG_SCHEMA_VERSIONS,
1346
1739
  SAFETY_LEVELS: () => SAFETY_LEVELS,
1740
+ REPO_ENV_FILENAME: () => REPO_ENV_FILENAME,
1347
1741
  KhotanUnknownCapabilityError: () => KhotanUnknownCapabilityError,
1348
1742
  KhotanError: () => KhotanError,
1349
1743
  KhotanConfirmationRequiredError: () => KhotanConfirmationRequiredError,
@@ -1353,6 +1747,7 @@ __export(exports_src, {
1353
1747
  KHOTAN_ADAPTER_VERSION: () => KHOTAN_ADAPTER_VERSION,
1354
1748
  KHOTAN_ADAPTER_NAME: () => KHOTAN_ADAPTER_NAME,
1355
1749
  ENV_PROFILE: () => ENV_PROFILE,
1750
+ ENV_ORG_ID: () => ENV_ORG_ID,
1356
1751
  ENV_API_URL: () => ENV_API_URL,
1357
1752
  ENV_API_KEY: () => ENV_API_KEY,
1358
1753
  CATALOG_VERSION: () => CATALOG_VERSION,
@@ -1366,6 +1761,7 @@ var init_src = __esm(() => {
1366
1761
  init_validate();
1367
1762
  init_api_client();
1368
1763
  init_profiles();
1764
+ init_repo_env();
1369
1765
  });
1370
1766
 
1371
1767
  // src/cli/io.ts
@@ -1402,6 +1798,65 @@ function errLine(io, line = "") {
1402
1798
  }
1403
1799
  var init_io = () => {};
1404
1800
 
1801
+ // src/cli/assert-org.ts
1802
+ import { createHash } from "node:crypto";
1803
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "node:fs";
1804
+ import { homedir as homedir2 } from "node:os";
1805
+ import { dirname as dirname4, join as join5 } from "node:path";
1806
+ function cacheDir(env) {
1807
+ const xdg = env.XDG_CACHE_HOME;
1808
+ const base = xdg && xdg.trim() ? xdg : join5(homedir2(), ".cache");
1809
+ return join5(base, "khotan");
1810
+ }
1811
+ function cacheFilePath(env, apiUrl, orgId) {
1812
+ const hash = createHash("sha256").update(`${apiUrl}
1813
+ ${orgId}`).digest("hex").slice(0, 32);
1814
+ return join5(cacheDir(env), `org-assert-${hash}.json`);
1815
+ }
1816
+ function readAssertionCache(path, expectedOrgId) {
1817
+ if (!existsSync3(path))
1818
+ return false;
1819
+ try {
1820
+ const parsed = JSON.parse(readFileSync4(path, "utf8"));
1821
+ if (parsed.organizationId !== expectedOrgId)
1822
+ return false;
1823
+ if (Date.now() - parsed.verifiedAt > CACHE_TTL_MS)
1824
+ return false;
1825
+ return true;
1826
+ } catch {
1827
+ return false;
1828
+ }
1829
+ }
1830
+ function writeAssertionCache(path, organizationId) {
1831
+ try {
1832
+ mkdirSync3(dirname4(path), { recursive: true });
1833
+ const payload = { organizationId, verifiedAt: Date.now() };
1834
+ writeFileSync4(path, `${JSON.stringify(payload)}
1835
+ `);
1836
+ } catch {}
1837
+ }
1838
+ async function assertOrg(session, expectedOrgId, options = {}) {
1839
+ const expected = expectedOrgId.trim();
1840
+ if (!expected)
1841
+ return;
1842
+ const env = options.env ?? process.env;
1843
+ const cachePath = cacheFilePath(env, session.apiUrl, expected);
1844
+ if (!options.skipCache && readAssertionCache(cachePath, expected)) {
1845
+ return;
1846
+ }
1847
+ const principal = await session.client.execute("identity.whoami");
1848
+ const actual = String(principal.organizationId ?? "");
1849
+ if (actual !== expected) {
1850
+ throw new KhotanClientError("org_mismatch", `Khotan org mismatch: expected organizationId=${expected} (KHOTAN_ORG_ID / --assert-org), ` + `but whoami returned organizationId=${actual || "(empty)"}. ` + `You may be authenticated to a different customer's org. ` + `Fix credentials in env.khotan.local, pass --assert-org, or select a per-customer profile.`);
1851
+ }
1852
+ writeAssertionCache(cachePath, actual);
1853
+ }
1854
+ var CACHE_TTL_MS;
1855
+ var init_assert_org = __esm(() => {
1856
+ init_src();
1857
+ CACHE_TTL_MS = 5 * 60 * 1000;
1858
+ });
1859
+
1405
1860
  // src/cli/session.ts
1406
1861
  function resolveSession(options) {
1407
1862
  const resolved = resolveProfile({
@@ -1421,8 +1876,17 @@ function resolveSession(options) {
1421
1876
  apiKey
1422
1877
  };
1423
1878
  }
1879
+ async function resolveSessionAsserted(options) {
1880
+ const session = resolveSession(options);
1881
+ const expectedOrgId = options.assertOrgOverride?.trim() || options.env[ENV_ORG_ID]?.trim();
1882
+ if (expectedOrgId) {
1883
+ await assertOrg(session, expectedOrgId, { env: options.env });
1884
+ }
1885
+ return session;
1886
+ }
1424
1887
  var init_session = __esm(() => {
1425
1888
  init_src();
1889
+ init_assert_org();
1426
1890
  });
1427
1891
 
1428
1892
  // src/mcp/protocol.ts
@@ -1673,13 +2137,14 @@ __export(exports_serve, {
1673
2137
  import { createInterface as createInterface2 } from "node:readline";
1674
2138
  async function serveMcp(deps) {
1675
2139
  assertSupportedCatalogSchemaVersion();
1676
- const session = resolveSession({
2140
+ const session = await resolveSessionAsserted({
1677
2141
  env: deps.env,
1678
2142
  storeOptions: deps.storeOptions,
1679
2143
  profileName: deps.profileName,
1680
2144
  apiUrlOverride: deps.apiUrlOverride,
1681
2145
  apiKeyOverride: deps.apiKeyOverride,
1682
- fetch: deps.fetch
2146
+ fetch: deps.fetch,
2147
+ assertOrgOverride: deps.assertOrgOverride
1683
2148
  });
1684
2149
  if (!session.apiKey) {
1685
2150
  throw new KhotanClientError("not_authenticated", "The MCP server needs an API key. Run `khotan auth set-key` or set KHOTAN_API_KEY.");
@@ -2053,8 +2518,8 @@ async function downloadFile(deps, options) {
2053
2518
 
2054
2519
  // src/cli/commands/init.ts
2055
2520
  init_io();
2056
- import { existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
2057
- import { dirname as dirname2, join as join2, relative } from "node:path";
2521
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readdirSync, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
2522
+ import { dirname as dirname3, join as join3, relative } from "node:path";
2058
2523
 
2059
2524
  // src/cli/skills.ts
2060
2525
  init_src();
@@ -2161,9 +2626,11 @@ var DOMAIN_TITLES = {
2161
2626
  apps: "Apps",
2162
2627
  pipelines: "Pipelines",
2163
2628
  databases: "Databases",
2629
+ redis: "Redis (KV)",
2164
2630
  files: "Files",
2165
2631
  folders: "Folders",
2166
- context: "Context documents"
2632
+ context: "Context documents",
2633
+ integrations: "Integrations"
2167
2634
  };
2168
2635
  function commandWords(capability) {
2169
2636
  return capability.cli?.command.join(" ");
@@ -2229,6 +2696,8 @@ function printTopLevelHelp(io) {
2229
2696
  outLine(io, " files download <fileId> Download a file to --output (presigned flow)");
2230
2697
  outLine(io);
2231
2698
  outLine(io, "Workspace:");
2699
+ outLine(io, " status Show the workspace topology (apps, pipelines, databases)");
2700
+ outLine(io, " status --write Refresh a committable WORKSPACE.md from live data");
2232
2701
  outLine(io, " init Scaffold MCP config + agent guidance into this workspace");
2233
2702
  outLine(io);
2234
2703
  outLine(io, "Agent:");
@@ -2240,10 +2709,33 @@ function printTopLevelHelp(io) {
2240
2709
  outLine(io, " --profile <name> Use a specific stored profile");
2241
2710
  outLine(io, " --api-url <url> Override the API origin");
2242
2711
  outLine(io, " --api-key <key> Override the API key");
2712
+ outLine(io, " --assert-org <id> Require whoami.organizationId to equal <id> (fail closed)");
2243
2713
  outLine(io, " --help Show help for a command");
2244
2714
  outLine(io);
2245
2715
  outLine(io, `khotan v${KHOTAN_ADAPTER_VERSION}`);
2246
2716
  }
2717
+ function groupCommands(prefix) {
2718
+ if (prefix.length === 0) {
2719
+ return [];
2720
+ }
2721
+ return listCapabilities().filter((capability) => {
2722
+ if (!isOperationCapability(capability) || !capability.cli) {
2723
+ return false;
2724
+ }
2725
+ const words = capability.cli.command;
2726
+ return words.length > prefix.length && prefix.every((word, index) => words[index] === word);
2727
+ });
2728
+ }
2729
+ function printGroupHelp(io, prefix, commands) {
2730
+ outLine(io, `khotan ${prefix.join(" ")} — commands`);
2731
+ outLine(io);
2732
+ for (const capability of commands) {
2733
+ const words = commandWords(capability) ?? capability.id;
2734
+ outLine(io, ` ${words.padEnd(30)} ${capability.cli?.summary ?? capability.title}`);
2735
+ }
2736
+ outLine(io);
2737
+ outLine(io, "Run `khotan <command> --help` for details.");
2738
+ }
2247
2739
  function printCommandHelp(io, capability) {
2248
2740
  outLine(io, capability.title);
2249
2741
  outLine(io);
@@ -2276,6 +2768,7 @@ var DOMAIN_TITLES2 = {
2276
2768
  apps: "Apps",
2277
2769
  pipelines: "Pipelines",
2278
2770
  databases: "Databases",
2771
+ redis: "Redis (KV)",
2279
2772
  files: "Files",
2280
2773
  folders: "Folders",
2281
2774
  context: "Context documents"
@@ -2284,6 +2777,7 @@ var DOMAIN_LABELS = {
2284
2777
  apps: "apps",
2285
2778
  pipelines: "pipelines",
2286
2779
  databases: "databases and database branches",
2780
+ redis: "Redis (KV) databases",
2287
2781
  files: "files",
2288
2782
  folders: "folders",
2289
2783
  context: "context documents"
@@ -2458,32 +2952,54 @@ var SKILL_BASES = [".cursor", ".agents", ".claude"];
2458
2952
  function mcpServerEntry() {
2459
2953
  return {
2460
2954
  command: "khotan",
2461
- args: ["mcp", "serve"],
2462
- env: {
2463
- KHOTAN_API_URL: "<your-khotan-api-url>",
2464
- KHOTAN_API_KEY: "<your-khotan-api-key>"
2465
- }
2955
+ args: ["mcp", "serve"]
2466
2956
  };
2467
2957
  }
2468
2958
  var GUIDE_BODY = `This workspace is wired to Khotan through the \`khotan\` CLI and its MCP server.
2469
2959
  Use them to inspect and configure apps, pipelines, databases, files, folders, and
2470
2960
  context documents through the Khotan \`/api/v1\` surface.
2471
2961
 
2962
+ ## Org isolation (never cross customers)
2963
+
2964
+ Khotan credentials are machine-global unless scoped per repo. Pin this repo to
2965
+ its organization with \`KHOTAN_ORG_ID\` in \`env.khotan.local\`: the CLI and MCP
2966
+ server load that file and assert \`whoami.organizationId === KHOTAN_ORG_ID\`
2967
+ before trusting any output, failing closed on mismatch. You can also pass
2968
+ \`--assert-org <id>\` on any command.
2969
+
2970
+ If \`apps list\` / \`context list\` returns another customer's data (foreign product
2971
+ names, wrong org slug), **stop** — credentials are wrong. Do not infer
2972
+ deployment or env from CLI output until the org assertion passes.
2973
+
2472
2974
  ## Authentication (do this once — never commit secrets)
2473
2975
 
2474
- Khotan reads an organization-scoped API key from a stored profile or the
2475
- environment:
2976
+ Repo credentials live in \`env.khotan.local\` (\`KHOTAN_API_URL\`, \`KHOTAN_API_KEY\`,
2977
+ \`KHOTAN_ORG_ID\`), which the CLI auto-loads. Alternatively:
2476
2978
 
2477
- - Profile (recommended): \`khotan auth set-key --api-url <url> --api-key <key>\`
2478
- stores the key in an owner-only file outside the repository.
2479
- - Environment: export \`KHOTAN_API_URL\` and \`KHOTAN_API_KEY\` in your shell or
2480
- agent sandbox.
2979
+ - Profile: \`khotan auth set-key --api-url <url> --api-key <key>\` stores the key in
2980
+ an owner-only file outside the repository (use a per-customer profile name, not
2981
+ \`default\`).
2982
+ - Environment: export \`KHOTAN_API_URL\`, \`KHOTAN_API_KEY\`, and \`KHOTAN_ORG_ID\`.
2481
2983
 
2482
2984
  Never put an API key in a file committed to the repository. The generated MCP
2483
- config contains only placeholders.
2985
+ config carries no secrets — the server resolves credentials at startup.
2986
+
2987
+ ## Workspace layout
2988
+
2989
+ A Khotan workspace is an **organization mono-root repo**: one GitHub repo whose
2990
+ top-level subdirectories are each an independent deployable (an app or a
2991
+ pipeline). The repo root holds shared agent skills and instruction files;
2992
+ per-app guidance lives in each subdirectory's own \`AGENTS.md\` / \`CLAUDE.md\`.
2993
+
2994
+ - Run \`khotan status\` to see which subdirectory maps to which app/pipeline and
2995
+ database, and which repo it deploys from. \`khotan status --write\` refreshes a
2996
+ committable \`WORKSPACE.md\` with that map.
2997
+ - Each app lives in its own subdirectory — **do not run app scaffolders (e.g.
2998
+ \`khotan-data init\`) at the repo root.** \`cd\` into the app's subdirectory first.
2484
2999
 
2485
3000
  ## CLI surface
2486
3001
 
3002
+ - \`khotan status\` — the workspace topology (apps, pipelines, databases) at a glance
2487
3003
  - \`khotan apps list|get|create|delete|redeploy\` and \`khotan apps env …\`
2488
3004
  - \`khotan pipelines …\`, \`khotan databases …\`
2489
3005
  - \`khotan files list|upload|download|…\`, \`khotan folders …\`
@@ -2503,7 +3019,9 @@ surface.
2503
3019
  ## MCP
2504
3020
 
2505
3021
  \`khotan mcp serve\` exposes the same operations as MCP tools and durable reads as
2506
- MCP resources, reusing your stored profile or environment credentials.
3022
+ MCP resources, scoping credentials from \`env.khotan.local\` (or your stored
3023
+ profile) and asserting the org at startup. Restart the MCP server after changing
3024
+ \`env.khotan.local\`.
2507
3025
  `;
2508
3026
  var CURSOR_RULES = `---
2509
3027
  description: Use the Khotan CLI and MCP tools to configure and operate this workspace's Khotan resources
@@ -2519,31 +3037,59 @@ ${GUIDE_BODY}`;
2519
3037
  function toRelative(cwd, absolutePath) {
2520
3038
  return relative(cwd, absolutePath) || absolutePath;
2521
3039
  }
3040
+ function detectMonoRoot(cwd) {
3041
+ if (hasWorkspacesManifest(join3(cwd, "package.json")))
3042
+ return true;
3043
+ if (existsSync2(join3(cwd, "turbo.json")))
3044
+ return true;
3045
+ if (existsSync2(join3(cwd, "WORKSPACE.md")))
3046
+ return true;
3047
+ if (existsSync2(join3(cwd, "package.json")))
3048
+ return false;
3049
+ try {
3050
+ return readdirSync(cwd, { withFileTypes: true }).some((entry) => entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules" && existsSync2(join3(cwd, entry.name, "package.json")));
3051
+ } catch {
3052
+ return false;
3053
+ }
3054
+ }
3055
+ function hasWorkspacesManifest(packageJsonPath) {
3056
+ if (!existsSync2(packageJsonPath))
3057
+ return false;
3058
+ try {
3059
+ const parsed = JSON.parse(readFileSync3(packageJsonPath, "utf8"));
3060
+ if (typeof parsed !== "object" || parsed === null)
3061
+ return false;
3062
+ const workspaces = parsed.workspaces;
3063
+ return Array.isArray(workspaces) && workspaces.length > 0;
3064
+ } catch {
3065
+ return false;
3066
+ }
3067
+ }
2522
3068
  function writeTextAsset(ctx, absolutePath, content) {
2523
3069
  const path = toRelative(ctx.cwd, absolutePath);
2524
- const existed = existsSync(absolutePath);
3070
+ const existed = existsSync2(absolutePath);
2525
3071
  if (existed && !ctx.force) {
2526
3072
  ctx.results.push({ path, action: "skipped" });
2527
3073
  return;
2528
3074
  }
2529
- mkdirSync2(dirname2(absolutePath), { recursive: true });
3075
+ mkdirSync2(dirname3(absolutePath), { recursive: true });
2530
3076
  writeFileSync2(absolutePath, content);
2531
3077
  ctx.results.push({ path, action: existed ? "forced" : "created" });
2532
3078
  }
2533
3079
  function writeManagedAsset(ctx, absolutePath, content) {
2534
3080
  const path = toRelative(ctx.cwd, absolutePath);
2535
- const existed = existsSync(absolutePath);
2536
- mkdirSync2(dirname2(absolutePath), { recursive: true });
3081
+ const existed = existsSync2(absolutePath);
3082
+ mkdirSync2(dirname3(absolutePath), { recursive: true });
2537
3083
  writeFileSync2(absolutePath, content);
2538
3084
  ctx.results.push({ path, action: existed ? "updated" : "created" });
2539
3085
  }
2540
3086
  function writeMcpConfig(ctx, absolutePath) {
2541
3087
  const path = toRelative(ctx.cwd, absolutePath);
2542
3088
  let config = {};
2543
- const existed = existsSync(absolutePath);
3089
+ const existed = existsSync2(absolutePath);
2544
3090
  if (existed) {
2545
3091
  try {
2546
- config = JSON.parse(readFileSync2(absolutePath, "utf8"));
3092
+ config = JSON.parse(readFileSync3(absolutePath, "utf8"));
2547
3093
  } catch {
2548
3094
  ctx.results.push({ path, action: "skipped" });
2549
3095
  return;
@@ -2559,7 +3105,7 @@ function writeMcpConfig(ctx, absolutePath) {
2559
3105
  return;
2560
3106
  }
2561
3107
  servers[MCP_SERVER_KEY] = mcpServerEntry();
2562
- mkdirSync2(dirname2(absolutePath), { recursive: true });
3108
+ mkdirSync2(dirname3(absolutePath), { recursive: true });
2563
3109
  writeFileSync2(absolutePath, `${JSON.stringify(config, null, 2)}
2564
3110
  `);
2565
3111
  ctx.results.push({
@@ -2571,19 +3117,20 @@ function runInit(options) {
2571
3117
  const { io, cwd, client, force, json } = options;
2572
3118
  const ctx = { cwd, force, results: [] };
2573
3119
  if (client === "cursor") {
2574
- writeMcpConfig(ctx, join2(cwd, ".cursor", "mcp.json"));
2575
- writeTextAsset(ctx, join2(cwd, ".cursor", "rules", "khotan.mdc"), CURSOR_RULES);
3120
+ writeMcpConfig(ctx, join3(cwd, ".cursor", "mcp.json"));
3121
+ writeTextAsset(ctx, join3(cwd, ".cursor", "rules", "khotan.mdc"), CURSOR_RULES);
2576
3122
  } else {
2577
- writeMcpConfig(ctx, join2(cwd, "mcp.json"));
2578
- writeTextAsset(ctx, join2(cwd, "khotan-agents.md"), GENERIC_GUIDE);
3123
+ writeMcpConfig(ctx, join3(cwd, "mcp.json"));
3124
+ writeTextAsset(ctx, join3(cwd, "khotan-agents.md"), GENERIC_GUIDE);
2579
3125
  }
2580
3126
  for (const skill of generateSkills()) {
2581
3127
  for (const base of SKILL_BASES) {
2582
- writeManagedAsset(ctx, join2(cwd, base, "skills", skill.name, "SKILL.md"), skill.content);
3128
+ writeManagedAsset(ctx, join3(cwd, base, "skills", skill.name, "SKILL.md"), skill.content);
2583
3129
  }
2584
3130
  }
3131
+ const monoRoot = detectMonoRoot(cwd);
2585
3132
  if (json) {
2586
- io.out(`${JSON.stringify({ client, actions: ctx.results }, null, 2)}
3133
+ io.out(`${JSON.stringify({ client, monoRoot, actions: ctx.results }, null, 2)}
2587
3134
  `);
2588
3135
  return 0;
2589
3136
  }
@@ -2592,12 +3139,322 @@ function runInit(options) {
2592
3139
  errLine(io, ` ${result.action.padEnd(8)} ${result.path}`);
2593
3140
  }
2594
3141
  errLine(io);
3142
+ if (monoRoot) {
3143
+ errLine(io, "Detected a Khotan mono-root workspace (multiple apps under one repo).");
3144
+ errLine(io, "Each app lives in its own subdirectory — do NOT run app scaffolders");
3145
+ errLine(io, "(e.g. `khotan-data init`) at this root. `cd` into an app subdirectory");
3146
+ errLine(io, "first. Run `khotan status` to see the layout.");
3147
+ errLine(io);
3148
+ }
2595
3149
  errLine(io, "Next steps:");
2596
3150
  errLine(io, " 1. Authenticate: khotan auth set-key --api-url <url> --api-key <key>");
2597
3151
  errLine(io, " 2. Open this workspace in your agent and ask it to configure Khotan.");
2598
3152
  return 0;
2599
3153
  }
2600
3154
 
3155
+ // src/cli/commands/status.ts
3156
+ init_io();
3157
+ import { writeFileSync as writeFileSync3 } from "node:fs";
3158
+ import { join as join4, relative as relative2 } from "node:path";
3159
+ function str(value) {
3160
+ return typeof value === "string" && value.length > 0 ? value : null;
3161
+ }
3162
+ function record(value) {
3163
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
3164
+ }
3165
+ function listOf(result, key) {
3166
+ const container = record(result);
3167
+ const candidate = container && Array.isArray(container[key]) && container[key] || Array.isArray(result) && result || container && soleArrayProperty(container) || [];
3168
+ return candidate.map(record).filter((r) => r !== null);
3169
+ }
3170
+ function soleArrayProperty(container) {
3171
+ const arrays = Object.keys(container).filter((k) => Array.isArray(container[k]));
3172
+ return arrays.length === 1 ? container[arrays[0]] : null;
3173
+ }
3174
+ function toResourceRow(kind, raw) {
3175
+ const repo = record(raw.repo);
3176
+ return {
3177
+ kind,
3178
+ name: str(raw.displayName) ?? str(raw.slug) ?? "(unnamed)",
3179
+ slug: str(raw.slug) ?? "",
3180
+ status: str(raw.status) ?? "unknown",
3181
+ subdirectory: repo ? str(repo.subdirectory) : null,
3182
+ repo: repo ? str(repo.fullName) : null,
3183
+ branch: repo ? str(repo.defaultBranch) : null,
3184
+ hostname: str(raw.liveUrl) ?? str(raw.primaryHostname) ?? null
3185
+ };
3186
+ }
3187
+ function toDatabaseRow(raw) {
3188
+ return {
3189
+ name: str(raw.displayName) ?? "(unnamed)",
3190
+ status: str(raw.status) ?? "unknown",
3191
+ environment: str(raw.environment),
3192
+ region: str(raw.regionId),
3193
+ host: str(raw.endpointHost)
3194
+ };
3195
+ }
3196
+ function toRedisRow(raw) {
3197
+ return {
3198
+ name: str(raw.displayName) ?? "(unnamed)",
3199
+ status: str(raw.status) ?? "unknown",
3200
+ environment: null,
3201
+ region: str(raw.primaryRegion),
3202
+ host: str(raw.endpointHost)
3203
+ };
3204
+ }
3205
+ async function readList(session, capabilityId, domain, errors, map, responseKey = domain) {
3206
+ try {
3207
+ const result = await session.client.execute(capabilityId);
3208
+ return map(listOf(result, responseKey));
3209
+ } catch (error) {
3210
+ errors.push({
3211
+ domain,
3212
+ message: error instanceof Error ? error.message : String(error)
3213
+ });
3214
+ return [];
3215
+ }
3216
+ }
3217
+ async function loadTopology(session) {
3218
+ const errors = [];
3219
+ const apps = await readList(session, "apps.list", "apps", errors, (rows) => rows.map((r) => toResourceRow("app", r)));
3220
+ const pipelines = await readList(session, "pipelines.list", "pipelines", errors, (rows) => rows.map((r) => toResourceRow("pipeline", r)));
3221
+ const databases = await readList(session, "databases.list", "databases", errors, (rows) => rows.map(toDatabaseRow));
3222
+ const redisDatabases = await readList(session, "redis.list", "redis", errors, (rows) => rows.map(toRedisRow), "databases");
3223
+ const repoBearer = [...apps, ...pipelines].find((r) => r.repo);
3224
+ return {
3225
+ repo: repoBearer?.repo ?? null,
3226
+ defaultBranch: repoBearer?.branch ?? null,
3227
+ apps,
3228
+ pipelines,
3229
+ databases,
3230
+ redisDatabases,
3231
+ errors
3232
+ };
3233
+ }
3234
+ var EM_DASH = "—";
3235
+ function table(rows) {
3236
+ if (rows.length === 0) {
3237
+ return [" (none)"];
3238
+ }
3239
+ const columns = Object.keys(rows[0]);
3240
+ const widths = columns.map((column) => Math.max(column.length, ...rows.map((row) => (row[column] ?? "").length)));
3241
+ const format = (cells) => ` ${cells.map((cell, index) => cell.padEnd(widths[index] ?? 0)).join(" ").trimEnd()}`;
3242
+ return [
3243
+ format(columns),
3244
+ format(widths.map((width) => "-".repeat(width))),
3245
+ ...rows.map((row) => format(columns.map((column) => row[column] ?? "")))
3246
+ ];
3247
+ }
3248
+ function resourceTable(rows) {
3249
+ return rows.map((row) => ({
3250
+ subdir: row.subdirectory ?? EM_DASH,
3251
+ name: row.name,
3252
+ status: row.status,
3253
+ hostname: row.hostname ?? EM_DASH
3254
+ }));
3255
+ }
3256
+ function deployables(topology) {
3257
+ return [...topology.apps, ...topology.pipelines];
3258
+ }
3259
+ function deployableLayout(topology) {
3260
+ if (topology.errors.some((error) => error.domain === "apps" || error.domain === "pipelines")) {
3261
+ return "partial";
3262
+ }
3263
+ const rows = deployables(topology);
3264
+ if (rows.length === 0)
3265
+ return "empty";
3266
+ const withSubdir = rows.filter((row) => row.subdirectory).length;
3267
+ if (withSubdir === rows.length)
3268
+ return "subdirectory";
3269
+ if (withSubdir === 0)
3270
+ return "whole-repo";
3271
+ return "mixed";
3272
+ }
3273
+ function renderLayoutIntro(io, topology) {
3274
+ const layout = deployableLayout(topology);
3275
+ if (layout === "subdirectory") {
3276
+ outLine(io, "Every app and pipeline below lives as a subdirectory of one shared");
3277
+ outLine(io, "organization mono-root repo — that repo is the deploy source. Each");
3278
+ outLine(io, "subdirectory is an independent deployable. Databases are separate");
3279
+ outLine(io, "resources, linked to apps via env vars (`khotan apps env list`), not");
3280
+ outLine(io, "by directory.");
3281
+ return;
3282
+ }
3283
+ if (layout === "mixed") {
3284
+ outLine(io, "Some deployables below live in subdirectories of the deploy repo;");
3285
+ outLine(io, "rows marked with — use the repo root or have no subdirectory metadata.");
3286
+ } else if (layout === "whole-repo") {
3287
+ outLine(io, "Deployables below use the deploy repo root or have no subdirectory");
3288
+ outLine(io, "metadata, rather than an organization mono-root subdirectory layout.");
3289
+ } else if (layout === "partial") {
3290
+ outLine(io, "Some app or pipeline data could not be read, so the deployable");
3291
+ outLine(io, "layout below is incomplete. Treat missing deployables as unknown.");
3292
+ } else {
3293
+ outLine(io, "No app or pipeline deployables were returned yet.");
3294
+ }
3295
+ outLine(io, "Databases are separate resources, linked to apps via env vars");
3296
+ outLine(io, "(`khotan apps env list`), not by directory.");
3297
+ }
3298
+ function renderHuman(io, topology) {
3299
+ outLine(io, "Workspace topology");
3300
+ outLine(io);
3301
+ renderLayoutIntro(io, topology);
3302
+ outLine(io);
3303
+ outLine(io, `Deploy repo: ${topology.repo ?? "(none provisioned yet)"}` + (topology.defaultBranch ? ` (branch: ${topology.defaultBranch})` : ""));
3304
+ outLine(io);
3305
+ outLine(io, `Apps (${topology.apps.length})`);
3306
+ for (const line of table(resourceTable(topology.apps)))
3307
+ outLine(io, line);
3308
+ outLine(io);
3309
+ outLine(io, `Pipelines (${topology.pipelines.length})`);
3310
+ for (const line of table(resourceTable(topology.pipelines)))
3311
+ outLine(io, line);
3312
+ outLine(io);
3313
+ outLine(io, `Databases (${topology.databases.length})`);
3314
+ for (const line of table(topology.databases.map((db) => ({
3315
+ name: db.name,
3316
+ status: db.status,
3317
+ region: db.region ?? EM_DASH,
3318
+ host: db.host ?? EM_DASH
3319
+ })))) {
3320
+ outLine(io, line);
3321
+ }
3322
+ outLine(io);
3323
+ outLine(io, `Redis (KV) (${topology.redisDatabases.length})`);
3324
+ for (const line of table(topology.redisDatabases.map((db) => ({
3325
+ name: db.name,
3326
+ status: db.status,
3327
+ region: db.region ?? EM_DASH,
3328
+ host: db.host ?? EM_DASH
3329
+ })))) {
3330
+ outLine(io, line);
3331
+ }
3332
+ for (const failure of topology.errors) {
3333
+ errLine(io, `! could not read ${failure.domain}: ${failure.message}`);
3334
+ }
3335
+ }
3336
+ function markdownTable(headers, rows) {
3337
+ const lines = [
3338
+ `| ${headers.join(" | ")} |`,
3339
+ `| ${headers.map(() => "---").join(" | ")} |`,
3340
+ ...rows.map((row) => `| ${row.join(" | ")} |`)
3341
+ ];
3342
+ return lines.join(`
3343
+ `);
3344
+ }
3345
+ function mdCell(value) {
3346
+ return value ? value : EM_DASH;
3347
+ }
3348
+ function workspaceIntro(topology) {
3349
+ const layout = deployableLayout(topology);
3350
+ if (layout === "subdirectory") {
3351
+ return [
3352
+ "This directory is one **organization mono-root repo**: a single GitHub repo",
3353
+ "whose top-level subdirectories are each an independent Khotan deployable (an",
3354
+ "app or a pipeline). Cloning the repo (or a sparse subdirectory checkout) gives",
3355
+ "you the code; each subdirectory deploys on its own from this same repo.",
3356
+ "",
3357
+ "**Do not run app scaffolders (e.g. `khotan-data init`) at this root.** Each",
3358
+ "app already lives in its own subdirectory — `cd` into it first."
3359
+ ];
3360
+ }
3361
+ if (layout === "mixed") {
3362
+ return [
3363
+ "This file records Khotan deployables for this repository. Some deployables",
3364
+ "live in subdirectories; deployables marked with — use the repo root or have",
3365
+ "no subdirectory metadata.",
3366
+ "",
3367
+ "For deployables with a subdirectory, `cd` into that directory before running",
3368
+ "app scaffolders. For rows marked —, verify the resource layout before",
3369
+ "scaffolding."
3370
+ ];
3371
+ }
3372
+ if (layout === "whole-repo") {
3373
+ return [
3374
+ "This file records Khotan deployables for this repository. The returned",
3375
+ "deployables use the repo root or have no subdirectory metadata, so this is",
3376
+ "not described as an organization mono-root subdirectory layout."
3377
+ ];
3378
+ }
3379
+ if (layout === "partial") {
3380
+ return [
3381
+ "This file records Khotan deployables for this repository. Some app or",
3382
+ "pipeline data could not be read, so the deployable layout is incomplete.",
3383
+ "Use the Read Errors section before deciding where to run scaffolders."
3384
+ ];
3385
+ }
3386
+ return [
3387
+ "This file records Khotan deployables for this repository. No apps or pipelines",
3388
+ "were returned yet; re-run `khotan status --write` after provisioning resources",
3389
+ "to capture their deploy mapping."
3390
+ ];
3391
+ }
3392
+ function errorsSection(errors) {
3393
+ if (errors.length === 0)
3394
+ return [];
3395
+ return [
3396
+ "",
3397
+ "## Read Errors",
3398
+ "",
3399
+ "Some live data could not be read while generating this file. Treat affected",
3400
+ "empty sections as unknown, not as confirmed empty.",
3401
+ "",
3402
+ markdownTable(["Domain", "Error"], errors.map((error) => [error.domain, error.message]))
3403
+ ];
3404
+ }
3405
+ function buildWorkspaceManifest(topology) {
3406
+ const rows = deployables(topology);
3407
+ const sections = [];
3408
+ sections.push("# Workspace", "", "> Generated by `khotan status --write`. Re-run to refresh; safe to commit.", "", ...workspaceIntro(topology), "", `- **Deploy repo:** ${topology.repo ? `\`${topology.repo}\`` : "_(none provisioned yet)_"}` + (topology.defaultBranch ? ` (branch \`${topology.defaultBranch}\`)` : ""));
3409
+ sections.push("", "## Deployables", "", rows.length === 0 ? "_No apps or pipelines provisioned yet._" : markdownTable(["Subdirectory", "Resource", "Type", "Status", "Live URL"], rows.map((row) => [
3410
+ row.subdirectory ? `\`${row.subdirectory}\`` : EM_DASH,
3411
+ row.name,
3412
+ row.kind,
3413
+ row.status,
3414
+ row.hostname ? `https://${row.hostname.replace(/^https?:\/\//, "")}` : EM_DASH
3415
+ ])));
3416
+ sections.push("", "## Databases", "", "Databases are separate resources. An app connects to one through environment", "variables (`khotan apps env list <appId>`), not by directory.", "", topology.databases.length === 0 ? "_No databases provisioned yet._" : markdownTable(["Name", "Status", "Environment", "Region", "Host"], topology.databases.map((db) => [
3417
+ db.name,
3418
+ db.status,
3419
+ mdCell(db.environment),
3420
+ mdCell(db.region),
3421
+ mdCell(db.host)
3422
+ ])));
3423
+ sections.push("", "## Redis (KV)", "", "Redis (KV) databases are separate resources. An app connects to one through", "environment variables (`khotan apps env list <appId>`), not by directory.", "", topology.redisDatabases.length === 0 ? "_No Redis (KV) databases provisioned yet._" : markdownTable(["Name", "Status", "Region", "Host"], topology.redisDatabases.map((db) => [
3424
+ db.name,
3425
+ db.status,
3426
+ mdCell(db.region),
3427
+ mdCell(db.host)
3428
+ ])));
3429
+ sections.push(...errorsSection(topology.errors));
3430
+ sections.push("", "## Working here", "", "- `khotan status` — reprint this topology (`--json` for machine output).", "- `khotan status --write` — regenerate this `WORKSPACE.md` from live data.", "- `khotan apps list` · `khotan pipelines list` · `khotan databases list` · `khotan redis list` — per-domain detail.", "");
3431
+ return sections.join(`
3432
+ `);
3433
+ }
3434
+ async function runStatus(options) {
3435
+ const { session, io, json, writeDir } = options;
3436
+ const topology = await loadTopology(session);
3437
+ if (writeDir) {
3438
+ const target = join4(writeDir, "WORKSPACE.md");
3439
+ writeFileSync3(target, buildWorkspaceManifest(topology));
3440
+ const shown = relative2(process.cwd(), target) || target;
3441
+ if (json) {
3442
+ outLine(io, JSON.stringify({ wrote: shown, topology }, null, 2));
3443
+ } else {
3444
+ renderHuman(io, topology);
3445
+ errLine(io);
3446
+ errLine(io, `Wrote ${shown}`);
3447
+ }
3448
+ return 0;
3449
+ }
3450
+ if (json) {
3451
+ outLine(io, JSON.stringify(topology, null, 2));
3452
+ return 0;
3453
+ }
3454
+ renderHuman(io, topology);
3455
+ return 0;
3456
+ }
3457
+
2601
3458
  // src/cli/confirm.ts
2602
3459
  init_src();
2603
3460
  init_io();
@@ -2653,13 +3510,14 @@ async function runCapability(match, args, options) {
2653
3510
  printCommandHelp(options.io, capability);
2654
3511
  return 0;
2655
3512
  }
2656
- const session = resolveSession({
3513
+ const session = await resolveSessionAsserted({
2657
3514
  env: options.env,
2658
3515
  storeOptions: options.storeOptions,
2659
3516
  profileName: options.profileName,
2660
3517
  apiUrlOverride: options.apiUrlOverride,
2661
3518
  apiKeyOverride: options.apiKeyOverride,
2662
- fetch: options.fetch
3519
+ fetch: options.fetch,
3520
+ assertOrgOverride: options.assertOrgOverride
2663
3521
  });
2664
3522
  const input = buildCapabilityInput(capability, match.rest, args);
2665
3523
  const json = options.jsonFlag || session.resolved.defaultOutput === "json";
@@ -2676,7 +3534,7 @@ async function runCapability(match, args, options) {
2676
3534
  }
2677
3535
  async function run(argv, options = {}) {
2678
3536
  const io = options.io ?? createNodeIo();
2679
- const env = options.env ?? io.env;
3537
+ const env = overlayRepoEnv(options.env ?? io.env, { cwd: process.cwd() }).env;
2680
3538
  const args = parseArgs(argv);
2681
3539
  const { positionals } = args;
2682
3540
  const jsonFlag = flagBool(args, "json");
@@ -2685,6 +3543,7 @@ async function run(argv, options = {}) {
2685
3543
  const profileName = flagValue(args, "profile");
2686
3544
  const apiUrlOverride = flagValue(args, "api-url");
2687
3545
  const apiKeyOverride = flagValue(args, "api-key");
3546
+ const assertOrgOverride = flagValue(args, "assert-org");
2688
3547
  if (flagBool(args, "version")) {
2689
3548
  const { KHOTAN_ADAPTER_VERSION: KHOTAN_ADAPTER_VERSION2 } = await Promise.resolve().then(() => (init_src(), exports_src));
2690
3549
  io.out(`${KHOTAN_ADAPTER_VERSION2}
@@ -2708,7 +3567,12 @@ async function run(argv, options = {}) {
2708
3567
  if (match2 && isOperationCapability(match2.capability)) {
2709
3568
  printCommandHelp(io, match2.capability);
2710
3569
  } else {
2711
- printTopLevelHelp(io);
3570
+ const group2 = groupCommands(topic);
3571
+ if (group2.length > 0) {
3572
+ printGroupHelp(io, topic, group2);
3573
+ } else {
3574
+ printTopLevelHelp(io);
3575
+ }
2712
3576
  }
2713
3577
  return 0;
2714
3578
  }
@@ -2724,6 +3588,26 @@ async function run(argv, options = {}) {
2724
3588
  const cwd = flagValue(args, "dir") ?? process.cwd();
2725
3589
  return runInit({ io, cwd, client: clientRaw, force: flagBool(args, "force"), json: jsonFlag });
2726
3590
  }
3591
+ if (first === "status") {
3592
+ if (helpFlag) {
3593
+ errLine(io, "Usage: khotan status [--write [path]] [--dir <path>] [--json]");
3594
+ return 0;
3595
+ }
3596
+ const session = await resolveSessionAsserted({
3597
+ env,
3598
+ storeOptions: options.storeOptions,
3599
+ profileName,
3600
+ apiUrlOverride,
3601
+ apiKeyOverride,
3602
+ fetch: options.fetch,
3603
+ assertOrgOverride
3604
+ });
3605
+ const json = jsonFlag || session.resolved.defaultOutput === "json";
3606
+ const writeValue = flagValue(args, "write");
3607
+ const write = flagBool(args, "write");
3608
+ const writeDir = write ? flagValue(args, "dir") ?? (writeValue && writeValue !== "true" ? writeValue : undefined) ?? process.cwd() : undefined;
3609
+ return await runStatus({ session, io, json, writeDir });
3610
+ }
2727
3611
  if (first === "mcp" && positionals[1] === "serve") {
2728
3612
  const launcher = options.startMcpServer ?? (async (deps) => {
2729
3613
  const { serveMcp: serveMcp2 } = await Promise.resolve().then(() => (init_serve(), exports_serve));
@@ -2736,7 +3620,8 @@ async function run(argv, options = {}) {
2736
3620
  fetch: options.fetch,
2737
3621
  profileName,
2738
3622
  apiUrlOverride,
2739
- apiKeyOverride
3623
+ apiKeyOverride,
3624
+ assertOrgOverride
2740
3625
  });
2741
3626
  }
2742
3627
  if (first === "login") {
@@ -2769,13 +3654,14 @@ async function run(argv, options = {}) {
2769
3654
  errLine(io, positionals[1] === "upload" ? "Usage: khotan files upload <localPath> [--name <n>] [--folder-id <id>] [--folder-path <p>] [--content-type <t>]" : "Usage: khotan files download <fileId> --output <path>");
2770
3655
  return 0;
2771
3656
  }
2772
- const session = resolveSession({
3657
+ const session = await resolveSessionAsserted({
2773
3658
  env,
2774
3659
  storeOptions: options.storeOptions,
2775
3660
  profileName,
2776
3661
  apiUrlOverride,
2777
3662
  apiKeyOverride,
2778
- fetch: options.fetch
3663
+ fetch: options.fetch,
3664
+ assertOrgOverride
2779
3665
  });
2780
3666
  const json = jsonFlag || session.resolved.defaultOutput === "json";
2781
3667
  if (positionals[1] === "upload") {
@@ -2808,9 +3694,15 @@ async function run(argv, options = {}) {
2808
3694
  yes,
2809
3695
  profileName,
2810
3696
  apiUrlOverride,
2811
- apiKeyOverride
3697
+ apiKeyOverride,
3698
+ assertOrgOverride
2812
3699
  });
2813
3700
  }
3701
+ const group = groupCommands(positionals);
3702
+ if (group.length > 0) {
3703
+ printGroupHelp(io, positionals, group);
3704
+ return 0;
3705
+ }
2814
3706
  errLine(io, `Unknown command "${positionals.join(" ")}". Run \`khotan help\` for usage.`);
2815
3707
  return 1;
2816
3708
  } catch (error) {