@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.
- package/README.md +22 -0
- package/dist/khotan.js +908 -42
- 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-
|
|
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 =
|
|
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
|
|
2083
|
-
import { dirname as
|
|
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
|
-
|
|
2501
|
-
|
|
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
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
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
|
|
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,
|
|
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 =
|
|
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(
|
|
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 =
|
|
2562
|
-
mkdirSync2(
|
|
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 =
|
|
3089
|
+
const existed = existsSync2(absolutePath);
|
|
2570
3090
|
if (existed) {
|
|
2571
3091
|
try {
|
|
2572
|
-
config = JSON.parse(
|
|
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(
|
|
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,
|
|
2601
|
-
writeTextAsset(ctx,
|
|
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,
|
|
2604
|
-
writeTextAsset(ctx,
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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/
|
|
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/
|
|
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/
|
|
14
|
+
"url": "https://github.com/khotan-core/khotan/issues"
|
|
15
15
|
},
|
|
16
16
|
"keywords": [
|
|
17
17
|
"khotan",
|