@khotan/cli 0.6.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 +908 -42
  3. package/package.json +4 -4
package/README.md CHANGED
@@ -101,6 +101,7 @@ state is needed in CI or sandboxes:
101
101
  | `KHOTAN_API_URL` | API origin (e.g. `https://app.example.com`) |
102
102
  | `KHOTAN_API_KEY` | Organization-scoped API key |
103
103
  | `KHOTAN_PROFILE` | Select a stored profile by name |
104
+ | `KHOTAN_ORG_ID` | Expected org id; the CLI asserts `whoami.organizationId` matches it and fails closed otherwise |
104
105
 
105
106
  ```bash
106
107
  export KHOTAN_API_URL=https://app.example.com
@@ -108,6 +109,27 @@ export KHOTAN_API_KEY=khk_live_...
108
109
  khotan apps list --json | jq '.apps[].id'
109
110
  ```
110
111
 
112
+ ### Repo-local org isolation (`env.khotan.local`)
113
+
114
+ When an `env.khotan.local` file sits at (or above) the working directory, the CLI
115
+ auto-loads its `KHOTAN_*` values so every command is scoped to that repo's
116
+ organization — never a stray machine-global profile from another customer. The
117
+ file is authoritative: its values win over shell-exported `KHOTAN_*`, which win
118
+ over the stored profile, and CLI flags win over everything (`flag >
119
+ env.khotan.local > shell env > profile`).
120
+
121
+ ```bash
122
+ # env.khotan.local (git-ignored; never commit secrets)
123
+ KHOTAN_API_URL='https://<customer>.khotan.com'
124
+ KHOTAN_API_KEY='<org-scoped key>'
125
+ KHOTAN_ORG_ID='<org id>'
126
+ ```
127
+
128
+ When `KHOTAN_ORG_ID` (or `--assert-org <id>`) is set, every operation and
129
+ `khotan mcp serve` verifies `whoami.organizationId` matches before trusting
130
+ output and fails closed on mismatch (`error [org_mismatch]`). With no expected
131
+ org id set, behavior is unchanged.
132
+
111
133
  ## Command surface
112
134
 
113
135
  Commands are grouped by domain and derived from the capability catalog, so the
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.6.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
@@ -322,6 +322,71 @@ function platformDomain(config) {
322
322
  };
323
323
  return [...operations, resource];
324
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
+ }
325
390
  function getCapability(id) {
326
391
  return capabilitiesById.get(id);
327
392
  }
@@ -343,7 +408,7 @@ var idField = (name, description) => ({
343
408
  type: "string",
344
409
  required: true,
345
410
  description
346
- }), 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;
347
412
  var init_catalog = __esm(() => {
348
413
  init_version();
349
414
  identityCapabilities = [
@@ -582,6 +647,54 @@ var init_catalog = __esm(() => {
582
647
  cli: { command: ["databases", "branches", "rotate-credentials"], summary: "Rotate branch credentials (secret)", defaultOutput: "raw" },
583
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." } }
584
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
+ },
585
698
  {
586
699
  id: "databases.branches.resource",
587
700
  kind: "resource",
@@ -607,6 +720,84 @@ var init_catalog = __esm(() => {
607
720
  }
608
721
  }
609
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
+ ];
610
801
  fileCapabilities = [
611
802
  {
612
803
  id: "files.list",
@@ -973,14 +1164,127 @@ var init_catalog = __esm(() => {
973
1164
  envDelete: "deletePipelineEnvVar"
974
1165
  }
975
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
+ ];
976
1277
  capabilities = [
977
1278
  ...identityCapabilities,
978
1279
  ...appsCapabilities,
979
1280
  ...pipelinesCapabilities,
980
1281
  ...databaseCapabilities,
1282
+ ...redisCapabilities,
981
1283
  ...fileCapabilities,
982
1284
  ...folderCapabilities,
983
- ...contextCapabilities
1285
+ ...contextCapabilities,
1286
+ ...integrationsCapabilities,
1287
+ ...emailSubscriptionCapabilities
984
1288
  ];
985
1289
  khotanCatalog = {
986
1290
  schemaVersion: CATALOG_SCHEMA_VERSION,
@@ -1341,6 +1645,66 @@ var init_profiles = __esm(() => {
1341
1645
  init_errors();
1342
1646
  });
1343
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
+
1344
1708
  // ../khotan-core/src/index.ts
1345
1709
  var exports_src = {};
1346
1710
  __export(exports_src, {
@@ -1351,6 +1715,8 @@ __export(exports_src, {
1351
1715
  saveProfileStore: () => saveProfileStore,
1352
1716
  resolveProfile: () => resolveProfile,
1353
1717
  requiresConfirmation: () => requiresConfirmation,
1718
+ parseRepoEnvFile: () => parseRepoEnvFile,
1719
+ overlayRepoEnv: () => overlayRepoEnv,
1354
1720
  loadProfileStore: () => loadProfileStore,
1355
1721
  listDomains: () => listDomains,
1356
1722
  listCapabilities: () => listCapabilities,
@@ -1364,12 +1730,14 @@ __export(exports_src, {
1364
1730
  indexOpenApiOperations: () => indexOpenApiOperations,
1365
1731
  getProfileStorePath: () => getProfileStorePath,
1366
1732
  getCapability: () => getCapability,
1733
+ findRepoEnvFile: () => findRepoEnvFile,
1367
1734
  createApiClient: () => createApiClient,
1368
1735
  capabilitiesByOperationId: () => capabilitiesByOperationId,
1369
1736
  buildHttpRequest: () => buildHttpRequest,
1370
1737
  assertSupportedCatalogSchemaVersion: () => assertSupportedCatalogSchemaVersion,
1371
1738
  SUPPORTED_CATALOG_SCHEMA_VERSIONS: () => SUPPORTED_CATALOG_SCHEMA_VERSIONS,
1372
1739
  SAFETY_LEVELS: () => SAFETY_LEVELS,
1740
+ REPO_ENV_FILENAME: () => REPO_ENV_FILENAME,
1373
1741
  KhotanUnknownCapabilityError: () => KhotanUnknownCapabilityError,
1374
1742
  KhotanError: () => KhotanError,
1375
1743
  KhotanConfirmationRequiredError: () => KhotanConfirmationRequiredError,
@@ -1379,6 +1747,7 @@ __export(exports_src, {
1379
1747
  KHOTAN_ADAPTER_VERSION: () => KHOTAN_ADAPTER_VERSION,
1380
1748
  KHOTAN_ADAPTER_NAME: () => KHOTAN_ADAPTER_NAME,
1381
1749
  ENV_PROFILE: () => ENV_PROFILE,
1750
+ ENV_ORG_ID: () => ENV_ORG_ID,
1382
1751
  ENV_API_URL: () => ENV_API_URL,
1383
1752
  ENV_API_KEY: () => ENV_API_KEY,
1384
1753
  CATALOG_VERSION: () => CATALOG_VERSION,
@@ -1392,6 +1761,7 @@ var init_src = __esm(() => {
1392
1761
  init_validate();
1393
1762
  init_api_client();
1394
1763
  init_profiles();
1764
+ init_repo_env();
1395
1765
  });
1396
1766
 
1397
1767
  // src/cli/io.ts
@@ -1428,6 +1798,65 @@ function errLine(io, line = "") {
1428
1798
  }
1429
1799
  var init_io = () => {};
1430
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
+
1431
1860
  // src/cli/session.ts
1432
1861
  function resolveSession(options) {
1433
1862
  const resolved = resolveProfile({
@@ -1447,8 +1876,17 @@ function resolveSession(options) {
1447
1876
  apiKey
1448
1877
  };
1449
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
+ }
1450
1887
  var init_session = __esm(() => {
1451
1888
  init_src();
1889
+ init_assert_org();
1452
1890
  });
1453
1891
 
1454
1892
  // src/mcp/protocol.ts
@@ -1699,13 +2137,14 @@ __export(exports_serve, {
1699
2137
  import { createInterface as createInterface2 } from "node:readline";
1700
2138
  async function serveMcp(deps) {
1701
2139
  assertSupportedCatalogSchemaVersion();
1702
- const session = resolveSession({
2140
+ const session = await resolveSessionAsserted({
1703
2141
  env: deps.env,
1704
2142
  storeOptions: deps.storeOptions,
1705
2143
  profileName: deps.profileName,
1706
2144
  apiUrlOverride: deps.apiUrlOverride,
1707
2145
  apiKeyOverride: deps.apiKeyOverride,
1708
- fetch: deps.fetch
2146
+ fetch: deps.fetch,
2147
+ assertOrgOverride: deps.assertOrgOverride
1709
2148
  });
1710
2149
  if (!session.apiKey) {
1711
2150
  throw new KhotanClientError("not_authenticated", "The MCP server needs an API key. Run `khotan auth set-key` or set KHOTAN_API_KEY.");
@@ -2079,8 +2518,8 @@ async function downloadFile(deps, options) {
2079
2518
 
2080
2519
  // src/cli/commands/init.ts
2081
2520
  init_io();
2082
- import { existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
2083
- 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";
2084
2523
 
2085
2524
  // src/cli/skills.ts
2086
2525
  init_src();
@@ -2187,9 +2626,11 @@ var DOMAIN_TITLES = {
2187
2626
  apps: "Apps",
2188
2627
  pipelines: "Pipelines",
2189
2628
  databases: "Databases",
2629
+ redis: "Redis (KV)",
2190
2630
  files: "Files",
2191
2631
  folders: "Folders",
2192
- context: "Context documents"
2632
+ context: "Context documents",
2633
+ integrations: "Integrations"
2193
2634
  };
2194
2635
  function commandWords(capability) {
2195
2636
  return capability.cli?.command.join(" ");
@@ -2255,6 +2696,8 @@ function printTopLevelHelp(io) {
2255
2696
  outLine(io, " files download <fileId> Download a file to --output (presigned flow)");
2256
2697
  outLine(io);
2257
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");
2258
2701
  outLine(io, " init Scaffold MCP config + agent guidance into this workspace");
2259
2702
  outLine(io);
2260
2703
  outLine(io, "Agent:");
@@ -2266,10 +2709,33 @@ function printTopLevelHelp(io) {
2266
2709
  outLine(io, " --profile <name> Use a specific stored profile");
2267
2710
  outLine(io, " --api-url <url> Override the API origin");
2268
2711
  outLine(io, " --api-key <key> Override the API key");
2712
+ outLine(io, " --assert-org <id> Require whoami.organizationId to equal <id> (fail closed)");
2269
2713
  outLine(io, " --help Show help for a command");
2270
2714
  outLine(io);
2271
2715
  outLine(io, `khotan v${KHOTAN_ADAPTER_VERSION}`);
2272
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
+ }
2273
2739
  function printCommandHelp(io, capability) {
2274
2740
  outLine(io, capability.title);
2275
2741
  outLine(io);
@@ -2302,6 +2768,7 @@ var DOMAIN_TITLES2 = {
2302
2768
  apps: "Apps",
2303
2769
  pipelines: "Pipelines",
2304
2770
  databases: "Databases",
2771
+ redis: "Redis (KV)",
2305
2772
  files: "Files",
2306
2773
  folders: "Folders",
2307
2774
  context: "Context documents"
@@ -2310,6 +2777,7 @@ var DOMAIN_LABELS = {
2310
2777
  apps: "apps",
2311
2778
  pipelines: "pipelines",
2312
2779
  databases: "databases and database branches",
2780
+ redis: "Redis (KV) databases",
2313
2781
  files: "files",
2314
2782
  folders: "folders",
2315
2783
  context: "context documents"
@@ -2484,32 +2952,54 @@ var SKILL_BASES = [".cursor", ".agents", ".claude"];
2484
2952
  function mcpServerEntry() {
2485
2953
  return {
2486
2954
  command: "khotan",
2487
- args: ["mcp", "serve"],
2488
- env: {
2489
- KHOTAN_API_URL: "<your-khotan-api-url>",
2490
- KHOTAN_API_KEY: "<your-khotan-api-key>"
2491
- }
2955
+ args: ["mcp", "serve"]
2492
2956
  };
2493
2957
  }
2494
2958
  var GUIDE_BODY = `This workspace is wired to Khotan through the \`khotan\` CLI and its MCP server.
2495
2959
  Use them to inspect and configure apps, pipelines, databases, files, folders, and
2496
2960
  context documents through the Khotan \`/api/v1\` surface.
2497
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
+
2498
2974
  ## Authentication (do this once — never commit secrets)
2499
2975
 
2500
- Khotan reads an organization-scoped API key from a stored profile or the
2501
- 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:
2502
2978
 
2503
- - Profile (recommended): \`khotan auth set-key --api-url <url> --api-key <key>\`
2504
- stores the key in an owner-only file outside the repository.
2505
- - Environment: export \`KHOTAN_API_URL\` and \`KHOTAN_API_KEY\` in your shell or
2506
- 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\`.
2507
2983
 
2508
2984
  Never put an API key in a file committed to the repository. The generated MCP
2509
- 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.
2510
2999
 
2511
3000
  ## CLI surface
2512
3001
 
3002
+ - \`khotan status\` — the workspace topology (apps, pipelines, databases) at a glance
2513
3003
  - \`khotan apps list|get|create|delete|redeploy\` and \`khotan apps env …\`
2514
3004
  - \`khotan pipelines …\`, \`khotan databases …\`
2515
3005
  - \`khotan files list|upload|download|…\`, \`khotan folders …\`
@@ -2529,7 +3019,9 @@ surface.
2529
3019
  ## MCP
2530
3020
 
2531
3021
  \`khotan mcp serve\` exposes the same operations as MCP tools and durable reads as
2532
- 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\`.
2533
3025
  `;
2534
3026
  var CURSOR_RULES = `---
2535
3027
  description: Use the Khotan CLI and MCP tools to configure and operate this workspace's Khotan resources
@@ -2545,31 +3037,59 @@ ${GUIDE_BODY}`;
2545
3037
  function toRelative(cwd, absolutePath) {
2546
3038
  return relative(cwd, absolutePath) || absolutePath;
2547
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
+ }
2548
3068
  function writeTextAsset(ctx, absolutePath, content) {
2549
3069
  const path = toRelative(ctx.cwd, absolutePath);
2550
- const existed = existsSync(absolutePath);
3070
+ const existed = existsSync2(absolutePath);
2551
3071
  if (existed && !ctx.force) {
2552
3072
  ctx.results.push({ path, action: "skipped" });
2553
3073
  return;
2554
3074
  }
2555
- mkdirSync2(dirname2(absolutePath), { recursive: true });
3075
+ mkdirSync2(dirname3(absolutePath), { recursive: true });
2556
3076
  writeFileSync2(absolutePath, content);
2557
3077
  ctx.results.push({ path, action: existed ? "forced" : "created" });
2558
3078
  }
2559
3079
  function writeManagedAsset(ctx, absolutePath, content) {
2560
3080
  const path = toRelative(ctx.cwd, absolutePath);
2561
- const existed = existsSync(absolutePath);
2562
- mkdirSync2(dirname2(absolutePath), { recursive: true });
3081
+ const existed = existsSync2(absolutePath);
3082
+ mkdirSync2(dirname3(absolutePath), { recursive: true });
2563
3083
  writeFileSync2(absolutePath, content);
2564
3084
  ctx.results.push({ path, action: existed ? "updated" : "created" });
2565
3085
  }
2566
3086
  function writeMcpConfig(ctx, absolutePath) {
2567
3087
  const path = toRelative(ctx.cwd, absolutePath);
2568
3088
  let config = {};
2569
- const existed = existsSync(absolutePath);
3089
+ const existed = existsSync2(absolutePath);
2570
3090
  if (existed) {
2571
3091
  try {
2572
- config = JSON.parse(readFileSync2(absolutePath, "utf8"));
3092
+ config = JSON.parse(readFileSync3(absolutePath, "utf8"));
2573
3093
  } catch {
2574
3094
  ctx.results.push({ path, action: "skipped" });
2575
3095
  return;
@@ -2585,7 +3105,7 @@ function writeMcpConfig(ctx, absolutePath) {
2585
3105
  return;
2586
3106
  }
2587
3107
  servers[MCP_SERVER_KEY] = mcpServerEntry();
2588
- mkdirSync2(dirname2(absolutePath), { recursive: true });
3108
+ mkdirSync2(dirname3(absolutePath), { recursive: true });
2589
3109
  writeFileSync2(absolutePath, `${JSON.stringify(config, null, 2)}
2590
3110
  `);
2591
3111
  ctx.results.push({
@@ -2597,19 +3117,20 @@ function runInit(options) {
2597
3117
  const { io, cwd, client, force, json } = options;
2598
3118
  const ctx = { cwd, force, results: [] };
2599
3119
  if (client === "cursor") {
2600
- writeMcpConfig(ctx, join2(cwd, ".cursor", "mcp.json"));
2601
- 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);
2602
3122
  } else {
2603
- writeMcpConfig(ctx, join2(cwd, "mcp.json"));
2604
- 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);
2605
3125
  }
2606
3126
  for (const skill of generateSkills()) {
2607
3127
  for (const base of SKILL_BASES) {
2608
- 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);
2609
3129
  }
2610
3130
  }
3131
+ const monoRoot = detectMonoRoot(cwd);
2611
3132
  if (json) {
2612
- io.out(`${JSON.stringify({ client, actions: ctx.results }, null, 2)}
3133
+ io.out(`${JSON.stringify({ client, monoRoot, actions: ctx.results }, null, 2)}
2613
3134
  `);
2614
3135
  return 0;
2615
3136
  }
@@ -2618,12 +3139,322 @@ function runInit(options) {
2618
3139
  errLine(io, ` ${result.action.padEnd(8)} ${result.path}`);
2619
3140
  }
2620
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
+ }
2621
3149
  errLine(io, "Next steps:");
2622
3150
  errLine(io, " 1. Authenticate: khotan auth set-key --api-url <url> --api-key <key>");
2623
3151
  errLine(io, " 2. Open this workspace in your agent and ask it to configure Khotan.");
2624
3152
  return 0;
2625
3153
  }
2626
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
+
2627
3458
  // src/cli/confirm.ts
2628
3459
  init_src();
2629
3460
  init_io();
@@ -2679,13 +3510,14 @@ async function runCapability(match, args, options) {
2679
3510
  printCommandHelp(options.io, capability);
2680
3511
  return 0;
2681
3512
  }
2682
- const session = resolveSession({
3513
+ const session = await resolveSessionAsserted({
2683
3514
  env: options.env,
2684
3515
  storeOptions: options.storeOptions,
2685
3516
  profileName: options.profileName,
2686
3517
  apiUrlOverride: options.apiUrlOverride,
2687
3518
  apiKeyOverride: options.apiKeyOverride,
2688
- fetch: options.fetch
3519
+ fetch: options.fetch,
3520
+ assertOrgOverride: options.assertOrgOverride
2689
3521
  });
2690
3522
  const input = buildCapabilityInput(capability, match.rest, args);
2691
3523
  const json = options.jsonFlag || session.resolved.defaultOutput === "json";
@@ -2702,7 +3534,7 @@ async function runCapability(match, args, options) {
2702
3534
  }
2703
3535
  async function run(argv, options = {}) {
2704
3536
  const io = options.io ?? createNodeIo();
2705
- const env = options.env ?? io.env;
3537
+ const env = overlayRepoEnv(options.env ?? io.env, { cwd: process.cwd() }).env;
2706
3538
  const args = parseArgs(argv);
2707
3539
  const { positionals } = args;
2708
3540
  const jsonFlag = flagBool(args, "json");
@@ -2711,6 +3543,7 @@ async function run(argv, options = {}) {
2711
3543
  const profileName = flagValue(args, "profile");
2712
3544
  const apiUrlOverride = flagValue(args, "api-url");
2713
3545
  const apiKeyOverride = flagValue(args, "api-key");
3546
+ const assertOrgOverride = flagValue(args, "assert-org");
2714
3547
  if (flagBool(args, "version")) {
2715
3548
  const { KHOTAN_ADAPTER_VERSION: KHOTAN_ADAPTER_VERSION2 } = await Promise.resolve().then(() => (init_src(), exports_src));
2716
3549
  io.out(`${KHOTAN_ADAPTER_VERSION2}
@@ -2734,7 +3567,12 @@ async function run(argv, options = {}) {
2734
3567
  if (match2 && isOperationCapability(match2.capability)) {
2735
3568
  printCommandHelp(io, match2.capability);
2736
3569
  } else {
2737
- printTopLevelHelp(io);
3570
+ const group2 = groupCommands(topic);
3571
+ if (group2.length > 0) {
3572
+ printGroupHelp(io, topic, group2);
3573
+ } else {
3574
+ printTopLevelHelp(io);
3575
+ }
2738
3576
  }
2739
3577
  return 0;
2740
3578
  }
@@ -2750,6 +3588,26 @@ async function run(argv, options = {}) {
2750
3588
  const cwd = flagValue(args, "dir") ?? process.cwd();
2751
3589
  return runInit({ io, cwd, client: clientRaw, force: flagBool(args, "force"), json: jsonFlag });
2752
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
+ }
2753
3611
  if (first === "mcp" && positionals[1] === "serve") {
2754
3612
  const launcher = options.startMcpServer ?? (async (deps) => {
2755
3613
  const { serveMcp: serveMcp2 } = await Promise.resolve().then(() => (init_serve(), exports_serve));
@@ -2762,7 +3620,8 @@ async function run(argv, options = {}) {
2762
3620
  fetch: options.fetch,
2763
3621
  profileName,
2764
3622
  apiUrlOverride,
2765
- apiKeyOverride
3623
+ apiKeyOverride,
3624
+ assertOrgOverride
2766
3625
  });
2767
3626
  }
2768
3627
  if (first === "login") {
@@ -2795,13 +3654,14 @@ async function run(argv, options = {}) {
2795
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>");
2796
3655
  return 0;
2797
3656
  }
2798
- const session = resolveSession({
3657
+ const session = await resolveSessionAsserted({
2799
3658
  env,
2800
3659
  storeOptions: options.storeOptions,
2801
3660
  profileName,
2802
3661
  apiUrlOverride,
2803
3662
  apiKeyOverride,
2804
- fetch: options.fetch
3663
+ fetch: options.fetch,
3664
+ assertOrgOverride
2805
3665
  });
2806
3666
  const json = jsonFlag || session.resolved.defaultOutput === "json";
2807
3667
  if (positionals[1] === "upload") {
@@ -2834,9 +3694,15 @@ async function run(argv, options = {}) {
2834
3694
  yes,
2835
3695
  profileName,
2836
3696
  apiUrlOverride,
2837
- apiKeyOverride
3697
+ apiKeyOverride,
3698
+ assertOrgOverride
2838
3699
  });
2839
3700
  }
3701
+ const group = groupCommands(positionals);
3702
+ if (group.length > 0) {
3703
+ printGroupHelp(io, positionals, group);
3704
+ return 0;
3705
+ }
2840
3706
  errLine(io, `Unknown command "${positionals.join(" ")}". Run \`khotan help\` for usage.`);
2841
3707
  return 1;
2842
3708
  } catch (error) {
package/package.json CHANGED
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "name": "@khotan/cli",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "description": "Khotan CLI and MCP server: human- and agent-facing adapters over the Khotan /api/v1 surface.",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
7
- "homepage": "https://github.com/adeep-mitra/meridian/tree/main/packages/khotan-cli#readme",
7
+ "homepage": "https://github.com/khotan-core/khotan/tree/main/packages/khotan-cli#readme",
8
8
  "repository": {
9
9
  "type": "git",
10
- "url": "git+https://github.com/adeep-mitra/meridian.git",
10
+ "url": "git+https://github.com/khotan-core/khotan.git",
11
11
  "directory": "packages/khotan-cli"
12
12
  },
13
13
  "bugs": {
14
- "url": "https://github.com/adeep-mitra/meridian/issues"
14
+ "url": "https://github.com/khotan-core/khotan/issues"
15
15
  },
16
16
  "keywords": [
17
17
  "khotan",