@topolo/cli 0.2.4 → 0.4.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/dist/index.js CHANGED
@@ -9,7 +9,7 @@ import { platform as platform2 } from "os";
9
9
  import { createInterface } from "readline/promises";
10
10
  import { stdin as input } from "process";
11
11
 
12
- // ../topolo-sdk/dist/index.js
12
+ // ../../../topolo-platform/packages/topolo-sdk/dist/index.js
13
13
  function applyAuthHeaders(headers, credential) {
14
14
  if (credential.kind === "api_key") {
15
15
  headers.set("X-Api-Key", credential.apiKey);
@@ -2098,6 +2098,262 @@ function firstString(...values) {
2098
2098
  }
2099
2099
  return null;
2100
2100
  }
2101
+ var SUPPORTED_SCHEMA_KEYWORDS = /* @__PURE__ */ new Set([
2102
+ "$schema",
2103
+ "$id",
2104
+ "$ref",
2105
+ "$defs",
2106
+ "definitions",
2107
+ "title",
2108
+ "description",
2109
+ "default",
2110
+ "examples",
2111
+ "deprecated",
2112
+ "readOnly",
2113
+ "writeOnly",
2114
+ "type",
2115
+ "nullable",
2116
+ "const",
2117
+ "enum",
2118
+ "allOf",
2119
+ "anyOf",
2120
+ "oneOf",
2121
+ "properties",
2122
+ "required",
2123
+ "additionalProperties",
2124
+ "propertyNames",
2125
+ "minProperties",
2126
+ "maxProperties",
2127
+ "items",
2128
+ "minItems",
2129
+ "maxItems",
2130
+ "uniqueItems",
2131
+ "minLength",
2132
+ "maxLength",
2133
+ "pattern",
2134
+ "format",
2135
+ "minimum",
2136
+ "maximum",
2137
+ "exclusiveMinimum",
2138
+ "exclusiveMaximum",
2139
+ "multipleOf"
2140
+ ]);
2141
+ function validateTopoloActionInput(schema, input2) {
2142
+ const schemaIssue = inspectSchema(schema, "$", schema, 0);
2143
+ if (schemaIssue) return { valid: false, errors: [schemaIssue] };
2144
+ const errors = [];
2145
+ validateNode(schema, input2, "$", schema, errors, 0);
2146
+ return { valid: errors.length === 0, errors };
2147
+ }
2148
+ function inspectSchema(schema, path4, root, depth) {
2149
+ if (depth > 128) return { path: path4, code: "schema_depth", message: "Schema recursion exceeded 128 levels." };
2150
+ const unsupportedKeyword = Object.keys(schema).find((keyword) => !SUPPORTED_SCHEMA_KEYWORDS.has(keyword));
2151
+ if (unsupportedKeyword) {
2152
+ return { path: path4, code: "unsupported_schema_keyword", message: `Action schema uses unsupported keyword ${unsupportedKeyword}.` };
2153
+ }
2154
+ if (typeof schema.format === "string" && !["uri", "email", "date-time"].includes(schema.format)) {
2155
+ return { path: path4, code: "unsupported_schema_format", message: `Action schema uses unsupported format ${schema.format}.` };
2156
+ }
2157
+ if (typeof schema.$ref === "string" && !resolveLocalReference(root, schema.$ref)) {
2158
+ return { path: path4, code: "unsupported_schema_ref", message: `Cannot resolve schema reference ${schema.$ref}.` };
2159
+ }
2160
+ const children = [];
2161
+ if (isRecord(schema.properties)) children.push(...Object.entries(schema.properties));
2162
+ if (isRecord(schema.$defs)) children.push(...Object.entries(schema.$defs));
2163
+ if (isRecord(schema.definitions)) children.push(...Object.entries(schema.definitions));
2164
+ children.push(
2165
+ ["additionalProperties", schema.additionalProperties],
2166
+ ["propertyNames", schema.propertyNames],
2167
+ ["items", schema.items],
2168
+ ...arraySchemaEntries("allOf", schema.allOf),
2169
+ ...arraySchemaEntries("anyOf", schema.anyOf),
2170
+ ...arraySchemaEntries("oneOf", schema.oneOf)
2171
+ );
2172
+ for (const [key, child] of children) {
2173
+ if (!isRecord(child)) continue;
2174
+ const issue = inspectSchema(child, `${path4}.${key}`, root, depth + 1);
2175
+ if (issue) return issue;
2176
+ }
2177
+ return null;
2178
+ }
2179
+ function arraySchemaEntries(keyword, value) {
2180
+ return Array.isArray(value) ? value.map((candidate, index) => [`${keyword}[${index}]`, candidate]) : [];
2181
+ }
2182
+ function validateNode(schema, value, path4, root, errors, depth) {
2183
+ if (depth > 128) {
2184
+ errors.push({ path: path4, code: "schema_depth", message: "Schema recursion exceeded 128 levels." });
2185
+ return;
2186
+ }
2187
+ if (typeof schema.$ref === "string") {
2188
+ const resolved = resolveLocalReference(root, schema.$ref);
2189
+ if (!resolved) {
2190
+ errors.push({ path: path4, code: "unsupported_schema_ref", message: `Cannot resolve schema reference ${schema.$ref}.` });
2191
+ return;
2192
+ }
2193
+ validateNode(resolved, value, path4, root, errors, depth + 1);
2194
+ }
2195
+ if (Array.isArray(schema.allOf)) {
2196
+ for (const candidate of schema.allOf) {
2197
+ if (isRecord(candidate)) validateNode(candidate, value, path4, root, errors, depth + 1);
2198
+ }
2199
+ }
2200
+ if (Array.isArray(schema.anyOf) && !schema.anyOf.some((candidate) => candidateMatches(candidate, value, root, depth))) {
2201
+ errors.push({ path: path4, code: "any_of", message: "Value does not match any allowed schema." });
2202
+ return;
2203
+ }
2204
+ if (Array.isArray(schema.oneOf)) {
2205
+ const matches = schema.oneOf.filter((candidate) => candidateMatches(candidate, value, root, depth)).length;
2206
+ if (matches !== 1) {
2207
+ errors.push({ path: path4, code: "one_of", message: "Value must match exactly one allowed schema." });
2208
+ return;
2209
+ }
2210
+ }
2211
+ if (value === null && schema.nullable === true) return;
2212
+ if (Object.prototype.hasOwnProperty.call(schema, "const") && !sameJsonValue(value, schema.const)) {
2213
+ errors.push({ path: path4, code: "const", message: `Value must equal ${JSON.stringify(schema.const)}.` });
2214
+ return;
2215
+ }
2216
+ if (Array.isArray(schema.enum) && !schema.enum.some((candidate) => sameJsonValue(value, candidate))) {
2217
+ errors.push({ path: path4, code: "enum", message: `Value must be one of ${schema.enum.map((item) => JSON.stringify(item)).join(", ")}.` });
2218
+ return;
2219
+ }
2220
+ if (!matchesType(schema.type, value)) {
2221
+ errors.push({ path: path4, code: "type", message: `Expected ${String(schema.type)}.` });
2222
+ return;
2223
+ }
2224
+ if (typeof value === "string") validateString(schema, value, path4, errors);
2225
+ if (typeof value === "number") validateNumber(schema, value, path4, errors);
2226
+ if (Array.isArray(value)) validateArray(schema, value, path4, root, errors, depth);
2227
+ if (isRecord(value)) validateObject(schema, value, path4, root, errors, depth);
2228
+ }
2229
+ function validateObject(schema, value, path4, root, errors, depth) {
2230
+ if (typeof schema.minProperties === "number" && Object.keys(value).length < schema.minProperties) {
2231
+ errors.push({ path: path4, code: "min_properties", message: `Expected at least ${schema.minProperties} properties.` });
2232
+ }
2233
+ if (typeof schema.maxProperties === "number" && Object.keys(value).length > schema.maxProperties) {
2234
+ errors.push({ path: path4, code: "max_properties", message: `Expected at most ${schema.maxProperties} properties.` });
2235
+ }
2236
+ const properties = isRecord(schema.properties) ? schema.properties : {};
2237
+ const required = Array.isArray(schema.required) ? schema.required.map(String) : [];
2238
+ for (const key of required) {
2239
+ if (!Object.prototype.hasOwnProperty.call(value, key)) {
2240
+ errors.push({ path: `${path4}.${key}`, code: "required", message: "Required property is missing." });
2241
+ }
2242
+ }
2243
+ if (isRecord(schema.propertyNames)) {
2244
+ for (const key of Object.keys(value)) {
2245
+ validateNode(schema.propertyNames, key, `${path4}.${key}`, root, errors, depth + 1);
2246
+ }
2247
+ }
2248
+ for (const [key, child] of Object.entries(value)) {
2249
+ const childSchema = properties[key];
2250
+ if (isRecord(childSchema)) {
2251
+ validateNode(childSchema, child, `${path4}.${key}`, root, errors, depth + 1);
2252
+ } else if (schema.additionalProperties === false) {
2253
+ errors.push({ path: `${path4}.${key}`, code: "additional_property", message: "Property is not declared by the action schema." });
2254
+ } else if (isRecord(schema.additionalProperties)) {
2255
+ validateNode(schema.additionalProperties, child, `${path4}.${key}`, root, errors, depth + 1);
2256
+ }
2257
+ }
2258
+ }
2259
+ function validateArray(schema, value, path4, root, errors, depth) {
2260
+ if (typeof schema.minItems === "number" && value.length < schema.minItems) {
2261
+ errors.push({ path: path4, code: "min_items", message: `Expected at least ${schema.minItems} items.` });
2262
+ }
2263
+ if (typeof schema.maxItems === "number" && value.length > schema.maxItems) {
2264
+ errors.push({ path: path4, code: "max_items", message: `Expected at most ${schema.maxItems} items.` });
2265
+ }
2266
+ if (schema.uniqueItems === true) {
2267
+ const unique2 = new Set(value.map((item) => JSON.stringify(item)));
2268
+ if (unique2.size !== value.length) errors.push({ path: path4, code: "unique_items", message: "Expected every item to be unique." });
2269
+ }
2270
+ if (isRecord(schema.items)) {
2271
+ value.forEach((item, index) => validateNode(schema.items, item, `${path4}[${index}]`, root, errors, depth + 1));
2272
+ }
2273
+ }
2274
+ function validateString(schema, value, path4, errors) {
2275
+ if (typeof schema.minLength === "number" && value.length < schema.minLength) {
2276
+ errors.push({ path: path4, code: "min_length", message: `Expected at least ${schema.minLength} characters.` });
2277
+ }
2278
+ if (typeof schema.maxLength === "number" && value.length > schema.maxLength) {
2279
+ errors.push({ path: path4, code: "max_length", message: `Expected at most ${schema.maxLength} characters.` });
2280
+ }
2281
+ if (typeof schema.pattern === "string") {
2282
+ try {
2283
+ if (!new RegExp(schema.pattern).test(value)) errors.push({ path: path4, code: "pattern", message: "Value does not match the required pattern." });
2284
+ } catch {
2285
+ errors.push({ path: path4, code: "invalid_schema_pattern", message: "Action schema contains an invalid pattern." });
2286
+ }
2287
+ }
2288
+ if (schema.format === "uri" && !isUrl(value)) errors.push({ path: path4, code: "format_uri", message: "Expected an absolute URL." });
2289
+ if (schema.format === "email" && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value)) errors.push({ path: path4, code: "format_email", message: "Expected an email address." });
2290
+ if (schema.format === "date-time" && !isDateTime(value)) errors.push({ path: path4, code: "format_date_time", message: "Expected an ISO-8601 date-time." });
2291
+ }
2292
+ function validateNumber(schema, value, path4, errors) {
2293
+ if (typeof schema.minimum === "number" && value < schema.minimum) errors.push({ path: path4, code: "minimum", message: `Expected a value of at least ${schema.minimum}.` });
2294
+ if (typeof schema.maximum === "number" && value > schema.maximum) errors.push({ path: path4, code: "maximum", message: `Expected a value of at most ${schema.maximum}.` });
2295
+ const exclusiveMinimum = typeof schema.exclusiveMinimum === "number" ? schema.exclusiveMinimum : schema.exclusiveMinimum === true && typeof schema.minimum === "number" ? schema.minimum : null;
2296
+ const exclusiveMaximum = typeof schema.exclusiveMaximum === "number" ? schema.exclusiveMaximum : schema.exclusiveMaximum === true && typeof schema.maximum === "number" ? schema.maximum : null;
2297
+ if (exclusiveMinimum !== null && value <= exclusiveMinimum) {
2298
+ errors.push({ path: path4, code: "exclusive_minimum", message: `Expected a value greater than ${exclusiveMinimum}.` });
2299
+ }
2300
+ if (exclusiveMaximum !== null && value >= exclusiveMaximum) {
2301
+ errors.push({ path: path4, code: "exclusive_maximum", message: `Expected a value less than ${exclusiveMaximum}.` });
2302
+ }
2303
+ if (typeof schema.multipleOf === "number" && !isMultipleOf(value, schema.multipleOf)) {
2304
+ errors.push({ path: path4, code: "multiple_of", message: `Expected a multiple of ${schema.multipleOf}.` });
2305
+ }
2306
+ }
2307
+ function candidateMatches(candidate, value, root, depth) {
2308
+ if (!isRecord(candidate)) return false;
2309
+ const issues = [];
2310
+ validateNode(candidate, value, "$", root, issues, depth + 1);
2311
+ return issues.length === 0;
2312
+ }
2313
+ function resolveLocalReference(root, reference) {
2314
+ if (!reference.startsWith("#/")) return null;
2315
+ let current = root;
2316
+ for (const token of reference.slice(2).split("/").map((part) => part.replace(/~1/g, "/").replace(/~0/g, "~"))) {
2317
+ if (Array.isArray(current)) {
2318
+ if (!/^\d+$/.test(token) || Number(token) >= current.length) return null;
2319
+ current = current[Number(token)];
2320
+ continue;
2321
+ }
2322
+ if (!isRecord(current) || !Object.prototype.hasOwnProperty.call(current, token)) return null;
2323
+ current = current[token];
2324
+ }
2325
+ return isRecord(current) ? current : null;
2326
+ }
2327
+ function matchesType(type, value) {
2328
+ if (type === void 0) return true;
2329
+ if (Array.isArray(type)) return type.some((candidate) => matchesType(candidate, value));
2330
+ if (type === "null") return value === null;
2331
+ if (type === "array") return Array.isArray(value);
2332
+ if (type === "object") return isRecord(value);
2333
+ if (type === "integer") return typeof value === "number" && Number.isInteger(value);
2334
+ return typeof value === type;
2335
+ }
2336
+ function sameJsonValue(left, right) {
2337
+ return JSON.stringify(left) === JSON.stringify(right);
2338
+ }
2339
+ function isRecord(value) {
2340
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
2341
+ }
2342
+ function isUrl(value) {
2343
+ try {
2344
+ return Boolean(new URL(value).protocol);
2345
+ } catch {
2346
+ return false;
2347
+ }
2348
+ }
2349
+ function isDateTime(value) {
2350
+ return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(value) && !Number.isNaN(Date.parse(value));
2351
+ }
2352
+ function isMultipleOf(value, divisor) {
2353
+ if (!(divisor > 0)) return false;
2354
+ const quotient = value / divisor;
2355
+ return Math.abs(quotient - Math.round(quotient)) < Number.EPSILON * Math.max(1, Math.abs(quotient)) * 4;
2356
+ }
2101
2357
  var WRITE_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
2102
2358
  var TOPOLO_ACTION_ID_HEADER = "X-Topolo-Action-Id";
2103
2359
  var TOPOLO_ACTION_NAME_HEADER = "X-Topolo-Action-Name";
@@ -2317,7 +2573,10 @@ var TopoloClient = class _TopoloClient {
2317
2573
  serviceKey,
2318
2574
  overrides: this.serviceUrls
2319
2575
  });
2320
- return baseUrl ? { serviceKey, baseUrl } : null;
2576
+ if (baseUrl) return { serviceKey, baseUrl };
2577
+ const actionPage = await this.searchActions({ service: match.appId, limit: 1 }).catch(() => null);
2578
+ const actionBaseUrl = actionPage?.actions.find((action) => action.apiBaseUrl)?.apiBaseUrl ?? null;
2579
+ return actionBaseUrl ? { serviceKey, baseUrl: requireHttpOrigin(actionBaseUrl) } : null;
2321
2580
  }
2322
2581
  /**
2323
2582
  * Resolve a service-slug → opaque app-id via caller config or TopoloAuth.
@@ -2402,7 +2661,7 @@ var TopoloClient = class _TopoloClient {
2402
2661
  }
2403
2662
  const entries = [];
2404
2663
  for (const item of raw) {
2405
- if (!isRecord(item)) continue;
2664
+ if (!isRecord2(item)) continue;
2406
2665
  const s = item;
2407
2666
  const appId = typeof s.app_id === "string" ? s.app_id : typeof s.id === "string" ? s.id : null;
2408
2667
  if (!appId) continue;
@@ -2452,6 +2711,44 @@ var TopoloClient = class _TopoloClient {
2452
2711
  }
2453
2712
  return entry;
2454
2713
  }
2714
+ async listBindableResources(application) {
2715
+ const service = await this.getService(application);
2716
+ const identity = await this.introspect();
2717
+ if (identity.apiKey) {
2718
+ return {
2719
+ application: { appId: service.appId, slug: service.slug, name: service.name },
2720
+ organizationId: identity.organization?.id ?? null,
2721
+ resources: identity.apiKey.resourceBindings.map((binding) => ({
2722
+ resourceType: binding.resourceType,
2723
+ resourceId: binding.resourceId,
2724
+ label: null,
2725
+ slug: null,
2726
+ status: "active",
2727
+ organizationId: identity.organization?.id ?? null,
2728
+ metadata: {}
2729
+ })),
2730
+ resourceTypes: service.apiKeyResources,
2731
+ syncError: null,
2732
+ source: "api_key_binding"
2733
+ };
2734
+ }
2735
+ const response = await this.readAction(
2736
+ "app_topolo_auth.service_api_key_resources.list",
2737
+ { appId: service.appId }
2738
+ );
2739
+ const payload = isRecord2(response) && isRecord2(response.data) ? response.data : response;
2740
+ if (!isRecord2(payload) || !Array.isArray(payload.resources)) {
2741
+ throw new TopoloSdkError("resource_catalog_unavailable", "TopoloAuth did not return bindable resources.");
2742
+ }
2743
+ return {
2744
+ application: { appId: service.appId, slug: service.slug, name: service.name },
2745
+ organizationId: readOptionalString(payload.organizationId ?? payload.organization_id),
2746
+ resources: payload.resources.map(normalizeBindableResource).filter((resource) => Boolean(resource)),
2747
+ resourceTypes: normalizeApiKeyResourceMetadataList(payload.resourceTypes ?? payload.resource_types),
2748
+ syncError: readOptionalString(payload.syncError ?? payload.sync_error),
2749
+ source: "auth_catalog"
2750
+ };
2751
+ }
2455
2752
  /**
2456
2753
  * List callable actions from the live registry-canonical catalog (definitions
2457
2754
  * from the Catalog registry, scoping from Auth). The response is scoped to the
@@ -2546,10 +2843,44 @@ var TopoloClient = class _TopoloClient {
2546
2843
  const entry = await this.getAction(action);
2547
2844
  return this.executeResolvedAction(entry, input2, options);
2548
2845
  }
2846
+ async validateActionInput(action, input2 = {}) {
2847
+ const entry = await this.getAction(action);
2848
+ return { action: entry, validation: validateTopoloActionInput(entry.inputSchema, input2) };
2849
+ }
2850
+ async planAction(action, input2 = {}, options = {}) {
2851
+ const entry = await this.getAction(action);
2852
+ const validation = validateTopoloActionInput(entry.inputSchema, input2);
2853
+ const agentAccess = entry.agentAccess ?? (entry.destructive ? "off" : entry.readOnly && !entry.requiresConfirmation ? "auto" : "confirm");
2854
+ const confirmationRequired = agentAccess === "confirm" || entry.requiresConfirmation || entry.destructive;
2855
+ let request = null;
2856
+ if (validation.valid) {
2857
+ const mapped = mapActionInput(entry, input2);
2858
+ request = {
2859
+ method: entry.method,
2860
+ service: entry.appSlug || entry.appId,
2861
+ apiBaseUrl: entry.apiBaseUrl,
2862
+ path: mapped.path,
2863
+ ...mapped.query ? { query: mapped.query } : {},
2864
+ ...mapped.body !== void 0 ? { body: mapped.body } : {},
2865
+ ...options.resource ? { resource: options.resource } : {}
2866
+ };
2867
+ }
2868
+ return {
2869
+ action: entry,
2870
+ validation,
2871
+ confirmationRequired,
2872
+ confirmationProvided: options.confirm === true,
2873
+ executable: validation.valid && agentAccess !== "off" && (!confirmationRequired || options.confirm === true),
2874
+ request,
2875
+ verification: stringArray(entry.agentContract?.verify),
2876
+ rollbackActionId: pickActionString(entry.agentContract?.rollbackActionId),
2877
+ nextActionIds: stringArray(entry.agentContract?.nextActionIds)
2878
+ };
2879
+ }
2549
2880
  async readAction(action, input2 = {}, options = {}) {
2550
2881
  const entry = await this.getAction(action);
2551
2882
  const agentAccess = entry.agentAccess ?? (entry.destructive ? "off" : entry.readOnly && !entry.requiresConfirmation ? "auto" : "confirm");
2552
- if (entry.method !== "GET" || !entry.readOnly || entry.destructive || entry.requiresConfirmation || agentAccess !== "auto") {
2883
+ if (!entry.readOnly || entry.destructive || entry.requiresConfirmation || agentAccess !== "auto") {
2553
2884
  throw new TopoloPermissionError(
2554
2885
  `Topolo action "${entry.actionId}" is not an automatic read-only action.`,
2555
2886
  entry.requiredPermission ? [entry.requiredPermission] : []
@@ -2570,6 +2901,13 @@ var TopoloClient = class _TopoloClient {
2570
2901
  `Topolo action "${entry.actionId}" requires { confirm: true } before invocation.`
2571
2902
  );
2572
2903
  }
2904
+ const validation = validateTopoloActionInput(entry.inputSchema, input2);
2905
+ if (!validation.valid) {
2906
+ throw new TopoloSdkError(
2907
+ "invalid_action_input",
2908
+ `Topolo action "${entry.actionId}" input is invalid: ${validation.errors.map((issue) => `${issue.path} ${issue.message}`).join("; ")}`
2909
+ );
2910
+ }
2573
2911
  const mapped = mapActionInput(entry, input2);
2574
2912
  const service = entry.appSlug || entry.appId;
2575
2913
  return this.request({
@@ -2584,7 +2922,7 @@ var TopoloClient = class _TopoloClient {
2584
2922
  [TOPOLO_ACTION_ID_HEADER]: entry.actionId,
2585
2923
  [TOPOLO_ACTION_NAME_HEADER]: entry.name
2586
2924
  },
2587
- confirm: options.confirm === true
2925
+ confirm: options.confirm === true || entry.readOnly && !entry.destructive && !entry.requiresConfirmation
2588
2926
  });
2589
2927
  }
2590
2928
  /**
@@ -2641,7 +2979,14 @@ var TopoloClient = class _TopoloClient {
2641
2979
  id: organization.id,
2642
2980
  slug: organization.slug,
2643
2981
  name: organization.name ?? organization.slug
2644
- } : user.orgId && user.orgSlug ? { id: user.orgId, slug: user.orgSlug, name: user.orgSlug } : null
2982
+ } : user.orgId && user.orgSlug ? { id: user.orgId, slug: user.orgSlug, name: user.orgSlug } : null,
2983
+ apiKey: kind === "api_key" ? {
2984
+ id: readOptionalString(payload.apiKey?.id),
2985
+ name: readOptionalString(payload.apiKey?.name),
2986
+ appId: readOptionalString(payload.apiKey?.appId),
2987
+ scopes: stringArray(payload.apiKey?.scopes),
2988
+ resourceBindings: normalizeResourceBindings(payload.apiKey?.resourceBindings)
2989
+ } : null
2645
2990
  };
2646
2991
  }
2647
2992
  };
@@ -2656,7 +3001,7 @@ function resolveConfiguredAppId(slug, appIds) {
2656
3001
  return null;
2657
3002
  }
2658
3003
  function normalizeApiKeyResourceMetadata(input2) {
2659
- if (!isRecord(input2)) return null;
3004
+ if (!isRecord2(input2)) return null;
2660
3005
  const resourceType = readString(input2.resourceType ?? input2.resource_type);
2661
3006
  const label = readString(input2.label);
2662
3007
  const emptyLabel = readString(input2.emptyLabel ?? input2.empty_label);
@@ -2689,10 +3034,38 @@ function normalizeApiKeyResourceMetadataList(input2) {
2689
3034
  }
2690
3035
  return resources;
2691
3036
  }
3037
+ function normalizeBindableResource(input2) {
3038
+ if (!isRecord2(input2)) return null;
3039
+ const resourceType = readString(input2.resourceType ?? input2.resource_type);
3040
+ const resourceId = readString(input2.resourceId ?? input2.resource_id);
3041
+ if (!resourceType || !resourceId) return null;
3042
+ return {
3043
+ resourceType,
3044
+ resourceId,
3045
+ label: readOptionalString(input2.label),
3046
+ slug: readOptionalString(input2.slug),
3047
+ status: readOptionalString(input2.status),
3048
+ organizationId: readOptionalString(input2.organizationId ?? input2.organization_id),
3049
+ metadata: isRecord2(input2.metadata) ? input2.metadata : {}
3050
+ };
3051
+ }
3052
+ function normalizeResourceBindings(input2) {
3053
+ if (!Array.isArray(input2)) return [];
3054
+ return input2.flatMap((binding) => {
3055
+ if (!isRecord2(binding)) return [];
3056
+ const resourceType = readString(binding.resourceType ?? binding.resource_type);
3057
+ const resourceId = readString(binding.resourceId ?? binding.resource_id);
3058
+ return resourceType && resourceId ? [{ resourceType, resourceId }] : [];
3059
+ });
3060
+ }
2692
3061
  function readString(value) {
2693
3062
  return typeof value === "string" ? value.trim() : "";
2694
3063
  }
2695
- function isRecord(value) {
3064
+ function readOptionalString(value) {
3065
+ const normalized = readString(value);
3066
+ return normalized || null;
3067
+ }
3068
+ function isRecord2(value) {
2696
3069
  return typeof value === "object" && value !== null && !Array.isArray(value);
2697
3070
  }
2698
3071
  function resolveConfiguredServiceUrl(slug, serviceUrls) {
@@ -2764,14 +3137,14 @@ function catalogSearchQuery(options) {
2764
3137
  return query;
2765
3138
  }
2766
3139
  function normalizeCatalogPage(value) {
2767
- if (!isRecord(value)) return { nextCursor: null, hasMore: false };
3140
+ if (!isRecord2(value)) return { nextCursor: null, hasMore: false };
2768
3141
  return {
2769
3142
  nextCursor: typeof value.nextCursor === "string" && value.nextCursor ? value.nextCursor : null,
2770
3143
  hasMore: value.hasMore === true
2771
3144
  };
2772
3145
  }
2773
3146
  function normalizeServiceCatalogEntry(item) {
2774
- if (!isRecord(item)) return null;
3147
+ if (!isRecord2(item)) return null;
2775
3148
  const appId = typeof item.app_id === "string" ? item.app_id : typeof item.id === "string" ? item.id : null;
2776
3149
  if (!appId) return null;
2777
3150
  const permissions = Array.isArray(item.granted_permissions) ? item.granted_permissions.filter(
@@ -2826,6 +3199,7 @@ function normalizeActionCatalogEntry(item) {
2826
3199
  authorizationMode: pickAuthorizationMode(a.authorization_mode ?? a.authorizationMode),
2827
3200
  inputSchema: plainObject(a.input_schema ?? a.inputSchema),
2828
3201
  outputSchema: plainObject(a.output_schema ?? a.outputSchema),
3202
+ agentContract: plainObject(a.agent_contract ?? a.agentContract),
2829
3203
  readOnly: Boolean(a.read_only ?? a.readOnly),
2830
3204
  destructive: Boolean(a.destructive),
2831
3205
  requiresConfirmation: Boolean(a.requires_confirmation ?? a.requiresConfirmation),
@@ -2849,6 +3223,9 @@ function pickAuthorizationMode(value) {
2849
3223
  function plainObject(value) {
2850
3224
  return value && typeof value === "object" && !Array.isArray(value) ? value : {};
2851
3225
  }
3226
+ function stringArray(value) {
3227
+ return Array.isArray(value) ? value.map((item) => String(item || "").trim()).filter(Boolean) : [];
3228
+ }
2852
3229
  function mapActionInput(action, input2) {
2853
3230
  const remaining = { ...input2 || {} };
2854
3231
  const path4 = action.path.replace(/\{([a-zA-Z0-9_]+)\}/g, (_match, key) => {
@@ -3076,7 +3453,7 @@ function createTopolo(options) {
3076
3453
  };
3077
3454
  }
3078
3455
 
3079
- // ../topolo-sdk/dist/node/credential-store.js
3456
+ // ../../../topolo-platform/packages/topolo-sdk/dist/node/credential-store.js
3080
3457
  import { mkdir, readFile, writeFile, chmod } from "fs/promises";
3081
3458
  import { existsSync } from "fs";
3082
3459
  import { homedir, platform } from "os";
@@ -3959,8 +4336,10 @@ function formatPretty(info) {
3959
4336
  return lines.join("\n");
3960
4337
  }
3961
4338
 
4339
+ // src/commands/actions.ts
4340
+ import { readFileSync as readFileSync2 } from "fs";
4341
+
3962
4342
  // src/commands/api.ts
3963
- var STATIC_APP_IDS = new Set(Object.keys(DEFAULT_APP_URLS));
3964
4343
  var HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
3965
4344
  var WRITE_METHODS2 = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
3966
4345
  function registerApiCommand(program2) {
@@ -3987,9 +4366,8 @@ function registerApiCommand(program2) {
3987
4366
  const config = await readConfig();
3988
4367
  const profile = resolvedProfileConfig(config, environment);
3989
4368
  const configuredUrl = resolveConfiguredServiceUrl2(service, profile.serviceUrls);
3990
- const staticUrl = STATIC_APP_IDS.has(service) ? DEFAULT_APP_URLS[service] : void 0;
3991
- if (configuredUrl || staticUrl) {
3992
- assertServiceUrlForEnvironment(service, configuredUrl ?? staticUrl, environment);
4369
+ if (configuredUrl) {
4370
+ assertServiceUrlForEnvironment(service, configuredUrl, environment);
3993
4371
  }
3994
4372
  let body = void 0;
3995
4373
  if (opts.data) {
@@ -4096,7 +4474,7 @@ function registerActionsCommand(program2) {
4096
4474
  fail(err, mode);
4097
4475
  }
4098
4476
  });
4099
- actions.command("call <action>").description("Call a permission-scoped Topolo action").option("-d, --data <json>", "JSON action input", "{}").option("--resource-type <type>", "App-local resource alias, such as workspace").option("--resource-id <id>", "Resource id for the selected resource type").option("--confirm", "Acknowledge a mutating action").option("--json", "Output JSON").option("--no-json", "Output human-readable text").action(async (action, opts, command) => {
4477
+ actions.command("call <action>").description("Call a permission-scoped Topolo action").option("-d, --data <json-or-@file>", "JSON action input or @path to a JSON file", "{}").option("--resource-type <type>", "App-local resource alias, such as workspace").option("--resource-id <id>", "Resource id for the selected resource type").option("--confirm", "Acknowledge a mutating action").option("--json", "Output JSON").option("--no-json", "Output human-readable text").action(async (action, opts, command) => {
4100
4478
  const mode = resolveOutputMode(opts.json ?? command.parent?.opts().json);
4101
4479
  try {
4102
4480
  const topolo = await buildClient();
@@ -4114,11 +4492,78 @@ function registerActionsCommand(program2) {
4114
4492
  fail(err, mode);
4115
4493
  }
4116
4494
  });
4495
+ actions.command("validate <action>").description("Validate action input locally against the published JSON Schema").option("-d, --data <json-or-@file>", "JSON action input or @path to a JSON file", "{}").option("--json", "Output JSON").option("--no-json", "Output human-readable text").action(async (action, opts, command) => {
4496
+ const mode = resolveOutputMode(opts.json ?? command.parent?.opts().json);
4497
+ try {
4498
+ const topolo = await requiredClient();
4499
+ const result = await topolo.client.validateActionInput(action, parseJsonObject(opts.data || "{}"));
4500
+ emit(result, mode, () => result.validation.valid ? `Valid input for ${result.action.actionId}.` : result.validation.errors.map((issue) => `${issue.path}: ${issue.message}`).join("\n"));
4501
+ } catch (err) {
4502
+ fail(err, mode);
4503
+ }
4504
+ });
4505
+ actions.command("plan <action>").description("Validate and preview the exact request, confirmation, verification, and rollback plan").option("-d, --data <json-or-@file>", "JSON action input or @path to a JSON file", "{}").option("--resource-type <type>", "App-local resource alias, such as workspace").option("--resource-id <id>", "Resource id for the selected resource type").option("--confirm", "Include confirmation in the plan without executing it").option("--json", "Output JSON").option("--no-json", "Output human-readable text").action(async (action, opts, command) => {
4506
+ const mode = resolveOutputMode(opts.json ?? command.parent?.opts().json);
4507
+ try {
4508
+ const topolo = await requiredClient();
4509
+ const resource = parseResourceOption(opts.resourceType, opts.resourceId);
4510
+ const plan = await topolo.client.planAction(action, parseJsonObject(opts.data || "{}"), {
4511
+ confirm: opts.confirm === true,
4512
+ ...resource ? { resource } : {}
4513
+ });
4514
+ emit(plan, mode, () => formatActionPlan(plan));
4515
+ } catch (err) {
4516
+ fail(err, mode);
4517
+ }
4518
+ });
4519
+ actions.command("examples <action>").description("Show published examples and public docs for one action").option("--json", "Output JSON").option("--no-json", "Output human-readable text").action(async (action, opts, command) => {
4520
+ const mode = resolveOutputMode(opts.json ?? command.parent?.opts().json);
4521
+ try {
4522
+ const topolo = await requiredClient();
4523
+ const entry = await topolo.client.getAction(action);
4524
+ const result = {
4525
+ actionId: entry.actionId,
4526
+ docsUrl: entry.agentContract?.docsUrl ?? null,
4527
+ examples: entry.agentContract?.examples ?? []
4528
+ };
4529
+ emit(result, mode, () => result.examples.length ? JSON.stringify(result, null, 2) : `No inline examples published. Docs: ${result.docsUrl || "not published"}`);
4530
+ } catch (err) {
4531
+ fail(err, mode);
4532
+ }
4533
+ });
4534
+ actions.command("capabilities").description("Discover capability and schema actions for one application").option("-s, --service <service>", "App id, slug, or name").option("--json", "Output JSON").option("--no-json", "Output human-readable table").action(async (opts, command) => {
4535
+ const mode = resolveOutputMode(opts.json ?? command.parent?.opts().json);
4536
+ try {
4537
+ const topolo = await requiredClient();
4538
+ const service = opts.service || command.parent?.opts().service;
4539
+ if (!service?.trim()) throw new Error("--service is required for capability discovery");
4540
+ const result = await topolo.client.searchActions({ service: service.trim(), query: "capabilities", limit: 100 });
4541
+ emit(result, mode, () => formatActionRows(result.actions, result.page.nextCursor));
4542
+ } catch (err) {
4543
+ fail(err, mode);
4544
+ }
4545
+ });
4546
+ }
4547
+ async function requiredClient() {
4548
+ const topolo = await buildClient();
4549
+ if (!topolo) throw new Error("No credential configured. Run `topolo auth login` or set TOPOLO_API_KEY.");
4550
+ return topolo;
4117
4551
  }
4118
4552
  function parseJsonObject(value) {
4553
+ let source = value;
4554
+ if (value.startsWith("@")) {
4555
+ const path4 = value.slice(1);
4556
+ if (!path4) throw new Error("--data @file path must not be empty");
4557
+ try {
4558
+ source = readFileSync2(path4, "utf8");
4559
+ } catch (err) {
4560
+ const detail = err instanceof Error ? err.message : String(err);
4561
+ throw new Error(`Could not read --data file ${path4}: ${detail}`);
4562
+ }
4563
+ }
4119
4564
  let parsed;
4120
4565
  try {
4121
- parsed = JSON.parse(value);
4566
+ parsed = JSON.parse(source);
4122
4567
  } catch {
4123
4568
  throw new Error("--data must be valid JSON");
4124
4569
  }
@@ -4164,21 +4609,37 @@ function formatActionDetail(action) {
4164
4609
  `Path: ${action.path}`,
4165
4610
  `Permission: ${action.requiredPermission}`,
4166
4611
  `Confirmation: ${action.requiresConfirmation ? "required" : "not required"}`,
4612
+ `Docs: ${action.agentContract?.docsUrl || "-"}`,
4613
+ `Rollback: ${action.agentContract?.rollbackActionId || "-"}`,
4167
4614
  "",
4168
4615
  action.description
4169
4616
  ].join("\n");
4170
4617
  }
4618
+ function formatActionPlan(plan) {
4619
+ const lines = [
4620
+ `Action: ${plan.action.actionId}`,
4621
+ `Valid: ${plan.validation.valid ? "yes" : "no"}`,
4622
+ `Executable: ${plan.executable ? "yes" : "no"}`,
4623
+ `Confirmation: ${plan.confirmationRequired ? plan.confirmationProvided ? "provided" : "required" : "not required"}`
4624
+ ];
4625
+ if (plan.request) lines.push(`Request: ${plan.request.method} ${plan.request.apiBaseUrl || plan.request.service}${plan.request.path}`);
4626
+ if (plan.request?.resource) lines.push(`Resource: ${plan.request.resource.resourceType}:${plan.request.resource.resourceId}`);
4627
+ if (plan.validation.errors.length) lines.push("", ...plan.validation.errors.map((issue) => `${issue.path}: ${issue.message}`));
4628
+ if (plan.verification.length) lines.push("", "Verify:", ...plan.verification.map((step) => `- ${step}`));
4629
+ if (plan.rollbackActionId) lines.push("", `Rollback: ${plan.rollbackActionId}`);
4630
+ return lines.join("\n");
4631
+ }
4171
4632
 
4172
4633
  // src/commands/apps.ts
4173
4634
  import { execSync } from "child_process";
4174
- import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync3, writeFileSync } from "fs";
4635
+ import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync4, writeFileSync } from "fs";
4175
4636
  import path2 from "path";
4176
4637
 
4177
4638
  // src/commands/app-audit-local.ts
4178
4639
  import {
4179
4640
  existsSync as existsSync2,
4180
4641
  readdirSync,
4181
- readFileSync as readFileSync2,
4642
+ readFileSync as readFileSync3,
4182
4643
  statSync
4183
4644
  } from "fs";
4184
4645
  import path from "path";
@@ -4189,7 +4650,7 @@ function readEvidenceFile(appRoot) {
4189
4650
  const candidate = path.join(appRoot, ".topolo", "evidence.json");
4190
4651
  if (!isFile(candidate)) return null;
4191
4652
  try {
4192
- const raw = JSON.parse(readFileSync2(candidate, "utf8"));
4653
+ const raw = JSON.parse(readFileSync3(candidate, "utf8"));
4193
4654
  if (raw && typeof raw === "object" && raw.version === 1) {
4194
4655
  return raw;
4195
4656
  }
@@ -5133,7 +5594,7 @@ function readSourceIndex(root) {
5133
5594
  const files = collectSourceFiles(root, 4e3);
5134
5595
  const chunks = files.map((file) => {
5135
5596
  try {
5136
- return readFileSync2(file, "utf8");
5597
+ return readFileSync3(file, "utf8");
5137
5598
  } catch {
5138
5599
  return "";
5139
5600
  }
@@ -5193,7 +5654,7 @@ function sortedDirEntries(dir) {
5193
5654
  function readJson(filePath) {
5194
5655
  if (!isFile(filePath)) return null;
5195
5656
  try {
5196
- return JSON.parse(readFileSync2(filePath, "utf8"));
5657
+ return JSON.parse(readFileSync3(filePath, "utf8"));
5197
5658
  } catch {
5198
5659
  return null;
5199
5660
  }
@@ -9289,7 +9750,8 @@ function applicationRowsFromCatalog(catalog) {
9289
9750
  name: entry.name ?? entry.slug ?? entry.appId,
9290
9751
  appDir: "-",
9291
9752
  packageName: null,
9292
- productionUrl: entry.launchUrl ?? null,
9753
+ apiBaseUrl: entry.apiBaseUrl ?? null,
9754
+ launchUrl: entry.launchUrl ?? null,
9293
9755
  services: [displayCatalogId(entry)],
9294
9756
  deployTargets: 0
9295
9757
  })).sort((left, right) => left.id.localeCompare(right.id));
@@ -9354,7 +9816,7 @@ function recordApplicationEvidence(application, options = {}) {
9354
9816
  let before = null;
9355
9817
  if (existsSync3(evidencePath)) {
9356
9818
  try {
9357
- const raw = JSON.parse(readFileSync3(evidencePath, "utf8"));
9819
+ const raw = JSON.parse(readFileSync4(evidencePath, "utf8"));
9358
9820
  if (raw && typeof raw === "object" && raw.version === 1) {
9359
9821
  before = raw;
9360
9822
  }
@@ -9434,7 +9896,7 @@ function evaluateApplicationAuditGate(payload, failOn) {
9434
9896
  function formatRows(rows) {
9435
9897
  const idWidth = Math.max(2, ...rows.map((r) => r.id.length));
9436
9898
  const nameWidth = Math.max(4, ...rows.map((r) => r.name.length));
9437
- const urlWidth = Math.max(3, ...rows.map((r) => (r.productionUrl ?? "-").length));
9899
+ const urlWidth = Math.max(3, ...rows.map((r) => (applicationUrl(r) ?? "-").length));
9438
9900
  const servicesWidth = Math.max(
9439
9901
  8,
9440
9902
  ...rows.map((r) => r.services.length ? r.services.join(",").length : 1)
@@ -9449,7 +9911,7 @@ function formatRows(rows) {
9449
9911
  for (const r of rows) {
9450
9912
  const services = r.services.length ? r.services.join(",") : "-";
9451
9913
  lines.push(
9452
- `${r.id.padEnd(idWidth)} ${r.name.padEnd(nameWidth)} ${(r.productionUrl ?? "-").padEnd(urlWidth)} ${services.padEnd(servicesWidth)} ${r.deployTargets}`
9914
+ `${r.id.padEnd(idWidth)} ${r.name.padEnd(nameWidth)} ${(applicationUrl(r) ?? "-").padEnd(urlWidth)} ${services.padEnd(servicesWidth)} ${r.deployTargets}`
9453
9915
  );
9454
9916
  }
9455
9917
  return lines.join("\n");
@@ -9458,10 +9920,14 @@ function formatApplicationRow(application) {
9458
9920
  return [
9459
9921
  `ID: ${application.id}`,
9460
9922
  `Name: ${application.name}`,
9461
- `URL: ${application.productionUrl ?? "-"}`,
9923
+ `API URL: ${application.apiBaseUrl ?? "-"}`,
9924
+ `Launch URL: ${application.launchUrl ?? application.productionUrl ?? "-"}`,
9462
9925
  `Services: ${application.services.length ? application.services.join(", ") : "-"}`
9463
9926
  ].join("\n");
9464
9927
  }
9928
+ function applicationUrl(application) {
9929
+ return application.apiBaseUrl ?? application.launchUrl ?? application.productionUrl ?? null;
9930
+ }
9465
9931
  function displayCatalogId(entry) {
9466
9932
  for (const value of [entry.slug, entry.name, entry.appId]) {
9467
9933
  if (typeof value !== "string" || !value.trim()) continue;
@@ -9645,7 +10111,7 @@ function statusesAtOrWorse(threshold) {
9645
10111
  }
9646
10112
 
9647
10113
  // src/commands/platform.ts
9648
- import { readdirSync as readdirSync2, readFileSync as readFileSync4, statSync as statSync2 } from "fs";
10114
+ import { readdirSync as readdirSync2, readFileSync as readFileSync5, statSync as statSync2 } from "fs";
9649
10115
  import path3 from "path";
9650
10116
  function registerPlatformCommand(program2) {
9651
10117
  const platform3 = program2.command("platform").description("Internal Topolo first-party platform and personal app commands");
@@ -9779,7 +10245,7 @@ function scanStagingProductionUrls(rootInput, allowPatterns) {
9779
10245
  function scanFile(filePath) {
9780
10246
  if (shouldSkipFile(filePath)) return;
9781
10247
  const relativePath = path3.relative(root, filePath);
9782
- const content = readFileSync4(filePath, "utf8");
10248
+ const content = readFileSync5(filePath, "utf8");
9783
10249
  const lines = content.split(/\r?\n/);
9784
10250
  lines.forEach((line, index) => {
9785
10251
  const matches = [...line.matchAll(PRODUCTION_TOPOLO_URL_PATTERN)].map((match) => match[0]);
@@ -10859,6 +11325,34 @@ function registerContextCommand(program2) {
10859
11325
  });
10860
11326
  }
10861
11327
 
11328
+ // src/commands/resources.ts
11329
+ function registerResourcesCommand(program2) {
11330
+ program2.command("resources <application>").description("List bindable resource ids for one credential-scoped Topolo application").option("--json", "Output JSON").option("--no-json", "Output human-readable text").action(async (application, opts) => {
11331
+ const mode = resolveOutputMode(opts.json);
11332
+ try {
11333
+ const topolo = await buildClient();
11334
+ if (!topolo) throw new Error("No credential configured. Run `topolo auth login` or set TOPOLO_API_KEY.");
11335
+ const result = await topolo.client.listBindableResources(application);
11336
+ emit(result, mode, () => formatResources(result));
11337
+ } catch (err) {
11338
+ fail(err, mode);
11339
+ }
11340
+ });
11341
+ }
11342
+ function formatResources(result) {
11343
+ const lines = [
11344
+ `Application: ${result.application.name || result.application.slug || result.application.appId}`,
11345
+ `Source: ${result.source}`
11346
+ ];
11347
+ if (result.resources.length === 0) return [...lines, "", "No bindable resources available."].join("\n");
11348
+ lines.push("", "TYPE ID LABEL");
11349
+ for (const resource of result.resources) {
11350
+ lines.push(`${resource.resourceType} ${resource.resourceId} ${resource.label || resource.slug || "-"}`);
11351
+ }
11352
+ if (result.syncError) lines.push("", `Sync warning: ${result.syncError}`);
11353
+ return lines.join("\n");
11354
+ }
11355
+
10862
11356
  // src/index.ts
10863
11357
  var program = new Command();
10864
11358
  var argv;
@@ -10889,6 +11383,7 @@ registerServicesCommand(program);
10889
11383
  registerSeedCommands(program);
10890
11384
  registerSkillsCommand(program);
10891
11385
  registerContextCommand(program);
11386
+ registerResourcesCommand(program);
10892
11387
  program.parseAsync(argv).catch((err) => {
10893
11388
  process.stderr.write(`Unexpected error: ${err instanceof Error ? err.message : String(err)}
10894
11389
  `);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topolo/cli",
3
- "version": "0.2.4",
3
+ "version": "0.4.0",
4
4
  "description": "Command-line interface for Topolo developers, operators, and agents, with internal first-party scaffolding available under the topolo platform namespace.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  # Topolo — agent guide (Codex / general-purpose agents)
2
2
 
3
- This file teaches an agent how to interact with the Topolo platform via the `topolo` CLI and, when available, the Topolo MCP server. It is intentionally short, shell-first, and stable across Codex, Cursor, and any other agent that reads `AGENTS.md`.
3
+ This file teaches an agent how to interact with the Topolo platform via the `topolo` CLI and, when available, the Topolo MCP server. It assumes no repository access or private product knowledge; public Topolo docs and credential-scoped runtime discovery are authoritative.
4
4
 
5
5
  ## What the platform is
6
6
 
@@ -11,6 +11,7 @@ Topolo is a multi-app SaaS platform. The CLI and MCP server both use `@topolo/sd
11
11
  - Use `topolo apps audit <application_id> --json` or the MCP `topolo_audit_applications` tool to inspect current requirement conformance and migration work.
12
12
  - Use `topolo services --json` or the MCP `topolo_search_applications` tool to discover callable app ids and effective URLs.
13
13
  - Use `topolo actions --service <app> --json` or MCP `topolo_search_actions` to discover callable operations granted to the current credential; invoke automatic reads with `topolo_read_action` and confirmation-gated operations with `topolo_call_action`.
14
+ - Use `topolo actions capabilities`, `actions examples`, `actions validate`, and `actions plan` (or their MCP equivalents) to obtain schemas, examples, verification, and rollback before execution.
14
15
  - The generic `api` command and MCP `topolo_api_call` tool can reach bundled SDK services and live Auth-catalog API/technical services when no action contract exists yet.
15
16
 
16
17
  Every call is scoped to the organization embedded in the credential. There is no `--org` flag anywhere — attempting to target another org will fail.
@@ -94,6 +95,11 @@ URL and the CLI will refuse `topolo api` calls until a staging override is set.
94
95
  ```bash
95
96
  topolo actions --service mail --json
96
97
  topolo actions get app_topolo_mail.messages.send --json
98
+ topolo actions examples app_topolo_mail.messages.send --json
99
+ topolo actions validate app_topolo_mail.messages.send \
100
+ --data '{"mailboxId":"primary","subject":"Hello","text":"..."}' --json
101
+ topolo actions plan app_topolo_mail.messages.send \
102
+ --data '{"mailboxId":"primary","subject":"Hello","text":"..."}' --json
97
103
  topolo actions call app_topolo_mail.messages.send \
98
104
  --data '{"mailboxId":"primary","subject":"Hello","text":"..."}' \
99
105
  --confirm --json
@@ -104,6 +110,10 @@ operations. It is filtered by the signed-in organization and the exact service
104
110
  permissions on the credential. Actions marked `authorizationMode: "runtime"`
105
111
  have input-dependent policy and are definitively authorized by the target app
106
112
  for the supplied organization, application, and resource at invocation time.
113
+ Never guess a missing action, field, resource id, endpoint, verification step,
114
+ or rollback. If catalog metadata and `https://docs.topolo.app` do not provide a
115
+ complete contract, report the missing publication contract instead of reading
116
+ source code or inferring a private API.
107
117
 
108
118
  ### Generic passthrough
109
119
 
@@ -151,5 +161,4 @@ Do not batch writes. Do not retry a failed write without re-confirming. The `--c
151
161
 
152
162
  - Documentation: `https://docs.topolo.app` (guides, authentication, API reference).
153
163
  - Developers console (register apps / OAuth clients): `https://developers.topolo.app`
154
- - CLI README: the `README.md` alongside this file.
155
- - Schemas: `packages/topolo-sdk/src/modules/` in the monorepo.
164
+ - CLI help: `topolo --help` and `topolo <command> --help`.
@@ -1,248 +1,226 @@
1
1
  ---
2
2
  name: topolo
3
- description: Read and write Topolo platform data (identity, application/service discovery, credential-scoped actions, and any SDK-registered Topolo API) from the shell via the `topolo` CLI. Use whenever the user references their Topolo organization, a Topolo app/service, or asks to mint/inspect Topolo credentials.
3
+ description: Operate the Topolo platform from any computer using only public Topolo documentation and credential-scoped CLI or MCP discovery. Use for Topolo identity, organizations, applications, resources, actions, OAuth, and API keys.
4
4
  ---
5
5
 
6
- # Topolo platform
6
+ # Topolo clean-room operator
7
7
 
8
- The `topolo` CLI is the agent-friendly entry point to the Topolo platform. Every command runs in the **active context** embedded in the current credential — the user's personal space or a specific organization. You cannot bypass the credential or forge a context; to act in a different organization (or personal), switch with `topolo context use` (see Contexts).
8
+ Use Topolo without repository access, private schemas, or prior product knowledge. The authenticated CLI/MCP catalog and `https://docs.topolo.app` are the only operational sources of truth.
9
9
 
10
- ## When this skill fires
10
+ Never inspect or request Topolo source code for ordinary platform work. Never invent an endpoint, action id, field, enum, resource id, permission, verification step, or rollback. If the live contract and public docs do not provide enough information, report the exact missing metadata as a platform contract defect.
11
11
 
12
- Use `topolo` whenever the user:
12
+ ## Install and connect
13
13
 
14
- - References records, messages, customers, or any other data stored in a Topolo app.
15
- - Asks about their Topolo identity ("who am I logged in as", "what org am I in", "what scopes do I have").
16
- - Wants to call an endpoint under a Topolo service from the shell.
17
- - Mentions a Topolo OAuth client (`doac_…`) or API key.
14
+ The CLI requires Node 20 or newer:
18
15
 
19
- If the user asks a platform question and `topolo` is not installed, install it: `npm install -g @topolo/cli`. Requires Node ≥ 20. To wire this skill and the MCP server into Claude Code / Codex in one step, run `topolo skills install` (copies the skill file, registers the `topolo` MCP; no API key needed — the MCP reads the same credential `topolo auth login` writes).
16
+ ```bash
17
+ command -v topolo || npm install -g @topolo/cli
18
+ topolo skills install
19
+ ```
20
+
21
+ `topolo skills install` installs this skill and registers the Topolo MCP server for Claude Code and Codex. Both use the same credential written by `topolo auth login`.
20
22
 
21
- ## Credentials — always check first
23
+ Production is the default. Use `--env staging` on every staging command, or set `TOPOLO_ENV=staging`. Never mix staging discovery with production execution.
22
24
 
23
- Before any data command, confirm a credential is configured:
25
+ ## Authentication gate
26
+
27
+ Start every session with:
24
28
 
25
29
  ```bash
26
30
  topolo auth status --json
31
+ topolo whoami --json
27
32
  ```
28
33
 
29
- Production is the default environment. Use `--env staging` anywhere in the
30
- command, or set `TOPOLO_ENV=staging`, when the user is testing internal staging
31
- or provisioning a staging-only first-party app:
34
+ If `source` is `none`, run `topolo auth login` and wait for browser approval. The built-in first-party OAuth client needs no client id. For a headless session, use `topolo auth login --api-key=<key>` or inject `TOPOLO_API_KEY` for one command. Never store credentials in a project or tracked file.
35
+
36
+ Authentication scopes and effective authorization are different:
37
+
38
+ - OAuth scopes describe what the token may ask the platform to do.
39
+ - `whoami`, the credential-scoped application catalog, action catalog, target policy, and resource binding determine what the current identity may actually do.
40
+ - An action with `authorizationMode: "runtime"` is discoverable, but the target application makes the final authorization decision from the concrete input and credential context.
41
+ - A 403 is authoritative. Do not retry it with guessed fields, another endpoint, or broader behavior.
42
+
43
+ The credential fixes the personal or organization context. There is no `--org` override:
32
44
 
33
45
  ```bash
34
- topolo auth status --env staging --json
35
- topolo auth login --env staging
46
+ topolo context ls --json
47
+ topolo context use <organization-name|personal> --json
48
+ topolo whoami --json
36
49
  ```
37
50
 
38
- Possible `source` values:
51
+ For concurrent agents or multiple MCP connections, never coordinate by changing
52
+ one shared active context. Register one named `topolo-mcp` process per context
53
+ with both `TOPOLO_ENV` and `TOPOLO_CONTEXT`. The context selector accepts an
54
+ exact key returned by `topolo context ls --json` (such as `personal` or
55
+ `org:<orgId>`) or a unique stored organization id, slug, or name. Each process
56
+ reads and refreshes only its pinned credential without changing the CLI or web
57
+ session. Do not set `TOPOLO_CONTEXT` alongside an injected API key or access
58
+ token because that credential already carries its immutable context.
39
59
 
40
- | source | meaning |
41
- | --- | --- |
42
- | `env:TOPOLO_STAGING_API_KEY` / `env:TOPOLO_PRODUCTION_API_KEY` | Environment-specific service-scoped API key in env |
43
- | `env:TOPOLO_STAGING_ACCESS_TOKEN` / `env:TOPOLO_PRODUCTION_ACCESS_TOKEN` | Environment-specific short-lived access token in env |
44
- | `env:TOPOLO_API_KEY` | Shared service-scoped API key in env |
45
- | `env:TOPOLO_ACCESS_TOKEN` | Shared short-lived access token in env (dev/testing) |
46
- | `config-file` | Token stored in the selected environment profile at `$XDG_CONFIG_HOME/topolo/config.json` |
47
- | `none` | Not signed in — call `topolo auth login` |
60
+ ## Mandatory clean-room workflow
48
61
 
49
- If `source: none`, run `topolo auth login` (add `--env staging` for staging) and
50
- tell the user to approve the device in the browser tab that opens. The CLI uses
51
- its **built-in first-party client**, so **no `--client-id` is needed** — any
52
- Topolo user can sign in; what they can then do is decided by their permissions.
53
- The CLI blocks until they approve, then stores the token in that environment's
54
- active-context profile. (`--client-id=<doac_…>` is only for advanced/self-hosted
55
- setups that register their own OAuth client.)
62
+ For every Topolo task, follow this sequence.
56
63
 
57
- For service-agent / headless installs, use an API key with
58
- `topolo auth login --api-key=<key>` or set `TOPOLO_API_KEY` only for the single
59
- command that needs it.
64
+ ### 1. Discover the application and resource boundary
60
65
 
61
- ## Output modes
66
+ ```bash
67
+ topolo apps --json
68
+ topolo apps get <application> --json
69
+ topolo services --query <application> --json
70
+ topolo resources <application> --json
71
+ ```
62
72
 
63
- All commands support `--json` (machine) and `--no-json` (human). The CLI autodetects: pretty when stdout is a TTY, JSON when piped. **When you call `topolo` from an agent, always pass `--json`** — the JSON shape is stable; the pretty text is not.
73
+ Use the returned app id or slug. Read `apiKeyResources` to learn whether calls can be scoped to aliases such as a workspace, site, mailbox, brand, or venue. Use `topolo resources` to resolve the actual credential-scoped resource ids. With an API key it returns only the key's bound resources; with OAuth it uses the published Auth resource catalog. Never assume that an organization id is a valid app resource id.
64
74
 
65
- ## Command reference
75
+ MCP equivalents:
66
76
 
67
- ### Identity
77
+ - `topolo_search_applications`
78
+ - `topolo_get_application`
79
+ - `topolo_get_resource_types`
80
+ - `topolo_list_resources`
81
+
82
+ ### 2. Discover credential-scoped actions
68
83
 
69
84
  ```bash
70
- topolo whoami --json
85
+ topolo actions --service <app-id-or-slug> --query <task-keyword> --limit 25 --json
86
+ topolo actions capabilities --service <app-id-or-slug> --json
87
+ topolo actions get <exact-action-id> --json
88
+ topolo actions examples <exact-action-id> --json
71
89
  ```
72
90
 
73
- Returns `{ user: { email, name, role, permissions }, organization: { name, slug } | null, kind }`.
91
+ Inspect the published input/output JSON Schemas and agent contract. The agent contract may provide:
74
92
 
75
- Use this once at the start of a session to confirm the active user + org — then reference that org context in subsequent explanations so the user doesn't have to re-orient.
93
+ - public `docsUrl`
94
+ - resource kind and id/version paths
95
+ - effects
96
+ - actionable error codes and recovery
97
+ - verification steps
98
+ - rollback action id
99
+ - next actions
100
+ - runnable examples
76
101
 
77
- ### Contexts (personal + multiple organizations)
102
+ MCP equivalents:
78
103
 
79
- A user can act in their **personal** space or in any **organization** they belong to. Each context has its own credential; the CLI and MCP always operate in the **active** one.
104
+ - `topolo_search_actions`
105
+ - `topolo_discover_capabilities`
106
+ - `topolo_get_action`
107
+ - `topolo_get_action_examples`
80
108
 
81
- CLI/MCP context is credential-bound and independent from the user's web
82
- switcher preference. `topolo context use` does not move the browser session;
83
- switching in the browser does not alter any issued CLI/MCP token or refresh
84
- chain. Multiple cached personal/org credentials can therefore coexist safely.
109
+ If discovery returns no relevant action, search `https://docs.topolo.app`. Use `topolo api` only when public docs give the exact service, method, path, schema, authorization, and verification behavior. Absence of a catalog action is not permission to infer a private API.
110
+
111
+ ### 3. Resolve identifiers through published reads
112
+
113
+ Prefer `topolo resources <application> --json` or `topolo_list_resources` to resolve required resource ids. If the target publishes a more specific read-only resource action, use it for app-local identifiers. Do not guess ids from names or copy ids from a different organization/context.
114
+
115
+ For an app-local resource boundary, pass resource context separately from action input:
85
116
 
86
117
  ```bash
87
- topolo context ls --json # personal + your orgs, and which is active
88
- topolo context use <organization-name|personal> --json # switch the active context
118
+ topolo actions call <read-action-id> \
119
+ --resource-type <published-resource-type> \
120
+ --resource-id <resolved-resource-id> \
121
+ --data '<schema-valid-json>' --json
89
122
  ```
90
123
 
91
- `topolo context use` activates a cached context or exchanges the current credential for a fresh token scoped to the target (membership is validated server-side). After switching, `topolo whoami` and every subsequent command run in that context. There is **no `--org` flag** — switch context instead. If a command returns a permission error, the user may need to switch to the org that owns the resource, or be granted access there.
124
+ Provide `--resource-type` and `--resource-id` together. MCP read actions use `topolo_read_action` with the same separate resource fields.
125
+
126
+ For API-key creation or updates, do not use the application's general resource list as the authorization decision. Discover and call Auth's `api_keys.options` action with the selected application and active organization. Its response is the complete caller-specific contract for that key operation: organization-wide scopes, deduplicated resource types, authorized resources, each resource's access level and allowed scopes, and whether key creation is allowed.
127
+
128
+ Choose resource bindings before scopes. With no bindings, use only `organizationWideScopes`. With one or more bindings, use only the intersection of `allowedScopes` across every selected resource. Then validate and plan `api_keys.create` or `api_keys.update` with that exact selection. Never widen, merge, or infer eligibility from application metadata, organization ownership alone, or a resource returned in another organization context.
92
129
 
93
- ### Application and service discovery
130
+ ### 4. Validate locally
131
+
132
+ Before every call, validate exact input against the published JSON Schema:
94
133
 
95
134
  ```bash
96
- topolo apps --json
97
- topolo apps get <application_id> --json
98
- topolo apps requirements <application_id> --json
99
- topolo apps audit <application_id> --json
100
- topolo services --json
101
- topolo actions --service <application_id_or_slug> --json
135
+ topolo actions validate <action-id> --data '<json>' --json
102
136
  ```
103
137
 
104
- `topolo apps` returns every cataloged Topolo application, including browser-only
105
- Worker apps that do not expose a callable API. `topolo services` returns every
106
- SDK-app id with its default and effective URL. In staging, a
107
- service with `environmentSafe: false` still resolves to a production SDK URL and
108
- the CLI refuses `topolo api` calls for it until a staging URL override is set.
109
- `topolo apps requirements` returns the current versioned app-build contract and should be run
110
- before creating or expanding a Topolo app. `topolo apps audit` returns
111
- catalog-backed requirement scores and migration work. Run `topolo services`
112
- before `topolo api` when you are not using a cataloged action.
138
+ MCP: `topolo_validate_action`.
113
139
 
114
- ### Credential-scoped actions
140
+ For large inputs, write one schema-valid JSON object to a file and pass it without shell escaping:
115
141
 
116
142
  ```bash
117
- topolo actions --service mail --json
118
- topolo actions get app_topolo_mail.messages.send --json
119
- topolo actions call app_topolo_mail.messages.send \
120
- --data '{"mailboxId":"primary","subject":"Hello","text":"..."}' \
121
- --confirm --json
143
+ topolo actions validate <action-id> --data @payload.json --json
144
+ topolo actions plan <action-id> --data @payload.json --json
145
+ topolo actions call <action-id> --data @payload.json --confirm --json
122
146
  ```
123
147
 
124
- `topolo actions` returns the credential-scoped action catalog. Static actions
125
- are filtered by the current organization and permissions. An action with
126
- `authorizationMode: "runtime"` has input-dependent policy; it remains
127
- discoverable, and the target app authorizes the concrete organization,
128
- application, and resource on every call. Prefer catalog actions over `topolo
129
- api` when the target app has published a machine contract through Topolo
130
- Developers. Writes require `--confirm`.
148
+ Fix every returned path-level validation error. Never add undeclared fields to work around a failure.
131
149
 
132
- For an app-local workspace or other resource boundary, pass the resource
133
- separately from the action input. Resource selection is per invocation: it does
134
- not switch the personal/organization credential context or the browser session.
150
+ ### 5. Plan without execution
135
151
 
136
152
  ```bash
137
- topolo actions call app_topolo_inventory.dashboard.get \
138
- --resource-type workspace \
139
- --resource-id <workspace_id> \
140
- --data '{}' --json
153
+ topolo actions plan <action-id> --data '<json>' --json
141
154
  ```
142
155
 
143
- Use a published workspace-list action to discover IDs when the app has multiple
144
- workspaces. For a single default workspace, use the active organization ID from
145
- `topolo whoami --json` when that app defines its workspace as the organization
146
- boundary. Always provide `--resource-type` and `--resource-id` together.
156
+ MCP: `topolo_plan_action`.
147
157
 
148
- ### Generic API passthrough
158
+ The plan must be valid and must expose the resolved request, confirmation requirement, verification steps, and rollback when one is published. If `executable` is false, do not call the action.
149
159
 
150
- For any endpoint that doesn't yet have a typed subcommand:
160
+ ### 6. Confirm mutations
151
161
 
152
- ```bash
153
- topolo api <service> <method> <path> [--data '<json>'] [--query key=value] [--confirm]
154
- ```
162
+ For a mutation, show the user:
155
163
 
156
- - `<service>`: any id from `topolo services --json`.
157
- - `<method>`: `GET` | `POST` | `PUT` | `PATCH` | `DELETE`.
158
- - `<path>`: starts with `/api/…` — don't include the host.
159
- - **Mutating calls require `--confirm`**. If you omit it, the CLI refuses and exits non-zero. This is intentional: confirm before sending writes.
164
+ - exact action id
165
+ - target method and path from the plan
166
+ - exact input and resource context
167
+ - published effects
168
+ - verification steps
169
+ - rollback action, or that no rollback is published
160
170
 
161
- Examples:
171
+ Obtain explicit approval unless the user already gave unambiguous authorization for this exact production work. The CLI’s `--confirm` is a transport guardrail, not user approval.
162
172
 
163
- ```bash
164
- # Read current user via auth service
165
- topolo api auth GET /api/me --json
173
+ Automatic read-only actions use no confirmation. Confirmed actions use:
166
174
 
167
- # Send mail through a published action (requires confirm)
168
- topolo actions call app_topolo_mail.messages.send \
169
- --data '{"mailboxId":"primary","subject":"Hello","text":"..."}' \
170
- --confirm --json
175
+ ```bash
176
+ topolo actions call <action-id> --data '<json>' --confirm --json
171
177
  ```
172
178
 
173
- ## Error handling
179
+ MCP uses `topolo_read_action` for automatic reads and `topolo_call_action` with `confirm: true` for approved mutations.
174
180
 
175
- Exit codes:
181
+ ### 7. Verify and recover
176
182
 
177
- | Code | Meaning | Action |
178
- | --- | --- | --- |
179
- | 0 | Success | — |
180
- | 1 | Generic error | Surface message to user |
181
- | 2 | Auth error (expired, invalid token) | Run `topolo auth login` and retry |
182
- | 3 | Permission denied | User's role/scopes lack the capability — point them to the Developers console |
183
+ Run every published verification step immediately after execution. Prefer a credential-scoped read action and compare returned ids, versions, status, or URLs to the mutation result.
183
184
 
184
- In `--json` mode, errors come on stderr as `{"error":{"message","code","hint","exitCode"}}`. Parse `code` first when branching.
185
+ If verification fails:
185
186
 
186
- Stored OAuth access tokens refresh automatically when the config contains both a refresh token and the OAuth client id used at login. If `whoami` still returns `auth_error`, have the user re-run `topolo auth login`.
187
+ 1. Preserve the action result and request id.
188
+ 2. Read the published error recovery guidance.
189
+ 3. Plan the published rollback action before calling it.
190
+ 4. Obtain confirmation for the rollback unless prior authorization explicitly covered recovery.
191
+ 5. Execute and verify the rollback.
187
192
 
188
- ## Safety rules
193
+ Never improvise a rollback.
189
194
 
190
- 1. **Never run a mutating `topolo actions call …` or `topolo api … POST|PUT|PATCH|DELETE …` without surfacing the exact body to the user first** and confirming. The `--confirm` flag is a guardrail, not your approval.
191
- 2. **Never write credentials to tracked files.** If a user pastes an API key inline, use it via env var for that invocation (`TOPOLO_API_KEY=… topolo whoami`) rather than committing it.
192
- 3. **Cross-org isolation is built-in** — there is no `--org` flag anywhere, and the backend rederives the org from the credential (it ignores org hints in a request body). To act in a different organization, use `topolo context use <org>` to switch the active context — never try to fake one via the passthrough.
193
- 4. Audit headers (`X-Topolo-Client`, `X-Topolo-Request-Id`, optional `X-Topolo-Agent`) are set by the CLI automatically. If `TOPOLO_AGENT_NAME` is already exported in the user's shell, leave it alone — it's the audit trail.
195
+ ## Generic public API escape hatch
194
196
 
195
- ## Common flows
197
+ Only when public docs provide a complete contract:
196
198
 
197
- **"Show me the available Mail actions"**
198
199
  ```bash
199
- topolo whoami --json # confirm org
200
- topolo actions --service mail --json
200
+ topolo api <service> <GET|POST|PUT|PATCH|DELETE> <path> \
201
+ [--query key=value] [--data '<json>'] [--confirm] --json
201
202
  ```
202
203
 
203
- **"Send a message through Mail"**
204
- ```bash
205
- topolo actions call app_topolo_mail.messages.send \
206
- --data '{"mailboxId":"primary","subject":"Hello","text":"..."}' \
207
- --confirm --json
208
- ```
204
+ Discover `<service>` with `topolo services --json`. Mutating methods require explicit approval and `--confirm`. Prefer catalog actions because they carry schemas, effects, verification, and recovery metadata.
209
205
 
210
- **"What scopes does my CLI token have?"**
211
- ```bash
212
- topolo whoami --json | jq '.user.permissions'
213
- ```
206
+ ## Errors and recovery
214
207
 
215
- **"What app id should I use?"**
216
- ```bash
217
- topolo apps --json
218
- topolo apps requirements <application_id> --json
219
- topolo apps audit <application_id> --json
220
- topolo services --json
221
- topolo actions --service mail --json
222
- ```
208
+ In JSON mode, branch on `error.code`, not message text:
223
209
 
224
- **"Sign me in fresh"**
225
- ```bash
226
- topolo auth logout
227
- topolo auth login # no --client-id needed; approve in the browser
228
- ```
210
+ - exit 2 / authentication error: run `topolo auth login`, then repeat identity and discovery.
211
+ - exit 3 / permission denied: surface the missing permission or target denial; do not retry or widen access.
212
+ - `invalid_action_input`: correct only the reported schema paths.
213
+ - `confirmation_required`: plan, obtain approval, then retry once with confirmation.
214
+ - unknown action/service: repeat bounded discovery; never substitute a guessed identifier.
215
+ - missing docs/schema/examples/verification/recovery: stop the affected operation and report the missing publication contract.
229
216
 
230
- **"Sign me in to staging"**
231
- ```bash
232
- topolo auth logout --env staging
233
- topolo auth login --env staging
234
- topolo whoami --env staging --json
235
- ```
217
+ Use `TOPOLO_DEBUG=1` only when diagnostics are needed. Preserve `X-Topolo-Request-Id` from failures for support. Do not expose tokens or API keys in output.
236
218
 
237
- **"Switch to my <Acme> organization" / "work in my personal space"**
238
- ```bash
239
- topolo context ls --json
240
- topolo context use acme --json # or: topolo context use personal
241
- ```
219
+ ## Public references
242
220
 
243
- ## Where to learn more
221
+ - Documentation: `https://docs.topolo.app`
222
+ - Staging documentation: `https://docs.stg.topolo.us`
223
+ - Developers console: `https://developers.topolo.app`
224
+ - CLI help: `topolo --help` and `topolo <command> --help`
244
225
 
245
- - **Topolo documentation:** https://docs.topolo.app — guides (authentication, quick start), API reference, and per-app docs. (Staging: https://docs.stg.topolo.us)
246
- - Register apps / OAuth clients: the Developers console at https://developers.topolo.app
247
- - CLI source (for an undocumented flag): `packages/topolo-cli/src/commands/` in the topolo-platform monorepo.
248
- - SDK package: `packages/topolo-sdk/src/` — authoritative client, dynamic action catalog, and shared response contracts.
226
+ These public surfaces and credential-scoped runtime responses are authoritative. Repository access is neither required nor an accepted fallback.