@apicircle/mcp-server 1.0.9 → 1.1.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.cjs CHANGED
@@ -1,7 +1,9 @@
1
1
  "use strict";
2
+ var __create = Object.create;
2
3
  var __defProp = Object.defineProperty;
3
4
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
5
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
5
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
6
8
  var __export = (target, all) => {
7
9
  for (var name in all)
@@ -15,21 +17,37 @@ var __copyProps = (to, from, except, desc) => {
15
17
  }
16
18
  return to;
17
19
  };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
18
28
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
29
 
20
30
  // src/index.ts
21
31
  var index_exports = {};
22
32
  __export(index_exports, {
33
+ AI_CLIENTS: () => AI_CLIENTS,
23
34
  FileBackedWorkspaceProvider: () => FileBackedWorkspaceProvider,
35
+ GitBackedWorkspaceProvider: () => GitBackedWorkspaceProvider,
24
36
  InMemoryWorkspaceProvider: () => InMemoryWorkspaceProvider,
25
37
  InProcessMockController: () => InProcessMockController,
38
+ MCP_PROMPTS: () => MCP_PROMPTS,
39
+ MCP_PROMPT_CATEGORIES: () => MCP_PROMPT_CATEGORIES,
26
40
  McpHost: () => McpHost,
27
41
  MultiWorkspaceProvider: () => MultiWorkspaceProvider,
28
42
  SingleWorkspaceAdapter: () => SingleWorkspaceAdapter,
43
+ StdioServerTransport: () => import_stdio2.StdioServerTransport,
44
+ StreamableHTTPServerTransport: () => import_streamableHttp.StreamableHTTPServerTransport,
29
45
  TOOL_REGISTRY: () => TOOL_REGISTRY,
30
46
  WorkspaceNotFoundError: () => WorkspaceNotFoundError,
47
+ buildSnippetVariants: () => buildSnippetVariants,
31
48
  createMcpServer: () => createMcpServer,
32
- getTool: () => getTool
49
+ getTool: () => getTool,
50
+ resolveAiClientConfigPath: () => resolveAiClientConfigPath
33
51
  });
34
52
  module.exports = __toCommonJS(index_exports);
35
53
 
@@ -41,9 +59,10 @@ var import_zod = require("zod");
41
59
  // package.json
42
60
  var package_default = {
43
61
  name: "@apicircle/mcp-server",
44
- version: "1.0.9",
62
+ version: "1.1.0",
45
63
  private: false,
46
64
  type: "module",
65
+ sideEffects: false,
47
66
  description: "Model Context Protocol server exposing API Circle Studio's workspace as a tool catalog. Used by Claude Desktop, ChatGPT, Cursor, GitHub Copilot, and any other MCP-compatible AI client.",
48
67
  keywords: [
49
68
  "apicircle",
@@ -86,7 +105,8 @@ var package_default = {
86
105
  "apicircle-mcp": "./dist/bin/mcp-server.cjs"
87
106
  },
88
107
  exports: {
89
- ".": "./src/index.ts"
108
+ ".": "./src/index.ts",
109
+ "./prompts": "./src/prompts/mcpPrompts.ts"
90
110
  },
91
111
  files: [
92
112
  "dist"
@@ -105,6 +125,16 @@ var package_default = {
105
125
  types: "./dist/index.d.cts",
106
126
  default: "./dist/index.cjs"
107
127
  }
128
+ },
129
+ "./prompts": {
130
+ import: {
131
+ types: "./dist/prompts/mcpPrompts.d.ts",
132
+ default: "./dist/prompts/mcpPrompts.js"
133
+ },
134
+ require: {
135
+ types: "./dist/prompts/mcpPrompts.d.cts",
136
+ default: "./dist/prompts/mcpPrompts.cjs"
137
+ }
108
138
  }
109
139
  }
110
140
  },
@@ -122,6 +152,7 @@ var package_default = {
122
152
  zod: "^3.23.0"
123
153
  },
124
154
  devDependencies: {
155
+ "@apicircle/git": "workspace:*",
125
156
  "@types/node": "^20.0.0",
126
157
  tsup: "^8.3.0",
127
158
  typescript: "^5.4.0",
@@ -590,6 +621,7 @@ var workspaceListTool = {
590
621
  var import_zod5 = require("zod");
591
622
  var import_shared2 = require("@apicircle/shared");
592
623
  var import_core2 = require("@apicircle/core");
624
+ var FULL_REQUEST_AUTH = import_zod5.z.object({ type: import_zod5.z.string() }).passthrough();
593
625
  var HTTP_METHOD = import_zod5.z.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]);
594
626
  var requestCreateTool = {
595
627
  name: "request.create",
@@ -677,16 +709,18 @@ var requestDeleteTool = {
677
709
  };
678
710
  var folderCreateTool = {
679
711
  name: "folder.create",
680
- description: "Create a folder under an optional parent folder.",
712
+ description: "Create a folder under an optional parent folder. Optionally seed folder-level `auth` so descendants with `auth: inherit` resolve to it immediately \u2014 saves a follow-up `folder.update` round-trip.",
681
713
  inputSchema: import_zod5.z.object({
682
714
  name: import_zod5.z.string().default("New folder"),
683
- parentId: import_zod5.z.string().nullable().optional()
715
+ parentId: import_zod5.z.string().nullable().optional(),
716
+ auth: FULL_REQUEST_AUTH.optional()
684
717
  }),
685
718
  async handler(input, ctx) {
686
719
  const folder = {
687
720
  id: (0, import_shared2.generateId)(),
688
721
  name: input.name,
689
- parentId: input.parentId ?? null
722
+ parentId: input.parentId ?? null,
723
+ ...input.auth ? { auth: input.auth } : {}
690
724
  };
691
725
  const out = await ctx.workspace.apply({ kind: "folder.create", folder });
692
726
  return { id: folder.id, changedIds: out.changedIds };
@@ -710,18 +744,39 @@ var folderReadTool = {
710
744
  };
711
745
  var folderUpdateTool = {
712
746
  name: "folder.update",
713
- description: "Move a folder to a new parent (or to root with parentId: null).",
747
+ description: 'Update a folder. Supply `parentId` to move it (use `null` for root), `name` to rename, and/or `auth` to set the folder-level auth that descendants inherit when their `auth.type === "inherit"`. Set `clearAuth: true` to remove the folder-level auth entirely (descendants then fall through to the next ancestor). The `auth` field accepts any of the 17 RequestAuth types \u2014 same surface as `request.update`. The simple ones (bearer / basic / api-key / custom-header / none / inherit) are easy to author by hand; OAuth2 / AWS SigV4 / Digest / NTLM / Hawk / JWT Bearer require the full canonical shape (see `RequestAuth` in `@apicircle/shared/types.ts`).',
714
748
  inputSchema: import_zod5.z.object({
715
749
  id: import_zod5.z.string(),
716
- parentId: import_zod5.z.string().nullable()
750
+ parentId: import_zod5.z.string().nullable().optional(),
751
+ name: import_zod5.z.string().optional(),
752
+ auth: FULL_REQUEST_AUTH.optional(),
753
+ clearAuth: import_zod5.z.boolean().optional()
754
+ }).refine((v) => !(v.auth !== void 0 && v.clearAuth === true), {
755
+ message: "Pass either `auth` or `clearAuth: true`, not both."
717
756
  }),
718
757
  async handler(input, ctx) {
719
- const out = await ctx.workspace.apply({
720
- kind: "folder.move",
721
- id: input.id,
722
- newParentId: input.parentId
723
- });
724
- return { changedIds: out.changedIds };
758
+ const changedIds = [];
759
+ if (input.parentId !== void 0) {
760
+ const out = await ctx.workspace.apply({
761
+ kind: "folder.move",
762
+ id: input.id,
763
+ newParentId: input.parentId
764
+ });
765
+ changedIds.push(...out.changedIds);
766
+ }
767
+ const updatePatch = {};
768
+ if (input.name !== void 0) updatePatch.name = input.name;
769
+ if (input.auth !== void 0) updatePatch.auth = input.auth;
770
+ else if (input.clearAuth === true) updatePatch.auth = void 0;
771
+ if (input.name !== void 0 || input.auth !== void 0 || input.clearAuth === true) {
772
+ const out = await ctx.workspace.apply({
773
+ kind: "folder.update",
774
+ id: input.id,
775
+ patch: updatePatch
776
+ });
777
+ changedIds.push(...out.changedIds);
778
+ }
779
+ return { changedIds: Array.from(new Set(changedIds)) };
725
780
  }
726
781
  };
727
782
  var folderDeleteTool = {
@@ -1695,7 +1750,9 @@ var CONDITION_CLAUSE_NL = import_zod9.z.object({
1695
1750
  var RESPONSE_RULE_NL = import_zod9.z.object({
1696
1751
  name: import_zod9.z.string(),
1697
1752
  enabled: import_zod9.z.boolean().default(true),
1698
- when: import_zod9.z.array(CONDITION_CLAUSE_NL).default([]),
1753
+ // At least one clause — a no-condition rule is dead (the runtime engine skips
1754
+ // clause-less rules), so it never fires (mirrors the VS Code parser's reject).
1755
+ when: import_zod9.z.array(CONDITION_CLAUSE_NL).min(1),
1699
1756
  response: import_zod9.z.object({
1700
1757
  status: import_zod9.z.number().int().min(100).max(599).default(200),
1701
1758
  jsonBody: import_zod9.z.string().default("{}")
@@ -1745,7 +1802,7 @@ function buildEndpoint(input) {
1745
1802
  };
1746
1803
  const validationRules = input.validationRules ?? [];
1747
1804
  const responseRules = input.responseRules ?? [];
1748
- const multipliers = input.multipliers ?? [];
1805
+ const multipliers = (input.multipliers ?? []).slice(0, import_shared3.MAX_RESPONSE_MULTIPLIERS);
1749
1806
  if (multipliers.length > 0) {
1750
1807
  defaultResponse.multipliers = multipliers.map((m) => ({
1751
1808
  id: (0, import_shared3.generateId)(),
@@ -1978,10 +2035,13 @@ var promptSetPlanVariablesTool = {
1978
2035
  };
1979
2036
  var promptCreateMockServerTool = {
1980
2037
  name: "prompt.create_mock_server",
1981
- description: "Create a manual-mode mock server with optional inline endpoints from an LLM-shaped JSON envelope. The model produces `{ name, defaultPort?, endpoints: [{ method, pathPattern, name?, response?, validationRules?, responseRules?, multipliers? }] }`; this tool generates ids for the server and every endpoint / rule, then persists in one shot.",
2038
+ description: "Create a manual-mode mock server with optional inline endpoints from an LLM-shaped JSON envelope. The model produces `{ name, defaultPort?, endpoints: [{ method, pathPattern, name?, response?, validationRules?, responseRules?, multiplier? }] }`; this tool generates ids for the server and every endpoint / rule, then persists in one shot.",
1982
2039
  inputSchema: import_zod9.z.object({
1983
2040
  name: import_zod9.z.string().min(1),
1984
- defaultPort: import_zod9.z.number().int().positive().nullable().optional(),
2041
+ // Mirrors mock.create_manual / mock.start / mock.set_default_port:
2042
+ // reject out-of-range ports at the tool boundary so a prompt that
2043
+ // returns a stray port (1, 80, 999999) never leaks into the synced doc.
2044
+ defaultPort: import_zod9.z.number().int().min(1024).max(65535).nullable().optional(),
1985
2045
  endpoints: import_zod9.z.array(ENDPOINT_INPUT).default([])
1986
2046
  }),
1987
2047
  async handler(input, ctx) {
@@ -2008,7 +2068,7 @@ var promptCreateMockServerTool = {
2008
2068
  };
2009
2069
  var promptAddMockEndpointTool = {
2010
2070
  name: "prompt.add_mock_endpoint",
2011
- description: "Append a new endpoint (with optional inline validation rules, response rules, and multipliers) to an existing mock server from an LLM-shaped JSON envelope. All ids are auto-generated; the existing endpoints stay in place.",
2071
+ description: "Append a new endpoint (with optional inline validation rules, response rules, and a single response multiplier) to an existing mock server from an LLM-shaped JSON envelope. All ids are auto-generated; the existing endpoints stay in place.",
2012
2072
  inputSchema: import_zod9.z.object({
2013
2073
  mockId: import_zod9.z.string(),
2014
2074
  method: HTTP_METHOD2,
@@ -2111,7 +2171,7 @@ var promptSetEndpointResponseRulesTool = {
2111
2171
  };
2112
2172
  var promptSetEndpointMultipliersTool = {
2113
2173
  name: "prompt.set_endpoint_multipliers",
2114
- description: "Replace the response multipliers on an endpoint's defaultResponse with an LLM-shaped list. Multipliers expand an array at `targetJsonPath` to a count derived from a request value. Every multiplier gets a fresh id. Empty array clears all multipliers.",
2174
+ description: "Replace the response multipliers on an endpoint's defaultResponse with an LLM-shaped list. Multipliers expand an array at `targetJsonPath` to a count derived from a request value. Every multiplier gets a fresh id. Empty array clears all. Capped at MAX_RESPONSE_MULTIPLIERS (1) \u2014 extra entries are rejected.",
2115
2175
  inputSchema: import_zod9.z.object({
2116
2176
  mockId: import_zod9.z.string(),
2117
2177
  endpointId: import_zod9.z.string(),
@@ -2122,6 +2182,9 @@ var promptSetEndpointMultipliersTool = {
2122
2182
  const mock = state.synced.mockServers[input.mockId];
2123
2183
  if (!mock) return { ok: false, error: "mock not found" };
2124
2184
  const multipliers = input.multipliers;
2185
+ if (multipliers.length > import_shared3.MAX_RESPONSE_MULTIPLIERS) {
2186
+ return { ok: false, error: "too many multipliers" };
2187
+ }
2125
2188
  const next = patchEndpoint(mock, input.endpointId, (e) => ({
2126
2189
  ...e,
2127
2190
  defaultResponse: {
@@ -2142,6 +2205,53 @@ var promptSetEndpointMultipliersTool = {
2142
2205
  return { ok: true, changedIds: out.changedIds };
2143
2206
  }
2144
2207
  };
2208
+ var PARAM_NL = import_zod9.z.object({
2209
+ name: import_zod9.z.string(),
2210
+ typeHint: import_zod9.z.string().optional(),
2211
+ required: import_zod9.z.boolean().optional(),
2212
+ description: import_zod9.z.string().optional(),
2213
+ example: import_zod9.z.string().optional()
2214
+ });
2215
+ var promptSetEndpointRequestSchemaTool = {
2216
+ name: "prompt.set_endpoint_request_schema",
2217
+ description: "Declare an endpoint's expected inputs with an LLM-shaped list: path / query / header / cookie params (name + optional typeHint / required / description / example) plus an optional body-shape doc. Every param gets a fresh id; omitted lists are cleared. Documentation-only \u2014 it drives the editor UI + OpenAPI export, not runtime gating (use validation rules for that).",
2218
+ inputSchema: import_zod9.z.object({
2219
+ mockId: import_zod9.z.string(),
2220
+ endpointId: import_zod9.z.string(),
2221
+ pathParams: import_zod9.z.array(PARAM_NL).default([]),
2222
+ queryParams: import_zod9.z.array(PARAM_NL).default([]),
2223
+ headers: import_zod9.z.array(PARAM_NL).default([]),
2224
+ cookies: import_zod9.z.array(PARAM_NL).default([]),
2225
+ body: import_zod9.z.object({ description: import_zod9.z.string().optional(), example: import_zod9.z.string().optional() }).optional()
2226
+ }),
2227
+ async handler(input, ctx) {
2228
+ const state = await ctx.workspace.read();
2229
+ const mock = state.synced.mockServers[input.mockId];
2230
+ if (!mock) return { ok: false, error: "mock not found" };
2231
+ const toParams = (list) => list.map((p) => ({
2232
+ id: (0, import_shared3.generateId)(),
2233
+ name: p.name,
2234
+ typeHint: p.typeHint,
2235
+ required: p.required,
2236
+ description: p.description,
2237
+ example: p.example
2238
+ }));
2239
+ const body = input.body && (input.body.description || input.body.example) ? input.body : void 0;
2240
+ const next = patchEndpoint(mock, input.endpointId, (e) => ({
2241
+ ...e,
2242
+ requestSchema: {
2243
+ pathParams: toParams(input.pathParams),
2244
+ queryParams: toParams(input.queryParams),
2245
+ headers: toParams(input.headers),
2246
+ cookies: toParams(input.cookies),
2247
+ body
2248
+ }
2249
+ }));
2250
+ if (!next) return { ok: false, error: "endpoint not found" };
2251
+ const out = await ctx.workspace.apply({ kind: "mock.upsert", mock: next });
2252
+ return { ok: true, changedIds: out.changedIds };
2253
+ }
2254
+ };
2145
2255
 
2146
2256
  // src/tools/globalAssets.ts
2147
2257
  var import_zod10 = require("zod");
@@ -2392,10 +2502,14 @@ var mockListTool = {
2392
2502
  };
2393
2503
  var mockStartTool = {
2394
2504
  name: "mock.start",
2395
- description: "Start a mock server by id. Returns the bound port. Errors if the mock is already running or the requested port is in use.",
2505
+ description: "Start a mock server by id. Returns the bound port. Errors if the mock is already running or the requested port is in use. Optional `port` (1024-65535) overrides the saved `defaultPort` for this run only \u2014 to persist a new default port, use `mock.set_default_port`.",
2396
2506
  inputSchema: import_zod11.z.object({
2397
2507
  id: import_zod11.z.string(),
2398
- port: import_zod11.z.number().int().positive().optional()
2508
+ // Mirrors the UI 1024-65535 window: <1024 needs OS privileges, and
2509
+ // outside that window the runtime would throw INVALID_PORT anyway —
2510
+ // surface the rejection at the tool boundary so the client sees a
2511
+ // schema error instead of a runtime exception.
2512
+ port: import_zod11.z.number().int().min(1024).max(65535).optional()
2399
2513
  }),
2400
2514
  async handler(input, ctx) {
2401
2515
  const state = await ctx.workspace.read();
@@ -2435,13 +2549,44 @@ var mockDeleteTool = {
2435
2549
  return { ok: true, changedIds: out.changedIds };
2436
2550
  }
2437
2551
  };
2552
+ var mockSetDefaultPortTool = {
2553
+ name: "mock.set_default_port",
2554
+ description: "Persist a new `defaultPort` on an existing mock server. Pass an integer 1024-65535 to pin the port, or `null` for 'pick a free port at next start'. Does NOT restart a running mock \u2014 the change takes effect on the next mock.start.",
2555
+ inputSchema: import_zod11.z.object({
2556
+ id: import_zod11.z.string(),
2557
+ defaultPort: import_zod11.z.number().int().min(1024).max(65535).nullable()
2558
+ }),
2559
+ async handler(input, ctx) {
2560
+ const state = await ctx.workspace.read();
2561
+ const mock = state.synced.mockServers[input.id];
2562
+ if (!mock) return { ok: false, error: "mock not found" };
2563
+ if (mock.defaultPort === input.defaultPort) {
2564
+ return { ok: true, defaultPort: mock.defaultPort, changed: false };
2565
+ }
2566
+ const next = {
2567
+ ...mock,
2568
+ defaultPort: input.defaultPort,
2569
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2570
+ };
2571
+ const out = await ctx.workspace.apply({ kind: "mock.upsert", mock: next });
2572
+ return {
2573
+ ok: true,
2574
+ defaultPort: input.defaultPort,
2575
+ changed: true,
2576
+ changedIds: out.changedIds
2577
+ };
2578
+ }
2579
+ };
2438
2580
  var HTTP_METHOD3 = import_zod11.z.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]);
2439
2581
  var mockCreateManualTool = {
2440
2582
  name: "mock.create_manual",
2441
2583
  description: "Create an empty manual-mode mock server. Use `mock.add_endpoint` afterward to populate it. CORS defaults to off (same-origin only); enable + list explicit origins via `mock.update_cors` if cross-origin access is needed.",
2442
2584
  inputSchema: import_zod11.z.object({
2443
2585
  name: import_zod11.z.string().min(1),
2444
- defaultPort: import_zod11.z.number().int().positive().nullable().optional()
2586
+ // Same 1024-65535 window as mock.start / mock.set_default_port — pin
2587
+ // the validation at the tool boundary so a malformed AI call doesn't
2588
+ // persist a port the runtime will later reject as INVALID_PORT.
2589
+ defaultPort: import_zod11.z.number().int().min(1024).max(65535).nullable().optional()
2445
2590
  }),
2446
2591
  async handler(input, ctx) {
2447
2592
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -2643,7 +2788,10 @@ var RESPONSE_RULE = import_zod11.z.object({
2643
2788
  id: import_zod11.z.string().optional(),
2644
2789
  name: import_zod11.z.string(),
2645
2790
  enabled: import_zod11.z.boolean().default(true),
2646
- when: import_zod11.z.array(CONDITION_CLAUSE).default([]),
2791
+ // At least one clause — a rule with no `when` is dead (the runtime engine
2792
+ // skips clause-less rules), so it can never fire. The VS Code endpoint parser
2793
+ // rejects the same shape, keeping the surfaces consistent.
2794
+ when: import_zod11.z.array(CONDITION_CLAUSE).min(1),
2647
2795
  response: import_zod11.z.object({
2648
2796
  status: import_zod11.z.number().int().min(100).max(599).default(200),
2649
2797
  jsonBody: import_zod11.z.string().default("{}")
@@ -2745,9 +2893,61 @@ var mockSetResponseRulesTool = {
2745
2893
  return { ok: true, changedIds: out.changedIds };
2746
2894
  }
2747
2895
  };
2896
+ var PARAM = import_zod11.z.object({
2897
+ id: import_zod11.z.string().optional(),
2898
+ name: import_zod11.z.string(),
2899
+ typeHint: import_zod11.z.string().optional(),
2900
+ required: import_zod11.z.boolean().optional(),
2901
+ description: import_zod11.z.string().optional(),
2902
+ example: import_zod11.z.string().optional()
2903
+ });
2904
+ var REQUEST_SCHEMA_BODY = import_zod11.z.object({ description: import_zod11.z.string().optional(), example: import_zod11.z.string().optional() }).optional();
2905
+ function normalizeSchemaBody(body) {
2906
+ if (!body || !body.description && !body.example) return void 0;
2907
+ return body;
2908
+ }
2909
+ var mockSetRequestSchemaTool = {
2910
+ name: "mock.set_request_schema",
2911
+ description: "Replace an endpoint's requestSchema \u2014 the declared path / query / header / cookie params plus an optional body-shape doc. Params without an `id` get a fresh one; omitted lists are cleared. Documentation-only (drives the editor UI + OpenAPI export); runtime gating lives in the validation rules.",
2912
+ inputSchema: import_zod11.z.object({
2913
+ mockId: import_zod11.z.string(),
2914
+ endpointId: import_zod11.z.string(),
2915
+ pathParams: import_zod11.z.array(PARAM).default([]),
2916
+ queryParams: import_zod11.z.array(PARAM).default([]),
2917
+ headers: import_zod11.z.array(PARAM).default([]),
2918
+ cookies: import_zod11.z.array(PARAM).default([]),
2919
+ body: REQUEST_SCHEMA_BODY
2920
+ }),
2921
+ async handler(input, ctx) {
2922
+ const state = await ctx.workspace.read();
2923
+ const mock = state.synced.mockServers[input.mockId];
2924
+ if (!mock) return { ok: false, error: "mock not found" };
2925
+ const toParams = (list) => list.map((p) => ({
2926
+ id: p.id ?? (0, import_shared5.generateId)(),
2927
+ name: p.name,
2928
+ typeHint: p.typeHint,
2929
+ required: p.required,
2930
+ description: p.description,
2931
+ example: p.example
2932
+ }));
2933
+ const next = patchEndpoint2(mock, input.endpointId, (e) => ({
2934
+ ...e,
2935
+ requestSchema: {
2936
+ pathParams: toParams(input.pathParams),
2937
+ queryParams: toParams(input.queryParams),
2938
+ headers: toParams(input.headers),
2939
+ cookies: toParams(input.cookies),
2940
+ body: normalizeSchemaBody(input.body)
2941
+ }
2942
+ }));
2943
+ if (!next) return { ok: false, error: "endpoint not found" };
2944
+ const out = await ctx.workspace.apply({ kind: "mock.upsert", mock: next });
2945
+ return { ok: true, changedIds: out.changedIds };
2946
+ }
2947
+ };
2748
2948
  var mockSetMultipliersTool = {
2749
2949
  name: "mock.set_multipliers",
2750
- description: "Replace the response multipliers on an endpoint's defaultResponse. Multipliers expand an array at `targetJsonPath` to a count derived from a request value. Empty array clears all multipliers.",
2950
+ description: "Replace the response multipliers on an endpoint's defaultResponse. Multipliers expand an array at `targetJsonPath` to a count derived from a request value. Empty array clears all. The list is currently capped at MAX_RESPONSE_MULTIPLIERS (1) \u2014 passing more is rejected.",
2751
2951
  inputSchema: import_zod11.z.object({
2752
2952
  mockId: import_zod11.z.string(),
2753
2953
  endpointId: import_zod11.z.string(),
@@ -2758,6 +2958,9 @@ var mockSetMultipliersTool = {
2758
2958
  const mock = state.synced.mockServers[input.mockId];
2759
2959
  if (!mock) return { ok: false, error: "mock not found" };
2760
2960
  const multipliers = input.multipliers;
2961
+ if (multipliers.length > import_shared5.MAX_RESPONSE_MULTIPLIERS) {
2962
+ return { ok: false, error: "too many multipliers" };
2963
+ }
2761
2964
  const next = patchEndpoint2(mock, input.endpointId, (e) => ({
2762
2965
  ...e,
2763
2966
  defaultResponse: {
@@ -2779,6 +2982,1290 @@ var mockSetMultipliersTool = {
2779
2982
  }
2780
2983
  };
2781
2984
 
2985
+ // src/tools/releases.ts
2986
+ var import_zod12 = require("zod");
2987
+ var import_core4 = require("@apicircle/core");
2988
+ var releaseListTool = {
2989
+ name: "release.list",
2990
+ description: "List this workspace's published releases (newest first) with their notes, snapshot fingerprint, and deprecated / withdrawn flags. Returns the current version too.",
2991
+ inputSchema: import_zod12.z.object({}),
2992
+ async handler(_input, ctx) {
2993
+ const state = await ctx.workspace.read();
2994
+ const ledger = state.synced.releases.self;
2995
+ if (!ledger) {
2996
+ return { currentVersion: null, count: 0, versions: [] };
2997
+ }
2998
+ const order = (0, import_core4.sortVersionsDesc)(ledger.versions.map((v) => v.version));
2999
+ const byVersion = new Map(ledger.versions.map((v) => [v.version, v]));
3000
+ const versions = order.map((v) => byVersion.get(v)).filter((v) => v !== void 0).map((v) => ({
3001
+ version: v.version,
3002
+ publishedAt: v.publishedAt,
3003
+ notes: v.notes,
3004
+ workspaceSnapshot: v.workspaceSnapshot,
3005
+ deprecated: v.deprecated,
3006
+ yanked: v.yanked,
3007
+ ...v.sha ? { sha: v.sha } : {},
3008
+ ...v.tagName ? { tagName: v.tagName } : {}
3009
+ }));
3010
+ return { currentVersion: ledger.currentVersion, count: versions.length, versions };
3011
+ }
3012
+ };
3013
+ var releasePublishTool = {
3014
+ name: "release.publish",
3015
+ description: "Publish a new release of this workspace. Appends a semver version + markdown notes to the ledger and bumps currentVersion. The release is fingerprinted with a SHA-256 of the workspace at publish time. Rejects invalid semver or a duplicate version. Does NOT create a Git tag or GitHub Release.",
3016
+ inputSchema: import_zod12.z.object({
3017
+ version: import_zod12.z.string().min(1).describe('Semantic version, e.g. "1.2.0".'),
3018
+ notes: import_zod12.z.string().default("").describe("Markdown release notes."),
3019
+ sha: import_zod12.z.string().optional().describe("Optional source commit SHA for bookkeeping."),
3020
+ tagName: import_zod12.z.string().optional().describe("Optional git tag name for bookkeeping.")
3021
+ }),
3022
+ async handler(input, ctx) {
3023
+ const state = await ctx.workspace.read();
3024
+ let entry;
3025
+ try {
3026
+ entry = await (0, import_core4.buildReleaseEntry)(state.synced, {
3027
+ version: input.version,
3028
+ notes: input.notes,
3029
+ sha: input.sha,
3030
+ tagName: input.tagName
3031
+ });
3032
+ } catch (err) {
3033
+ return { ok: false, error: err instanceof Error ? err.message : "release.publish failed" };
3034
+ }
3035
+ try {
3036
+ const out = await ctx.workspace.apply({ kind: "release.publish", entry });
3037
+ const after = (await ctx.workspace.read()).synced.releases.self;
3038
+ return {
3039
+ ok: true,
3040
+ version: entry.version,
3041
+ currentVersion: after?.currentVersion ?? entry.version,
3042
+ workspaceSnapshot: entry.workspaceSnapshot,
3043
+ changedIds: out.changedIds
3044
+ };
3045
+ } catch (err) {
3046
+ return { ok: false, error: err instanceof Error ? err.message : "release.publish failed" };
3047
+ }
3048
+ }
3049
+ };
3050
+ var releaseDeprecateTool = {
3051
+ name: "release.deprecate",
3052
+ description: "Mark a published version as deprecated (soft signal). Consumers see a warning but the version stays installable. Errors if the version is unknown or no ledger exists.",
3053
+ inputSchema: import_zod12.z.object({ version: import_zod12.z.string().min(1) }),
3054
+ async handler(input, ctx) {
3055
+ try {
3056
+ const out = await ctx.workspace.apply({ kind: "release.deprecate", version: input.version });
3057
+ return { ok: true, version: input.version, changedIds: out.changedIds };
3058
+ } catch (err) {
3059
+ return { ok: false, error: err instanceof Error ? err.message : "release.deprecate failed" };
3060
+ }
3061
+ }
3062
+ };
3063
+ var releaseYankTool = {
3064
+ name: "release.yank",
3065
+ description: "Withdraw (yank) a published version (hard signal). Consumers are warned the version is broken / unsafe and told to move to a different one. The entry stays in the ledger. Errors if the version is unknown or no ledger exists.",
3066
+ inputSchema: import_zod12.z.object({ version: import_zod12.z.string().min(1) }),
3067
+ async handler(input, ctx) {
3068
+ try {
3069
+ const out = await ctx.workspace.apply({ kind: "release.yank", version: input.version });
3070
+ return { ok: true, version: input.version, changedIds: out.changedIds };
3071
+ } catch (err) {
3072
+ return { ok: false, error: err instanceof Error ? err.message : "release.yank failed" };
3073
+ }
3074
+ }
3075
+ };
3076
+
3077
+ // src/tools/linkedWorkspaces.ts
3078
+ var import_zod13 = require("zod");
3079
+ function summarize(link, currentVersion) {
3080
+ return {
3081
+ id: link.id,
3082
+ name: link.name,
3083
+ kind: link.kind,
3084
+ description: link.description,
3085
+ source: link.source,
3086
+ scope: link.scope,
3087
+ pinnedVersion: link.pinnedVersion,
3088
+ requiredSecretKeyIds: link.requiredSecretKeyIds,
3089
+ marketplace: link.marketplace,
3090
+ cachedCurrentVersion: currentVersion
3091
+ };
3092
+ }
3093
+ var linkedListTool = {
3094
+ name: "linked.list",
3095
+ description: "List the workspaces this workspace links to (consumes). Each entry includes its source repo/branch, scope, pinned version, required secret-key ids, and the current version of its cached release ledger.",
3096
+ inputSchema: import_zod13.z.object({}),
3097
+ async handler(_input, ctx) {
3098
+ const state = await ctx.workspace.read();
3099
+ const links = Object.values(state.synced.linkedWorkspaces);
3100
+ return {
3101
+ count: links.length,
3102
+ links: links.map(
3103
+ (l) => summarize(l, state.synced.releases.perLink[l.id]?.currentVersion ?? null)
3104
+ )
3105
+ };
3106
+ }
3107
+ };
3108
+ var linkedGetTool = {
3109
+ name: "linked.get",
3110
+ description: "Read one linked workspace by id, including its cached release ledger (the versions available to pin to).",
3111
+ inputSchema: import_zod13.z.object({ id: import_zod13.z.string() }),
3112
+ async handler(input, ctx) {
3113
+ const state = await ctx.workspace.read();
3114
+ const link = state.synced.linkedWorkspaces[input.id];
3115
+ if (!link) return { ok: false, error: `Linked workspace ${input.id} not found` };
3116
+ const ledger = state.synced.releases.perLink[input.id] ?? null;
3117
+ return {
3118
+ ok: true,
3119
+ link: summarize(link, ledger?.currentVersion ?? null),
3120
+ ledger
3121
+ };
3122
+ }
3123
+ };
3124
+ var linkedSetConfigTool = {
3125
+ name: "linked.set_config",
3126
+ description: "Update an existing linked workspace's config: rename, pin/unpin a version (must exist in the cached ledger), set scope, session mode, required secret-key ids, or marketplace metadata. Only supplied fields change. Does NOT fetch from the network.",
3127
+ inputSchema: import_zod13.z.object({
3128
+ id: import_zod13.z.string(),
3129
+ name: import_zod13.z.string().optional(),
3130
+ description: import_zod13.z.string().optional(),
3131
+ pinnedVersion: import_zod13.z.string().nullable().optional().describe("null = unpin (track source HEAD)."),
3132
+ scope: import_zod13.z.array(import_zod13.z.enum(["collections", "environments"])).optional(),
3133
+ sessionMode: import_zod13.z.enum(["workspace", "dedicated"]).optional(),
3134
+ requiredSecretKeyIds: import_zod13.z.array(import_zod13.z.string()).optional(),
3135
+ marketplace: import_zod13.z.object({
3136
+ listedAs: import_zod13.z.string(),
3137
+ tags: import_zod13.z.array(import_zod13.z.string()),
3138
+ summary: import_zod13.z.string()
3139
+ }).nullable().optional().describe("null = clear marketplace metadata.")
3140
+ }),
3141
+ async handler(input, ctx) {
3142
+ const state = await ctx.workspace.read();
3143
+ const link = state.synced.linkedWorkspaces[input.id];
3144
+ if (!link) return { ok: false, error: `Linked workspace ${input.id} not found` };
3145
+ if (input.pinnedVersion !== void 0 && input.pinnedVersion !== null) {
3146
+ const cached = state.synced.releases.perLink[input.id]?.versions ?? [];
3147
+ if (!cached.some((v) => v.version === input.pinnedVersion)) {
3148
+ return {
3149
+ ok: false,
3150
+ error: `Version ${input.pinnedVersion} is not in the cached ledger \u2014 refresh the link first`
3151
+ };
3152
+ }
3153
+ }
3154
+ const next = {
3155
+ ...link,
3156
+ ...input.name !== void 0 ? { name: input.name } : {},
3157
+ ...input.description !== void 0 ? { description: input.description } : {},
3158
+ ...input.pinnedVersion !== void 0 ? { pinnedVersion: input.pinnedVersion } : {},
3159
+ ...input.scope !== void 0 ? { scope: input.scope } : {},
3160
+ ...input.requiredSecretKeyIds !== void 0 ? { requiredSecretKeyIds: input.requiredSecretKeyIds } : {},
3161
+ ...input.sessionMode !== void 0 ? { source: { ...link.source, sessionMode: input.sessionMode } } : {}
3162
+ };
3163
+ if (input.marketplace !== void 0) {
3164
+ if (input.marketplace === null) {
3165
+ delete next.marketplace;
3166
+ } else {
3167
+ next.marketplace = input.marketplace;
3168
+ }
3169
+ }
3170
+ const out = await ctx.workspace.apply({ kind: "linkedWorkspace.upsert", link: next });
3171
+ return {
3172
+ ok: true,
3173
+ changedIds: out.changedIds,
3174
+ link: summarize(next, state.synced.releases.perLink[input.id]?.currentVersion ?? null)
3175
+ };
3176
+ }
3177
+ };
3178
+ var linkedUnlinkTool = {
3179
+ name: "linked.unlink",
3180
+ description: "Unlink a workspace by id. Removes the link, its cached release ledger, every local override for it, the cached collections/environments snapshot, and any per-link session entry. The source repo is untouched.",
3181
+ inputSchema: import_zod13.z.object({ id: import_zod13.z.string() }),
3182
+ async handler(input, ctx) {
3183
+ const state = await ctx.workspace.read();
3184
+ if (!state.synced.linkedWorkspaces[input.id]) {
3185
+ return { ok: false, error: `Linked workspace ${input.id} not found` };
3186
+ }
3187
+ const out = await ctx.workspace.apply({ kind: "linkedWorkspace.remove", id: input.id });
3188
+ return { ok: true, changedIds: out.changedIds };
3189
+ }
3190
+ };
3191
+
3192
+ // src/tools/githubOps.ts
3193
+ var import_zod14 = require("zod");
3194
+ var import_core5 = require("@apicircle/core");
3195
+ var import_shared6 = require("@apicircle/shared");
3196
+
3197
+ // ../git/src/github/errors.ts
3198
+ var GitHubError = class extends Error {
3199
+ constructor(message, status, body) {
3200
+ super(message);
3201
+ this.status = status;
3202
+ this.body = body;
3203
+ this.name = "GitHubError";
3204
+ }
3205
+ status;
3206
+ body;
3207
+ };
3208
+ var MissingScopeError = class extends GitHubError {
3209
+ /** Scope strings the API said are missing, e.g. ['pull_request']. */
3210
+ missingScopes;
3211
+ /** Scope strings the token currently grants, parsed from x-oauth-scopes. */
3212
+ grantedScopes;
3213
+ constructor(message, status, missingScopes, grantedScopes) {
3214
+ super(message, status);
3215
+ this.name = "MissingScopeError";
3216
+ this.missingScopes = missingScopes;
3217
+ this.grantedScopes = grantedScopes;
3218
+ }
3219
+ };
3220
+ var RateLimitedError = class extends GitHubError {
3221
+ /** Unix timestamp (ms) when the rate-limit window resets. */
3222
+ resetAtMs;
3223
+ constructor(message, status, resetAtMs) {
3224
+ super(message, status);
3225
+ this.name = "RateLimitedError";
3226
+ this.resetAtMs = resetAtMs;
3227
+ }
3228
+ };
3229
+ var UnauthorizedError = class extends GitHubError {
3230
+ constructor(message, status) {
3231
+ super(message, status);
3232
+ this.name = "UnauthorizedError";
3233
+ }
3234
+ };
3235
+ var TimeoutError = class extends GitHubError {
3236
+ /** Timeout that fired, in ms. Useful for the UI message. */
3237
+ timeoutMs;
3238
+ constructor(message, timeoutMs) {
3239
+ super(message, 0);
3240
+ this.name = "TimeoutError";
3241
+ this.timeoutMs = timeoutMs;
3242
+ }
3243
+ };
3244
+
3245
+ // ../git/src/github/api.ts
3246
+ var API_BASE = "https://api.github.com";
3247
+ var LOGIN_BASE = "https://github.com";
3248
+ var DEFAULT_TIMEOUT_MS = 15e3;
3249
+ var GitHubClient = class {
3250
+ baseUrl;
3251
+ loginBaseUrl;
3252
+ fetchImpl;
3253
+ timeoutMs;
3254
+ constructor(opts = {}) {
3255
+ this.baseUrl = opts.baseUrl ?? API_BASE;
3256
+ this.loginBaseUrl = (opts.loginBaseUrl ?? LOGIN_BASE).replace(/\/$/, "");
3257
+ this.fetchImpl = opts.fetchImpl ?? globalThis.fetch.bind(globalThis);
3258
+ this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
3259
+ }
3260
+ /**
3261
+ * Fetch the authenticated user. Doubles as a "verify token" probe — used
3262
+ * by the Secret Vault Sessions tab to refresh the granted-scopes list.
3263
+ */
3264
+ async getViewer(token, opts = {}) {
3265
+ const { json, response } = await this.call(token, "/user", opts);
3266
+ return {
3267
+ viewer: {
3268
+ login: json.login,
3269
+ id: json.id,
3270
+ name: json.name ?? null,
3271
+ avatarUrl: json.avatar_url ?? null
3272
+ },
3273
+ scopes: parseScopes(response.headers)
3274
+ };
3275
+ }
3276
+ /**
3277
+ * List repositories the authenticated user can access. Used by the repo
3278
+ * picker. Capped at 100 sorted by recent push; users with thousands of
3279
+ * repos can paginate later.
3280
+ */
3281
+ async listAccessibleRepos(token, opts = {}) {
3282
+ const { json } = await this.call(
3283
+ token,
3284
+ "/user/repos?per_page=100&sort=pushed&affiliation=owner,collaborator,organization_member",
3285
+ opts
3286
+ );
3287
+ return json.map(normalizeRepo);
3288
+ }
3289
+ /**
3290
+ * Fetch a specific repo. Validates the user-supplied owner/name pair
3291
+ * exists + is accessible, and exposes the default branch.
3292
+ */
3293
+ async getRepo(token, owner, name, opts = {}) {
3294
+ const { json } = await this.call(
3295
+ token,
3296
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`,
3297
+ opts
3298
+ );
3299
+ return normalizeRepo(json);
3300
+ }
3301
+ /**
3302
+ * Read the head SHA of a branch. Used to seed a new working branch from
3303
+ * main before any edits land.
3304
+ */
3305
+ async getBranchHead(token, owner, name, branch, opts = {}) {
3306
+ const { json } = await this.call(
3307
+ token,
3308
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/branches/${encodeURIComponent(branch)}`,
3309
+ opts
3310
+ );
3311
+ return { name: json.name, commitSha: json.commit.sha };
3312
+ }
3313
+ /**
3314
+ * List branches on a repo. Used by the Link Workspace repo-browser to
3315
+ * populate the branch dropdown after the user picks a repo. Capped at
3316
+ * 100 (GitHub's max page size); repos with more branches paginate.
3317
+ */
3318
+ async listBranches(token, owner, name, opts = {}) {
3319
+ const { json } = await this.call(
3320
+ token,
3321
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/branches?per_page=100`,
3322
+ opts
3323
+ );
3324
+ return json.map((b) => ({ name: b.name, commitSha: b.commit.sha }));
3325
+ }
3326
+ /**
3327
+ * Create a new branch ref pointing at `sha`. The auto-branch flow calls
3328
+ * this with the head SHA from `getBranchHead(main)`.
3329
+ *
3330
+ * GitHub returns 422 with "Reference already exists" when the branch
3331
+ * already exists; that surfaces as a GitHubError(422) so the UI can
3332
+ * prompt for a different name.
3333
+ */
3334
+ async createBranch(token, owner, name, branchName, sha, opts = {}) {
3335
+ const { json } = await this.call(
3336
+ token,
3337
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/git/refs`,
3338
+ {
3339
+ ...opts,
3340
+ method: "POST",
3341
+ body: { ref: `refs/heads/${branchName}`, sha },
3342
+ requiredScopes: ["repo"]
3343
+ }
3344
+ );
3345
+ return { name: branchName, commitSha: json.object.sha };
3346
+ }
3347
+ /**
3348
+ * Read a branch ref's current commit SHA. Used at the start of push-to-
3349
+ * save to find the parent commit before building the new tree.
3350
+ */
3351
+ async getRef(token, owner, name, branch, opts = {}) {
3352
+ const { json } = await this.call(
3353
+ token,
3354
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/git/refs/heads/${encodeURIComponent(branch)}`,
3355
+ opts
3356
+ );
3357
+ return { ref: json.ref, sha: json.object.sha };
3358
+ }
3359
+ /**
3360
+ * Read a commit's tree SHA. Used so the new tree can be built `base_tree`
3361
+ * — every path we don't override is inherited from the parent.
3362
+ */
3363
+ async getCommit(token, owner, name, sha, opts = {}) {
3364
+ const { json } = await this.call(
3365
+ token,
3366
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/git/commits/${encodeURIComponent(sha)}`,
3367
+ opts
3368
+ );
3369
+ return {
3370
+ sha: json.sha,
3371
+ treeSha: json.tree.sha,
3372
+ message: json.message
3373
+ };
3374
+ }
3375
+ /**
3376
+ * Upload a blob to the repo and return its SHA. Used by push-to-save
3377
+ * (P4.3b) for binary attachments — text files go straight into a tree
3378
+ * entry's `content`, but binary bytes have to go through a blob first.
3379
+ *
3380
+ * `content` is base64 when `encoding === 'base64'`. GitHub stores blobs
3381
+ * deduplicated by their git-sha1 (not our sha256), so re-uploading the
3382
+ * same bytes is cheap on their side; we save a roundtrip locally by
3383
+ * tracking lastPushedBlobSha per slot in a future revision.
3384
+ */
3385
+ async createBlob(token, owner, name, args, opts = {}) {
3386
+ const { json } = await this.call(
3387
+ token,
3388
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/git/blobs`,
3389
+ {
3390
+ ...opts,
3391
+ method: "POST",
3392
+ body: { content: args.content, encoding: args.encoding },
3393
+ requiredScopes: ["repo"]
3394
+ }
3395
+ );
3396
+ return { sha: json.sha, size: json.size ?? 0 };
3397
+ }
3398
+ /**
3399
+ * Build a new tree from `entries`, layered over `baseTreeSha`. Entries
3400
+ * with `content` are inlined (text path); entries with a pre-uploaded
3401
+ * `sha` reference an existing blob (binary path — used by attachments).
3402
+ */
3403
+ async createTree(token, owner, name, args, opts = {}) {
3404
+ const tree = args.entries.map((e) => ({
3405
+ path: e.path,
3406
+ mode: e.mode ?? "100644",
3407
+ type: e.type ?? "blob",
3408
+ ...e.content !== void 0 ? { content: e.content } : {},
3409
+ ...e.sha !== void 0 ? { sha: e.sha } : {}
3410
+ }));
3411
+ const { json } = await this.call(
3412
+ token,
3413
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/git/trees`,
3414
+ {
3415
+ ...opts,
3416
+ method: "POST",
3417
+ body: { base_tree: args.baseTreeSha, tree },
3418
+ requiredScopes: ["repo"]
3419
+ }
3420
+ );
3421
+ return { sha: json.sha };
3422
+ }
3423
+ /**
3424
+ * Create a new commit object pointing at the given tree, with the given
3425
+ * parents. Returns the new commit's SHA + the tree it points at.
3426
+ */
3427
+ async createCommit(token, owner, name, args, opts = {}) {
3428
+ const { json } = await this.call(
3429
+ token,
3430
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/git/commits`,
3431
+ {
3432
+ ...opts,
3433
+ method: "POST",
3434
+ body: {
3435
+ message: args.message,
3436
+ tree: args.treeSha,
3437
+ parents: args.parents
3438
+ },
3439
+ requiredScopes: ["repo"]
3440
+ }
3441
+ );
3442
+ return { sha: json.sha, treeSha: json.tree.sha };
3443
+ }
3444
+ /**
3445
+ * Fast-forward a branch ref to a new commit SHA. Pass `force: true` to
3446
+ * skip the FF check (we don't — push-to-save is always FF over the ref
3447
+ * we just read with getRef()).
3448
+ */
3449
+ async updateRef(token, owner, name, args, opts = {}) {
3450
+ const { json } = await this.call(
3451
+ token,
3452
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/git/refs/heads/${encodeURIComponent(args.branch)}`,
3453
+ {
3454
+ ...opts,
3455
+ method: "PATCH",
3456
+ body: { sha: args.sha, force: args.force ?? false },
3457
+ requiredScopes: ["repo"]
3458
+ }
3459
+ );
3460
+ return { ref: json.ref, sha: json.object.sha };
3461
+ }
3462
+ /**
3463
+ * Search GitHub for public API Circle workspaces. Appends
3464
+ * `topic:apicircle` to the user-supplied query so only repos carrying
3465
+ * the `apicircle` topic — the topic the Releases & Topics dialog
3466
+ * locks onto every workspace repo — surface in results. GitHub
3467
+ * matches the bare query against repository name, description, and
3468
+ * topics, so category words like `payments` narrow the marketplace by
3469
+ * topic. An empty query lists every public API Circle workspace. Top
3470
+ * 30 results. Token is optional — anonymous browsing is supported
3471
+ * (lower GitHub rate limits apply); pass a PAT when one is available
3472
+ * to lift them. `sort` controls ordering: omit for GitHub's
3473
+ * best-match relevance, or pass `'stars'` / `'updated'`.
3474
+ */
3475
+ async searchMarketplaceRepos(token, query, opts = {}) {
3476
+ const { sort, ...callOpts } = opts;
3477
+ const fullQuery = `${query.trim()} topic:apicircle`.trim();
3478
+ const sortParam = sort ? `&sort=${sort}&order=desc` : "";
3479
+ const path2 = `/search/repositories?q=${encodeURIComponent(fullQuery)}&per_page=30${sortParam}`;
3480
+ const { json } = await this.call(token, path2, callOpts);
3481
+ const items = json.items ?? [];
3482
+ return items.map(normalizeMarketplaceRepo);
3483
+ }
3484
+ /**
3485
+ * Start GitHub's OAuth Device Flow. Returns a user-facing code the
3486
+ * user types into github.com/login/device + a device_code the app
3487
+ * polls with. Pure browser-safe: no client_secret involved (device
3488
+ * flow is the only OAuth path GitHub supports for public clients).
3489
+ *
3490
+ * Requires the OAuth App to have "Enable Device Flow" turned on in
3491
+ * its GitHub settings — surface 400 with `not_supported` to the user
3492
+ * if the App owner hasn't done that yet.
3493
+ */
3494
+ async startDeviceFlow(clientId, scope, opts = {}) {
3495
+ const url = `${this.loginBaseUrl}/login/device/code`;
3496
+ const response = await this.fetchImpl(url, {
3497
+ method: "POST",
3498
+ headers: { Accept: "application/json", "Content-Type": "application/json" },
3499
+ body: JSON.stringify({ client_id: clientId, scope }),
3500
+ signal: opts.signal
3501
+ });
3502
+ if (!response.ok) {
3503
+ throw new GitHubError(
3504
+ `Device-flow start failed: HTTP ${response.status}`,
3505
+ response.status,
3506
+ {}
3507
+ );
3508
+ }
3509
+ const json = await response.json();
3510
+ if (json.error) {
3511
+ throw new GitHubError(json.error_description ?? json.error, 400, json);
3512
+ }
3513
+ return {
3514
+ deviceCode: json.device_code,
3515
+ userCode: json.user_code,
3516
+ verificationUri: json.verification_uri,
3517
+ expiresIn: json.expires_in,
3518
+ interval: json.interval
3519
+ };
3520
+ }
3521
+ /**
3522
+ * Poll for the access token after the user has authorized the device
3523
+ * code. GitHub returns `authorization_pending` until the user
3524
+ * completes the flow, `slow_down` if we polled too fast, then a real
3525
+ * token. Caller wraps this in a polling loop bounded by `expiresIn`.
3526
+ */
3527
+ async pollDeviceToken(clientId, deviceCode, opts = {}) {
3528
+ const url = `${this.loginBaseUrl}/login/oauth/access_token`;
3529
+ const response = await this.fetchImpl(url, {
3530
+ method: "POST",
3531
+ headers: { Accept: "application/json", "Content-Type": "application/json" },
3532
+ body: JSON.stringify({
3533
+ client_id: clientId,
3534
+ device_code: deviceCode,
3535
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code"
3536
+ }),
3537
+ signal: opts.signal
3538
+ });
3539
+ const json = await response.json();
3540
+ if (json.access_token) {
3541
+ return {
3542
+ kind: "granted",
3543
+ accessToken: json.access_token,
3544
+ tokenType: json.token_type ?? "bearer",
3545
+ scope: json.scope ?? ""
3546
+ };
3547
+ }
3548
+ if (json.error === "authorization_pending") return { kind: "pending", slowDown: false };
3549
+ if (json.error === "slow_down") return { kind: "pending", slowDown: true };
3550
+ if (json.error === "expired_token") return { kind: "expired" };
3551
+ if (json.error === "access_denied")
3552
+ return { kind: "denied", reason: json.error_description ?? "User denied authorization" };
3553
+ throw new GitHubError(
3554
+ json.error_description ?? json.error ?? "Device-token poll failed",
3555
+ response.status,
3556
+ json
3557
+ );
3558
+ }
3559
+ /**
3560
+ * Create a lightweight Git tag (a ref under `refs/tags/<name>`) on the
3561
+ * given commit SHA. Used by the publish-release flow when the user
3562
+ * opts in to "Create Git tag v<x.y.z>". Returns the resolved ref.
3563
+ *
3564
+ * GitHub returns 422 with "Reference already exists" when the tag is
3565
+ * a duplicate; that surfaces as a GitHubError(422) so the UI can warn
3566
+ * the user without ever overwriting an existing tag.
3567
+ */
3568
+ async createTag(token, owner, name, args, opts = {}) {
3569
+ const { json } = await this.call(
3570
+ token,
3571
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/git/refs`,
3572
+ {
3573
+ ...opts,
3574
+ method: "POST",
3575
+ body: { ref: `refs/tags/${args.tagName}`, sha: args.sha },
3576
+ requiredScopes: ["repo"]
3577
+ }
3578
+ );
3579
+ return { ref: json.ref, sha: json.object.sha };
3580
+ }
3581
+ /**
3582
+ * Compare two commits. Returns the relationship classification GitHub
3583
+ * gives us: `ahead` (head is descendant of base), `behind` (base is
3584
+ * descendant of head), `identical`, or `diverged` (the two histories
3585
+ * share a base but neither contains the other — typical of a force-push
3586
+ * that rewrote history under us).
3587
+ *
3588
+ * Used by the refresh path so we never silently 3-way-merge across a
3589
+ * history rewrite — divergence steers the user through an explicit
3590
+ * "history rewritten" modal instead of corrupting local state.
3591
+ */
3592
+ async compareCommits(token, owner, name, base, head, opts = {}) {
3593
+ const { json } = await this.call(
3594
+ token,
3595
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/compare/${encodeURIComponent(
3596
+ base
3597
+ )}...${encodeURIComponent(head)}`,
3598
+ { ...opts, requiredScopes: ["repo"] }
3599
+ );
3600
+ return {
3601
+ status: json.status,
3602
+ aheadBy: json.ahead_by,
3603
+ behindBy: json.behind_by
3604
+ };
3605
+ }
3606
+ /**
3607
+ * Is `ancestor` reachable from `descendant`? Thin wrapper around
3608
+ * `compareCommits` — "ahead" or "identical" means yes; "behind" or
3609
+ * "diverged" means the histories don't fit, so the answer is no.
3610
+ */
3611
+ async isAncestor(token, owner, name, ancestor, descendant, opts = {}) {
3612
+ if (ancestor === descendant) return true;
3613
+ const cmp = await this.compareCommits(token, owner, name, ancestor, descendant, opts);
3614
+ return cmp.status === "ahead" || cmp.status === "identical";
3615
+ }
3616
+ /**
3617
+ * Create a GitHub Release pointing at an existing tag. Used by the
3618
+ * publish-release flow when the user opts in to "Create GitHub
3619
+ * Release". Returns the release's HTML URL so the UI can show a
3620
+ * "Released — view on GitHub" link.
3621
+ *
3622
+ * Pass `prerelease: true` for semver pre-release identifiers (e.g.
3623
+ * `1.0.0-rc.1`); GitHub's Releases UI flags those distinctly.
3624
+ */
3625
+ async createRelease(token, owner, name, args, opts = {}) {
3626
+ const { json } = await this.call(token, `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/releases`, {
3627
+ ...opts,
3628
+ method: "POST",
3629
+ body: {
3630
+ tag_name: args.tagName,
3631
+ name: args.releaseName ?? args.tagName,
3632
+ body: args.body ?? "",
3633
+ draft: args.draft ?? false,
3634
+ prerelease: args.prerelease ?? false
3635
+ },
3636
+ requiredScopes: ["repo"]
3637
+ });
3638
+ return { id: json.id, htmlUrl: json.html_url, tagName: json.tag_name };
3639
+ }
3640
+ /**
3641
+ * Read a tag ref's current commit SHA. Used by the Release & topics
3642
+ * modal to detect whether a tag with the chosen name already exists
3643
+ * (so the UI can surface an "Override existing tag" toggle instead of
3644
+ * silently 422'ing through createTag).
3645
+ *
3646
+ * Returns `null` when the tag doesn't exist (404). Other failures
3647
+ * surface as typed errors.
3648
+ */
3649
+ async getTagSha(token, owner, name, tagName, opts = {}) {
3650
+ try {
3651
+ const { json } = await this.call(
3652
+ token,
3653
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/git/refs/tags/${encodeURIComponent(tagName)}`,
3654
+ opts
3655
+ );
3656
+ return json.object.sha;
3657
+ } catch (err) {
3658
+ if (err instanceof GitHubError && err.status === 404) return null;
3659
+ throw err;
3660
+ }
3661
+ }
3662
+ /**
3663
+ * Delete a ref. Used to support the "Override existing tag" path on
3664
+ * the Release & topics modal — we delete the existing tag ref, then
3665
+ * createTag against the new SHA. (GitHub doesn't have a single
3666
+ * "force-update tag" endpoint via the simple refs API.)
3667
+ *
3668
+ * `ref` is the bare suffix, e.g. `tags/v1.0.0` or `heads/feature-x`.
3669
+ */
3670
+ async deleteRef(token, owner, name, ref, opts = {}) {
3671
+ await this.call(
3672
+ token,
3673
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/git/refs/${ref.split("/").map(encodeURIComponent).join("/")}`,
3674
+ {
3675
+ ...opts,
3676
+ method: "DELETE",
3677
+ requiredScopes: ["repo"]
3678
+ }
3679
+ );
3680
+ }
3681
+ /**
3682
+ * Read the repo's current topic list. Topics drive marketplace
3683
+ * discoverability — public API Circle workspaces include `apicircle`
3684
+ * plus user-chosen category topics.
3685
+ *
3686
+ * Note: GitHub's topics API uses a custom Accept header, but we treat
3687
+ * that as transport detail; the `application/vnd.github.mercy-preview+json`
3688
+ * preview is now stable so the default Accept works.
3689
+ */
3690
+ async listRepoTopics(token, owner, name, opts = {}) {
3691
+ const { json } = await this.call(
3692
+ token,
3693
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/topics`,
3694
+ opts
3695
+ );
3696
+ return Array.isArray(json.names) ? json.names : [];
3697
+ }
3698
+ /**
3699
+ * Replace the repo's full topic list. GitHub's `PUT /topics` endpoint
3700
+ * is a full replace (not a merge), so the caller must pass the
3701
+ * complete desired list. Caps at 20 topics; each must match
3702
+ * `^[a-z0-9][a-z0-9-]*$` and be ≤ 50 chars (GitHub enforces this with
3703
+ * a 422). Returns the persisted list.
3704
+ */
3705
+ async setRepoTopics(token, owner, name, topics, opts = {}) {
3706
+ const { json } = await this.call(
3707
+ token,
3708
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/topics`,
3709
+ {
3710
+ ...opts,
3711
+ method: "PUT",
3712
+ body: { names: topics },
3713
+ requiredScopes: ["repo"]
3714
+ }
3715
+ );
3716
+ return Array.isArray(json.names) ? json.names : [];
3717
+ }
3718
+ /**
3719
+ * Fetch a single file's contents from a branch / commit. Returns
3720
+ * `null` when GitHub answers 404 (file simply doesn't exist on that
3721
+ * ref — the common case for the very first pull). Other failures
3722
+ * surface as the usual typed errors.
3723
+ *
3724
+ * Used by the refresh flow to read remote `workspace.json` so the
3725
+ * 3-way diff can compare it against the local doc.
3726
+ */
3727
+ async getContents(token, owner, name, path2, ref, opts = {}) {
3728
+ const query = `?ref=${encodeURIComponent(ref)}`;
3729
+ try {
3730
+ const { json } = await this.call(
3731
+ token,
3732
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/contents/${path2.split("/").map(encodeURIComponent).join("/")}${query}`,
3733
+ opts
3734
+ );
3735
+ if (Array.isArray(json) || json.type !== "file") {
3736
+ throw new GitHubError(`Path ${path2} is not a file`, 422, json);
3737
+ }
3738
+ const cleaned = json.content.replace(/\n/g, "");
3739
+ const decoded = decodeBase64Utf8(cleaned);
3740
+ return { content: decoded, sha: json.sha, path: json.path, size: json.size };
3741
+ } catch (err) {
3742
+ if (err instanceof GitHubError && err.status === 404) return null;
3743
+ throw err;
3744
+ }
3745
+ }
3746
+ /**
3747
+ * Create or update a file via the Contents API. The killer feature here
3748
+ * vs. the git-data flow (createBlob → createTree → createCommit →
3749
+ * updateRef) is that this works on **truly empty repos**: GitHub's git
3750
+ * database isn't initialized until the first commit lands, so all the
3751
+ * `/git/*` endpoints reject with 409 "Git Repository is empty" — but
3752
+ * `PUT /contents/{path}` atomically initializes the database with a
3753
+ * single-file commit on the supplied branch (defaulting to the repo's
3754
+ * default branch).
3755
+ *
3756
+ * Used by the seed-initial-commit flow to bootstrap a freshly-created
3757
+ * empty repo with a scaffold `workspace.json`.
3758
+ *
3759
+ * `contentBase64` must already be base64-encoded — caller chooses the
3760
+ * encoder (TextEncoder for UTF-8 strings, raw bytes for binaries).
3761
+ */
3762
+ async putContents(token, owner, name, path2, args, opts = {}) {
3763
+ const body = {
3764
+ message: args.message,
3765
+ content: args.contentBase64
3766
+ };
3767
+ if (args.branch) body.branch = args.branch;
3768
+ if (args.sha) body.sha = args.sha;
3769
+ const { json } = await this.call(
3770
+ token,
3771
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/contents/${path2.split("/").map(encodeURIComponent).join("/")}`,
3772
+ {
3773
+ ...opts,
3774
+ method: "PUT",
3775
+ body,
3776
+ requiredScopes: ["repo"]
3777
+ }
3778
+ );
3779
+ return { commitSha: json.commit.sha, contentSha: json.content.sha };
3780
+ }
3781
+ /**
3782
+ * Same as `getContents` but returns the raw bytes instead of UTF-8
3783
+ * decoding the file. Used by the refresh flow to pull
3784
+ * `.apicircle/workspace-<id>/attachments/<slotId>` blobs into local IDB without
3785
+ * mangling binary data through TextDecoder.
3786
+ */
3787
+ async getBinaryContents(token, owner, name, path2, ref, opts = {}) {
3788
+ const query = `?ref=${encodeURIComponent(ref)}`;
3789
+ try {
3790
+ const { json } = await this.call(
3791
+ token,
3792
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/contents/${path2.split("/").map(encodeURIComponent).join("/")}${query}`,
3793
+ opts
3794
+ );
3795
+ if (Array.isArray(json) || json.type !== "file") {
3796
+ throw new GitHubError(`Path ${path2} is not a file`, 422, json);
3797
+ }
3798
+ const cleaned = json.content.replace(/\n/g, "");
3799
+ const bytes = decodeBase64Bytes(cleaned);
3800
+ return { bytes, sha: json.sha, path: json.path, size: json.size };
3801
+ } catch (err) {
3802
+ if (err instanceof GitHubError && err.status === 404) return null;
3803
+ throw err;
3804
+ }
3805
+ }
3806
+ /**
3807
+ * Open a pull request from `head` (the working branch) into `base` (the
3808
+ * repo's default branch). PR creation needs the `pull_request` scope on
3809
+ * top of `repo`; missing-scope errors flow through MissingScopeError so
3810
+ * the UI can prompt the user to update the token without losing branch
3811
+ * state (Plan §3.7).
3812
+ *
3813
+ * GitHub returns 422 when:
3814
+ * - head/base are equal (nothing to merge)
3815
+ * - a PR already exists between this head and base
3816
+ * - the head branch doesn't exist
3817
+ * All three surface as a plain GitHubError(422); the UI message is
3818
+ * picked up from response.body.message.
3819
+ */
3820
+ /**
3821
+ * Fetch a single pull request by number. Used by the refresh flow to
3822
+ * detect whether a previously-opened PR has been merged on GitHub —
3823
+ * `merged: true` is what triggers the working-branch retirement path.
3824
+ *
3825
+ * Returns `null` on 404 (PR was deleted or never existed at this number);
3826
+ * other failures surface as the usual typed errors.
3827
+ */
3828
+ async getPullRequest(token, owner, name, number, opts = {}) {
3829
+ try {
3830
+ const { json } = await this.call(
3831
+ token,
3832
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pulls/${number}`,
3833
+ opts
3834
+ );
3835
+ return {
3836
+ number: json.number,
3837
+ htmlUrl: json.html_url,
3838
+ state: json.state,
3839
+ merged: json.merged === true
3840
+ };
3841
+ } catch (err) {
3842
+ if (err instanceof GitHubError && err.status === 404) return null;
3843
+ throw err;
3844
+ }
3845
+ }
3846
+ /**
3847
+ * List pull requests on a repo. The capability-probe path uses this with
3848
+ * `perPage: 1` to determine whether the token can read PRs (and, by
3849
+ * extension on classic PATs, whether it can also create them).
3850
+ *
3851
+ * Caller declares `requiredScopes` to surface a `MissingScopeError` on
3852
+ * 403, so the capability probe can recognise the missing-scope case
3853
+ * cleanly vs. transient 5xx/network failures.
3854
+ */
3855
+ async listPullRequests(token, owner, name, args = {}, opts = {}) {
3856
+ const params = new URLSearchParams();
3857
+ params.set("per_page", String(args.perPage ?? 30));
3858
+ if (args.state) params.set("state", args.state);
3859
+ const { json } = await this.call(
3860
+ token,
3861
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pulls?${params.toString()}`,
3862
+ {
3863
+ ...opts,
3864
+ requiredScopes: ["repo", "pull_request"]
3865
+ }
3866
+ );
3867
+ return json.map((pr) => ({
3868
+ number: pr.number,
3869
+ htmlUrl: pr.html_url,
3870
+ state: pr.state,
3871
+ title: pr.title
3872
+ }));
3873
+ }
3874
+ async createPullRequest(token, owner, name, args, opts = {}) {
3875
+ const { json } = await this.call(
3876
+ token,
3877
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pulls`,
3878
+ {
3879
+ ...opts,
3880
+ method: "POST",
3881
+ body: {
3882
+ title: args.title,
3883
+ body: args.body,
3884
+ head: args.head,
3885
+ base: args.base,
3886
+ draft: args.draft ?? false
3887
+ },
3888
+ requiredScopes: ["repo", "pull_request"]
3889
+ }
3890
+ );
3891
+ return {
3892
+ number: json.number,
3893
+ htmlUrl: json.html_url,
3894
+ state: json.state,
3895
+ title: json.title
3896
+ };
3897
+ }
3898
+ // --- low-level call ----------------------------------------------------
3899
+ async call(token, path2, opts = {}) {
3900
+ const url = path2.startsWith("http") ? path2 : `${this.baseUrl}${path2}`;
3901
+ const controller = new AbortController();
3902
+ const onExternalAbort = () => controller.abort(opts.signal.reason);
3903
+ if (opts.signal) {
3904
+ if (opts.signal.aborted) controller.abort(opts.signal.reason);
3905
+ else opts.signal.addEventListener("abort", onExternalAbort, { once: true });
3906
+ }
3907
+ const timeoutHandle = setTimeout(
3908
+ () => controller.abort(new Error(`GitHub request timed out after ${this.timeoutMs}ms`)),
3909
+ this.timeoutMs
3910
+ );
3911
+ let response;
3912
+ let timedOut = false;
3913
+ try {
3914
+ response = await this.fetchImpl(url, {
3915
+ method: opts.method ?? "GET",
3916
+ headers: {
3917
+ Accept: "application/vnd.github+json",
3918
+ "X-GitHub-Api-Version": "2022-11-28",
3919
+ ...token ? { Authorization: `Bearer ${token}` } : {},
3920
+ ...opts.body !== void 0 ? { "Content-Type": "application/json" } : {}
3921
+ },
3922
+ cache: "no-store",
3923
+ body: opts.body !== void 0 ? JSON.stringify(opts.body) : void 0,
3924
+ signal: controller.signal
3925
+ });
3926
+ } catch (err) {
3927
+ const isAbort = err instanceof DOMException && err.name === "AbortError";
3928
+ const callerAborted = opts.signal?.aborted ?? false;
3929
+ if (isAbort && !callerAborted) {
3930
+ timedOut = true;
3931
+ throw new TimeoutError(
3932
+ `GitHub request timed out after ${this.timeoutMs}ms. The write may have partially landed \u2014 refresh before retrying.`,
3933
+ this.timeoutMs
3934
+ );
3935
+ }
3936
+ throw err;
3937
+ } finally {
3938
+ clearTimeout(timeoutHandle);
3939
+ if (opts.signal) opts.signal.removeEventListener("abort", onExternalAbort);
3940
+ void timedOut;
3941
+ }
3942
+ if (response.ok) {
3943
+ if (response.status === 204 || response.status === 205) {
3944
+ return { json: {}, response };
3945
+ }
3946
+ const json = await response.json();
3947
+ return { json, response };
3948
+ }
3949
+ const errBody = await safeReadJson(response);
3950
+ throw classifyError(response, errBody, opts.requiredScopes ?? []);
3951
+ }
3952
+ };
3953
+ function normalizeMarketplaceRepo(raw) {
3954
+ return {
3955
+ fullName: raw.full_name,
3956
+ owner: raw.owner.login,
3957
+ name: raw.name,
3958
+ description: raw.description ?? "",
3959
+ topics: raw.topics ?? [],
3960
+ stargazers: raw.stargazers_count ?? 0,
3961
+ defaultBranch: raw.default_branch ?? "main"
3962
+ };
3963
+ }
3964
+ function decodeBase64Utf8(b64) {
3965
+ return new TextDecoder("utf-8").decode(decodeBase64Bytes(b64));
3966
+ }
3967
+ function decodeBase64Bytes(b64) {
3968
+ const binary = atob(b64);
3969
+ const bytes = new Uint8Array(binary.length);
3970
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
3971
+ return bytes;
3972
+ }
3973
+ function normalizeRepo(raw) {
3974
+ const visibility = raw.visibility ?? (raw.private === true ? "private" : "public");
3975
+ const isPrivate = raw.private ?? visibility !== "public";
3976
+ const pushable = raw.permissions?.push === true || raw.permissions?.admin === true;
3977
+ return {
3978
+ fullName: raw.full_name,
3979
+ owner: raw.owner.login,
3980
+ name: raw.name,
3981
+ defaultBranch: raw.default_branch,
3982
+ visibility,
3983
+ isPrivate,
3984
+ pushable
3985
+ };
3986
+ }
3987
+ function parseScopes(headers) {
3988
+ const raw = headers.get("x-oauth-scopes") ?? "";
3989
+ const granted = raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
3990
+ const acceptedHeader = headers.get("x-accepted-oauth-scopes") ?? "";
3991
+ const acceptedRequired = acceptedHeader.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
3992
+ return acceptedRequired.length > 0 ? { granted, acceptedRequired } : { granted };
3993
+ }
3994
+ function classifyError(response, body, callerRequiredScopes) {
3995
+ const message = extractMessage(body) ?? response.statusText;
3996
+ const status = response.status;
3997
+ if (status === 401) {
3998
+ return new UnauthorizedError(message || "Unauthorized \u2014 token rejected", status);
3999
+ }
4000
+ if (status === 403) {
4001
+ const remaining = response.headers.get("x-ratelimit-remaining");
4002
+ const reset = response.headers.get("x-ratelimit-reset");
4003
+ if (remaining === "0" && reset) {
4004
+ const resetAtMs = Number(reset) * 1e3;
4005
+ const deltaMs = Math.max(0, resetAtMs - Date.now());
4006
+ const totalSeconds = Math.ceil(deltaMs / 1e3);
4007
+ const human = totalSeconds < 60 ? `${totalSeconds}s` : totalSeconds < 3600 ? `${Math.ceil(totalSeconds / 60)} min` : `${Math.ceil(totalSeconds / 3600)} h`;
4008
+ return new RateLimitedError(
4009
+ `GitHub rate limit reached. Resets in ${human} (at ${new Date(resetAtMs).toISOString()}).`,
4010
+ status,
4011
+ resetAtMs
4012
+ );
4013
+ }
4014
+ const accepted = (response.headers.get("x-accepted-oauth-scopes") ?? "").split(",").map((s) => s.trim()).filter((s) => s.length > 0);
4015
+ const granted = (response.headers.get("x-oauth-scopes") ?? "").split(",").map((s) => s.trim()).filter((s) => s.length > 0);
4016
+ const missing = accepted.length > 0 ? accepted.filter((s) => !granted.includes(s)) : callerRequiredScopes.filter((s) => !granted.includes(s));
4017
+ if (missing.length > 0) {
4018
+ return new MissingScopeError(
4019
+ `GitHub denied this action: missing scopes ${missing.join(", ")}.`,
4020
+ status,
4021
+ missing,
4022
+ granted
4023
+ );
4024
+ }
4025
+ }
4026
+ return new GitHubError(message || "GitHub API call failed", status, body);
4027
+ }
4028
+ function extractMessage(body) {
4029
+ if (typeof body === "object" && body !== null && "message" in body) {
4030
+ const m = body.message;
4031
+ if (typeof m === "string") return m;
4032
+ }
4033
+ return null;
4034
+ }
4035
+ async function safeReadJson(response) {
4036
+ try {
4037
+ return await response.json();
4038
+ } catch {
4039
+ return null;
4040
+ }
4041
+ }
4042
+
4043
+ // src/tools/githubOps.ts
4044
+ function resolveToken(input) {
4045
+ const t = (input ?? process.env.GITHUB_TOKEN ?? "").trim();
4046
+ return t;
4047
+ }
4048
+ var TOKEN_HELP = "Pass `token`, or set the GITHUB_TOKEN env var on the MCP process.";
4049
+ var linkedLinkTool = {
4050
+ name: "linked.link",
4051
+ description: "Link a workspace by fetching its `.apicircle/` workspace from GitHub (registry.json \u2192 workspace-<id>/workspace.json). Caches the release ledger + a collections/environments snapshot. Needs a token for private repos. " + TOKEN_HELP,
4052
+ inputSchema: import_zod14.z.object({
4053
+ repoFullName: import_zod14.z.string().describe("owner/name of the source workspace repo."),
4054
+ branch: import_zod14.z.string().default("main"),
4055
+ pinnedVersion: import_zod14.z.string().nullable().optional().describe("null/omitted = source current version."),
4056
+ kind: import_zod14.z.enum(["private", "public"]).default("private"),
4057
+ token: import_zod14.z.string().optional()
4058
+ }),
4059
+ async handler(input, ctx) {
4060
+ const token = resolveToken(input.token);
4061
+ const repoFullName = input.repoFullName.trim();
4062
+ if (!repoFullName.includes("/")) return { ok: false, error: "repoFullName must be owner/name" };
4063
+ if (input.kind === "private" && !token)
4064
+ return { ok: false, error: `A token is required for private repos. ${TOKEN_HELP}` };
4065
+ const [owner, name] = repoFullName.split("/", 2);
4066
+ const state = await ctx.workspace.read();
4067
+ const dup = Object.values(state.synced.linkedWorkspaces).find(
4068
+ (l) => l.source.repoFullName === repoFullName && l.source.branch === input.branch
4069
+ );
4070
+ if (dup)
4071
+ return { ok: false, error: `Already linked to ${repoFullName}@${input.branch} (${dup.id})` };
4072
+ const client = new GitHubClient();
4073
+ let result;
4074
+ try {
4075
+ result = await (0, import_core5.fetchRemoteWorkspaceJson)(async (p) => {
4076
+ const f = await client.getContents(token, owner, name, p, input.branch);
4077
+ return f?.content ?? null;
4078
+ });
4079
+ } catch (e) {
4080
+ return {
4081
+ ok: false,
4082
+ error: e instanceof GitHubError ? e.message : e instanceof Error ? e.message : "fetch failed"
4083
+ };
4084
+ }
4085
+ if ("error" in result)
4086
+ return { ok: false, error: `${repoFullName}@${input.branch}: ${result.error}` };
4087
+ const probe = (0, import_core5.parseLinkedWorkspaceJson)(result.content);
4088
+ const ledger = (0, import_core5.ledgerFromProbe)(probe);
4089
+ const link = {
4090
+ id: (0, import_shared6.generateId)(),
4091
+ kind: input.kind,
4092
+ name: repoFullName,
4093
+ sourceWorkspaceId: result.workspaceId,
4094
+ source: { provider: "github", repoFullName, branch: input.branch, sessionMode: "workspace" },
4095
+ scope: ["collections", "environments"],
4096
+ pinnedVersion: input.pinnedVersion ?? ledger.currentVersion,
4097
+ updatePolicy: "manual",
4098
+ linkedAt: (/* @__PURE__ */ new Date()).toISOString(),
4099
+ requiredSecretKeyIds: probe.secretKeys ? Object.keys(probe.secretKeys) : []
4100
+ };
4101
+ const snapshot = (0, import_core5.buildLinkedSnapshot)(probe, link) ?? void 0;
4102
+ const out = await ctx.workspace.apply({
4103
+ kind: "linkedWorkspace.upsert",
4104
+ link,
4105
+ ledger,
4106
+ ...snapshot ? { snapshot } : {}
4107
+ });
4108
+ return { ok: true, id: link.id, pinnedVersion: link.pinnedVersion, changedIds: out.changedIds };
4109
+ }
4110
+ };
4111
+ var linkedRefreshTool = {
4112
+ name: "linked.refresh",
4113
+ description: "Re-pull a linked workspace's cached release ledger (+ bootstrap snapshot if missing) from GitHub. " + TOKEN_HELP,
4114
+ inputSchema: import_zod14.z.object({ id: import_zod14.z.string(), token: import_zod14.z.string().optional() }),
4115
+ async handler(input, ctx) {
4116
+ const state = await ctx.workspace.read();
4117
+ const link = state.synced.linkedWorkspaces[input.id];
4118
+ if (!link) return { ok: false, error: `Linked workspace ${input.id} not found` };
4119
+ const token = resolveToken(input.token);
4120
+ if (link.kind === "private" && !token)
4121
+ return { ok: false, error: `A token is required for private links. ${TOKEN_HELP}` };
4122
+ const [owner, name] = link.source.repoFullName.split("/", 2);
4123
+ const client = new GitHubClient();
4124
+ let result;
4125
+ try {
4126
+ result = await (0, import_core5.fetchRemoteWorkspaceJson)(async (p) => {
4127
+ const f = await client.getContents(token, owner, name, p, link.source.branch);
4128
+ return f?.content ?? null;
4129
+ });
4130
+ } catch (e) {
4131
+ return { ok: false, error: e instanceof Error ? e.message : "fetch failed" };
4132
+ }
4133
+ if ("error" in result)
4134
+ return {
4135
+ ok: false,
4136
+ error: `${link.source.repoFullName}@${link.source.branch}: ${result.error}`
4137
+ };
4138
+ const probe = (0, import_core5.parseLinkedWorkspaceJson)(result.content);
4139
+ const ledger = (0, import_core5.ledgerFromProbe)(probe);
4140
+ const needsSnapshot = !state.local.linkedCollections[input.id];
4141
+ const snapshot = needsSnapshot ? (0, import_core5.buildLinkedSnapshot)(probe, link) ?? void 0 : void 0;
4142
+ await ctx.workspace.apply({
4143
+ kind: "linkedWorkspace.upsert",
4144
+ link,
4145
+ ledger,
4146
+ ...snapshot ? { snapshot } : {}
4147
+ });
4148
+ return {
4149
+ ok: true,
4150
+ currentVersion: ledger.currentVersion,
4151
+ versionCount: ledger.versions.length
4152
+ };
4153
+ }
4154
+ };
4155
+ var releaseTagTool = {
4156
+ name: "release.tag",
4157
+ description: "Create a `v<version>` Git tag (optionally a GitHub Release) on the workspace's own repo. The version should already exist in the release ledger. " + TOKEN_HELP,
4158
+ inputSchema: import_zod14.z.object({
4159
+ owner: import_zod14.z.string(),
4160
+ name: import_zod14.z.string(),
4161
+ version: import_zod14.z.string(),
4162
+ createGitHubRelease: import_zod14.z.boolean().default(false),
4163
+ notes: import_zod14.z.string().optional(),
4164
+ overrideExisting: import_zod14.z.boolean().default(false),
4165
+ token: import_zod14.z.string().optional()
4166
+ }),
4167
+ async handler(input, _ctx) {
4168
+ const token = resolveToken(input.token);
4169
+ if (!token) return { ok: false, error: `A token is required to tag. ${TOKEN_HELP}` };
4170
+ const client = new GitHubClient();
4171
+ const tagName = `v${input.version.replace(/^v/, "")}`;
4172
+ try {
4173
+ const repo = await client.getRepo(token, input.owner, input.name);
4174
+ const ref = await client.getRef(token, input.owner, input.name, repo.defaultBranch);
4175
+ const existing = await client.getTagSha(token, input.owner, input.name, tagName);
4176
+ if (existing !== null) {
4177
+ if (!input.overrideExisting) {
4178
+ return {
4179
+ ok: false,
4180
+ error: `Tag ${tagName} already exists at ${existing.slice(0, 7)}. Pass overrideExisting:true to replace.`
4181
+ };
4182
+ }
4183
+ await client.deleteRef(token, input.owner, input.name, `tags/${tagName}`);
4184
+ }
4185
+ await client.createTag(token, input.owner, input.name, { tagName, sha: ref.sha });
4186
+ let releaseUrl;
4187
+ if (input.createGitHubRelease) {
4188
+ const release = await client.createRelease(token, input.owner, input.name, {
4189
+ tagName,
4190
+ releaseName: tagName,
4191
+ body: input.notes ?? ""
4192
+ });
4193
+ releaseUrl = release.htmlUrl;
4194
+ }
4195
+ return {
4196
+ ok: true,
4197
+ tagName,
4198
+ sha: ref.sha,
4199
+ branch: repo.defaultBranch,
4200
+ ...releaseUrl ? { releaseUrl } : {}
4201
+ };
4202
+ } catch (e) {
4203
+ return { ok: false, error: e instanceof Error ? e.message : "tag failed" };
4204
+ }
4205
+ }
4206
+ };
4207
+ var marketplaceSearchTool = {
4208
+ name: "marketplace.search",
4209
+ description: "Search the API Circle marketplace for public workspaces tagged with `apicircle` on GitHub. Returns up to 30 results sorted by relevance (default), stars, or recent updates. Token is optional \u2014 anonymous browsing is supported (lower rate limits); pass a token to lift them. " + TOKEN_HELP,
4210
+ inputSchema: import_zod14.z.object({
4211
+ query: import_zod14.z.string().default("").describe("Search query \u2014 matches repo name, description, and topics. Empty = browse all."),
4212
+ sort: import_zod14.z.enum(["best-match", "stars", "updated"]).default("best-match").describe(
4213
+ "Sort order: best-match (default relevance), stars (most starred first), updated (recently pushed first)."
4214
+ ),
4215
+ token: import_zod14.z.string().optional()
4216
+ }),
4217
+ async handler(input, _ctx) {
4218
+ const token = resolveToken(input.token) || null;
4219
+ const client = new GitHubClient();
4220
+ try {
4221
+ const repos = await client.searchMarketplaceRepos(token, input.query, {
4222
+ sort: input.sort === "best-match" ? void 0 : input.sort
4223
+ });
4224
+ return { ok: true, count: repos.length, results: repos };
4225
+ } catch (e) {
4226
+ return {
4227
+ ok: false,
4228
+ error: e instanceof GitHubError ? e.message : e instanceof Error ? e.message : "search failed"
4229
+ };
4230
+ }
4231
+ }
4232
+ };
4233
+ var TOPIC_RE = /^[a-z0-9][a-z0-9-]*$/;
4234
+ var repoSetTopicsTool = {
4235
+ name: "repo.set_topics",
4236
+ description: "Replace a repo's topics (the `apicircle` topic is always kept \u2014 it drives marketplace discovery). Topics must be lowercase, start with a letter/digit, \u226450 chars, \u226420 total. " + TOKEN_HELP,
4237
+ inputSchema: import_zod14.z.object({
4238
+ owner: import_zod14.z.string(),
4239
+ name: import_zod14.z.string(),
4240
+ topics: import_zod14.z.array(import_zod14.z.string()),
4241
+ token: import_zod14.z.string().optional()
4242
+ }),
4243
+ async handler(input, _ctx) {
4244
+ const token = resolveToken(input.token);
4245
+ if (!token) return { ok: false, error: `A token is required to set topics. ${TOKEN_HELP}` };
4246
+ const requested = input.topics;
4247
+ const normalized = Array.from(
4248
+ /* @__PURE__ */ new Set(["apicircle", ...requested.map((t) => t.trim().toLowerCase()).filter(Boolean)])
4249
+ );
4250
+ for (const t of normalized) {
4251
+ if (!TOPIC_RE.test(t))
4252
+ return {
4253
+ ok: false,
4254
+ error: `Invalid topic "${t}" \u2014 lowercase letters/digits/"-", starting with a letter or digit.`
4255
+ };
4256
+ if (t.length > 50) return { ok: false, error: `Topic "${t}" exceeds 50 characters.` };
4257
+ }
4258
+ if (normalized.length > 20) return { ok: false, error: "GitHub allows at most 20 topics." };
4259
+ const client = new GitHubClient();
4260
+ try {
4261
+ const saved = await client.setRepoTopics(token, input.owner, input.name, normalized);
4262
+ return { ok: true, topics: saved };
4263
+ } catch (e) {
4264
+ return { ok: false, error: e instanceof Error ? e.message : "set topics failed" };
4265
+ }
4266
+ }
4267
+ };
4268
+
2782
4269
  // src/tools/registry.ts
2783
4270
  var TOOL_REGISTRY = [
2784
4271
  importCurlTool,
@@ -2839,6 +4326,7 @@ var TOOL_REGISTRY = [
2839
4326
  promptSetEndpointValidationRulesTool,
2840
4327
  promptSetEndpointResponseRulesTool,
2841
4328
  promptSetEndpointMultipliersTool,
4329
+ promptSetEndpointRequestSchemaTool,
2842
4330
  globalAssetsFilesListTool,
2843
4331
  globalAssetsFilesCreateTool,
2844
4332
  globalAssetsFilesUpdateTool,
@@ -2858,7 +4346,22 @@ var TOOL_REGISTRY = [
2858
4346
  mockSetValidationRulesTool,
2859
4347
  mockSetResponseRulesTool,
2860
4348
  mockSetMultipliersTool,
2861
- mockImportPostmanMockCollectionTool
4349
+ mockSetRequestSchemaTool,
4350
+ mockSetDefaultPortTool,
4351
+ mockImportPostmanMockCollectionTool,
4352
+ releaseListTool,
4353
+ releasePublishTool,
4354
+ releaseDeprecateTool,
4355
+ releaseYankTool,
4356
+ linkedListTool,
4357
+ linkedGetTool,
4358
+ linkedSetConfigTool,
4359
+ linkedUnlinkTool,
4360
+ linkedLinkTool,
4361
+ linkedRefreshTool,
4362
+ releaseTagTool,
4363
+ repoSetTopicsTool,
4364
+ marketplaceSearchTool
2862
4365
  ];
2863
4366
  function getTool(name) {
2864
4367
  return TOOL_REGISTRY.find((t) => t.name === name);
@@ -2876,22 +4379,24 @@ var SingleWorkspaceAdapter = class {
2876
4379
  displayName;
2877
4380
  async list() {
2878
4381
  const state = await this.provider.read();
2879
- const id = state.synced.workspaceId;
4382
+ const s = state.synced;
4383
+ const id = s.workspaceId ?? this.workspaceId ?? "unknown";
2880
4384
  this.workspaceId = id;
4385
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2881
4386
  return [
2882
4387
  {
2883
4388
  id,
2884
4389
  name: this.displayName,
2885
4390
  isActive: true,
2886
- createdAt: state.synced.meta.createdAt,
2887
- lastOpenedAt: state.synced.meta.updatedAt,
2888
- counts: {
2889
- requests: Object.keys(state.synced.collections.requests).length,
2890
- folders: Object.keys(state.synced.collections.folders).length,
2891
- environments: Object.keys(state.synced.environments.items).length,
2892
- mockServers: Object.keys(state.synced.mockServers ?? {}).length,
2893
- plans: Object.keys(state.synced.executionPlans ?? {}).length
2894
- }
4391
+ createdAt: s.meta?.createdAt ?? now,
4392
+ lastOpenedAt: s.meta?.updatedAt ?? now,
4393
+ counts: s.collections ? {
4394
+ requests: Object.keys(s.collections.requests ?? {}).length,
4395
+ folders: Object.keys(s.collections.folders ?? {}).length,
4396
+ environments: Object.keys(s.environments?.items ?? {}).length,
4397
+ mockServers: Object.keys(s.mockServers ?? {}).length,
4398
+ plans: Object.keys(s.executionPlans ?? {}).length
4399
+ } : null
2895
4400
  }
2896
4401
  ];
2897
4402
  }
@@ -2922,7 +4427,7 @@ var WorkspaceNotFoundError = class extends Error {
2922
4427
  };
2923
4428
 
2924
4429
  // src/providers/InMemoryWorkspaceProvider.ts
2925
- var import_core4 = require("@apicircle/core");
4430
+ var import_core6 = require("@apicircle/core");
2926
4431
  var InMemoryWorkspaceProvider = class {
2927
4432
  state;
2928
4433
  constructor(initial) {
@@ -2932,7 +4437,7 @@ var InMemoryWorkspaceProvider = class {
2932
4437
  return this.state;
2933
4438
  }
2934
4439
  async apply(patch) {
2935
- const out = (0, import_core4.applyMutation)(this.state, patch);
4440
+ const out = (0, import_core6.applyMutation)(this.state, patch);
2936
4441
  this.state = out.next;
2937
4442
  return { state: this.state, changedIds: out.changedIds };
2938
4443
  }
@@ -2946,7 +4451,7 @@ var InMemoryWorkspaceProvider = class {
2946
4451
  };
2947
4452
 
2948
4453
  // src/providers/FileBackedWorkspaceProvider.ts
2949
- var import_core5 = require("@apicircle/core");
4454
+ var import_core7 = require("@apicircle/core");
2950
4455
  var import_file_backed = require("@apicircle/core/workspace/file-backed");
2951
4456
  var FileBackedWorkspaceProvider = class {
2952
4457
  constructor(dir) {
@@ -2963,7 +4468,7 @@ var FileBackedWorkspaceProvider = class {
2963
4468
  async apply(patch) {
2964
4469
  let captured = null;
2965
4470
  await (0, import_file_backed.withWorkspace)(this.dir, async (state) => {
2966
- const result = (0, import_core5.applyMutation)(state, patch);
4471
+ const result = (0, import_core7.applyMutation)(state, patch);
2967
4472
  captured = { state: result.next, changedIds: result.changedIds };
2968
4473
  return { next: result.next };
2969
4474
  });
@@ -2981,6 +4486,52 @@ var FileBackedWorkspaceProvider = class {
2981
4486
  }
2982
4487
  };
2983
4488
 
4489
+ // src/providers/GitBackedWorkspaceProvider.ts
4490
+ var import_core8 = require("@apicircle/core");
4491
+ var import_file_backed2 = require("@apicircle/core/workspace/file-backed");
4492
+ var GIT_SYNCED_FILENAME = "workspace.json";
4493
+ var GitBackedWorkspaceProvider = class {
4494
+ constructor(dir) {
4495
+ this.dir = dir;
4496
+ }
4497
+ dir;
4498
+ async read() {
4499
+ const out = await (0, import_file_backed2.loadFromFile)(this.dir, {
4500
+ syncedFilename: GIT_SYNCED_FILENAME,
4501
+ allowMissing: true
4502
+ });
4503
+ if (!out) {
4504
+ throw new Error(
4505
+ `No workspace found at ${this.dir}. Expected .apicircle/registry.json and .apicircle/workspace-<id>/workspace.json in the repo.`
4506
+ );
4507
+ }
4508
+ return out;
4509
+ }
4510
+ async apply(patch) {
4511
+ let captured = null;
4512
+ await (0, import_file_backed2.withWorkspace)(
4513
+ this.dir,
4514
+ async (state) => {
4515
+ const result = (0, import_core8.applyMutation)(state, patch);
4516
+ captured = { state: result.next, changedIds: result.changedIds };
4517
+ return { next: result.next };
4518
+ },
4519
+ { syncedFilename: GIT_SYNCED_FILENAME }
4520
+ );
4521
+ if (!captured) throw new Error("apply did not run");
4522
+ return captured;
4523
+ }
4524
+ async write(next) {
4525
+ const current = await this.read();
4526
+ const merged = {
4527
+ synced: next.synced ?? current.synced,
4528
+ local: next.local ?? current.local
4529
+ };
4530
+ await (0, import_file_backed2.saveToFile)(this.dir, merged, { syncedFilename: GIT_SYNCED_FILENAME });
4531
+ return merged;
4532
+ }
4533
+ };
4534
+
2984
4535
  // src/providers/MultiWorkspaceProvider.ts
2985
4536
  var import_registry = require("@apicircle/core/workspace/registry");
2986
4537
  var LazyActiveWorkspaceProvider = class {
@@ -3067,13 +4618,14 @@ var MultiWorkspaceProvider = class {
3067
4618
  let counts = null;
3068
4619
  try {
3069
4620
  const state = await (0, import_registry.loadWorkspaceById)(this.registryRoot, entry.id);
3070
- if (state) {
4621
+ if (state?.synced?.collections) {
4622
+ const s = state.synced;
3071
4623
  counts = {
3072
- requests: Object.keys(state.synced.collections.requests).length,
3073
- folders: Object.keys(state.synced.collections.folders).length,
3074
- environments: Object.keys(state.synced.environments.items).length,
3075
- mockServers: Object.keys(state.synced.mockServers ?? {}).length,
3076
- plans: Object.keys(state.synced.executionPlans ?? {}).length
4624
+ requests: Object.keys(s.collections.requests ?? {}).length,
4625
+ folders: Object.keys(s.collections.folders ?? {}).length,
4626
+ environments: Object.keys(s.environments?.items ?? {}).length,
4627
+ mockServers: Object.keys(s.mockServers ?? {}).length,
4628
+ plans: Object.keys(s.executionPlans ?? {}).length
3077
4629
  };
3078
4630
  }
3079
4631
  } catch {
@@ -3153,7 +4705,245 @@ var InProcessMockController = class {
3153
4705
  }
3154
4706
  };
3155
4707
 
4708
+ // src/config/snippets.ts
4709
+ var path = __toESM(require("path"), 1);
4710
+ var AI_CLIENTS = [
4711
+ "claude-desktop",
4712
+ "claude-code",
4713
+ "codex",
4714
+ "cursor",
4715
+ "continue",
4716
+ "cline",
4717
+ "zed",
4718
+ "windsurf",
4719
+ "github-copilot",
4720
+ "chatgpt",
4721
+ "generic"
4722
+ ];
4723
+ function buildSnippetVariants(client, binary, workspace) {
4724
+ const forwardWorkspace = workspace.replace(/\\/g, "/");
4725
+ const render = client === "codex" ? renderTomlSnippet : renderJsonSnippet;
4726
+ const escaped = render(binary, workspace);
4727
+ const forwardSlash = render(binary, forwardWorkspace);
4728
+ return {
4729
+ forwardSlash,
4730
+ escaped,
4731
+ identical: forwardSlash === escaped
4732
+ };
4733
+ }
4734
+ function renderJsonSnippet(binary, workspace) {
4735
+ const entry = {
4736
+ command: binary,
4737
+ args: ["--workspace", workspace],
4738
+ env: { APICIRCLE_WORKSPACE: workspace }
4739
+ };
4740
+ return JSON.stringify({ mcpServers: { apicircle: entry } }, null, 2);
4741
+ }
4742
+ function renderTomlSnippet(binary, workspace) {
4743
+ const esc = (s) => s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
4744
+ return [
4745
+ `[mcp_servers.apicircle]`,
4746
+ `command = "${esc(binary)}"`,
4747
+ `args = ["--workspace", "${esc(workspace)}"]`,
4748
+ ``,
4749
+ `[mcp_servers.apicircle.env]`,
4750
+ `APICIRCLE_WORKSPACE = "${esc(workspace)}"`
4751
+ ].join("\n");
4752
+ }
4753
+ function resolveAiClientConfigPath(client, env) {
4754
+ const { homedir, platform, appdata } = env;
4755
+ switch (client) {
4756
+ case "claude-desktop":
4757
+ if (platform === "darwin") {
4758
+ return path.join(homedir, "Library/Application Support/Claude/claude_desktop_config.json");
4759
+ }
4760
+ if (platform === "win32") {
4761
+ return path.join(
4762
+ appdata ?? path.join(homedir, "AppData/Roaming"),
4763
+ "Claude/claude_desktop_config.json"
4764
+ );
4765
+ }
4766
+ return path.join(homedir, ".config/Claude/claude_desktop_config.json");
4767
+ case "claude-code":
4768
+ return path.join(homedir, ".claude/mcp.json");
4769
+ case "cursor":
4770
+ return path.join(homedir, ".cursor/mcp.json");
4771
+ case "continue":
4772
+ return path.join(homedir, ".continue/config.yaml");
4773
+ case "zed":
4774
+ return path.join(homedir, ".config/zed/settings.json");
4775
+ case "windsurf":
4776
+ return path.join(homedir, ".codeium/windsurf/mcp_config.json");
4777
+ case "codex":
4778
+ return path.join(homedir, ".codex/config.toml");
4779
+ default:
4780
+ return null;
4781
+ }
4782
+ }
4783
+
4784
+ // src/prompts/mcpPrompts.ts
4785
+ var MCP_PROMPT_CATEGORIES = [
4786
+ { id: "workspaces", label: "Workspaces" },
4787
+ { id: "collections", label: "Collections" },
4788
+ { id: "environments", label: "Environments" },
4789
+ { id: "execution", label: "Execution" },
4790
+ { id: "mocks", label: "Mocks" },
4791
+ { id: "auth", label: "Auth" },
4792
+ { id: "imports", label: "Imports" }
4793
+ ];
4794
+ var MCP_PROMPTS = [
4795
+ // ── Workspaces (multi-workspace discovery) ───────────────────────
4796
+ {
4797
+ id: "list-workspaces",
4798
+ text: "List every API Circle workspace I have and tell me which is active.",
4799
+ description: "Multi-workspace discovery \u2014 call this first when you are not sure which workspace to drive.",
4800
+ category: "workspaces",
4801
+ tools: ["workspace.list"]
4802
+ },
4803
+ {
4804
+ id: "scope-to-workspace",
4805
+ text: 'Read the requests in the "Petstore" workspace.',
4806
+ description: "Drill into a specific workspace by name; the AI passes `workspaceId` to scope reads.",
4807
+ category: "workspaces",
4808
+ tools: ["workspace.list", "workspace.read"]
4809
+ },
4810
+ {
4811
+ id: "multi-workspace-summary",
4812
+ text: "Across every workspace, count requests, folders, environments, and mocks. Give me one row per workspace.",
4813
+ description: "High-level summary across every registered workspace \u2014 pairs well with the multi-workspace envelope.",
4814
+ category: "workspaces",
4815
+ tools: ["workspace.list"]
4816
+ },
4817
+ // ── Collections (requests + folders in the active workspace) ─────
4818
+ {
4819
+ id: "list-requests",
4820
+ text: "List every request in my API Circle workspace grouped by folder.",
4821
+ description: "Quick overview of the request catalog so you know what is already wired up.",
4822
+ category: "collections",
4823
+ tools: ["workspace.read", "request.read", "folder.read"]
4824
+ },
4825
+ {
4826
+ id: "create-request",
4827
+ text: 'Add a new GET request named "Health check" pointing at https://example.com/healthz with an Accept: application/json header.',
4828
+ description: "Have the AI author a request and persist it to the workspace.",
4829
+ category: "collections",
4830
+ tools: ["request.create"]
4831
+ },
4832
+ {
4833
+ id: "update-request",
4834
+ text: 'Find the "Create user" request and change its method to POST and body to {"name": "Ada"}.',
4835
+ description: "Targeted edit by name \u2014 the AI looks it up, then updates.",
4836
+ category: "collections",
4837
+ tools: ["request.read", "request.update"]
4838
+ },
4839
+ {
4840
+ id: "organize-folders",
4841
+ text: 'Move every request whose URL contains /users into a folder named "User API".',
4842
+ description: "Bulk reorganisation via natural language.",
4843
+ category: "collections",
4844
+ tools: ["workspace.read", "folder.create", "request.update"]
4845
+ },
4846
+ // ── Environments ──────────────────────────────────────────────────
4847
+ {
4848
+ id: "env-list",
4849
+ text: "Show me all environments and which one is active.",
4850
+ description: "Inventory of envs + which is layered onto requests right now.",
4851
+ category: "environments",
4852
+ tools: ["environment.read"]
4853
+ },
4854
+ {
4855
+ id: "env-create",
4856
+ text: 'Create a "staging" environment with BASE_URL=https://staging.example.com and API_KEY={{SECRET:staging-key}}.',
4857
+ description: "Spin up a new env with both a plain variable and a secret reference.",
4858
+ category: "environments",
4859
+ tools: ["environment.create"]
4860
+ },
4861
+ {
4862
+ id: "env-switch",
4863
+ text: 'Switch the active environment to "production" and confirm by previewing the effective URL of the "Get user" request.',
4864
+ description: "Activate an env then verify variable interpolation.",
4865
+ category: "environments",
4866
+ tools: ["environment.update", "request.read"]
4867
+ },
4868
+ // ── Execution ─────────────────────────────────────────────────────
4869
+ {
4870
+ id: "run-request",
4871
+ text: 'Run the "Get user" request with userId=42 and show me the JSON response.',
4872
+ description: "One-shot execution with overridden context vars.",
4873
+ category: "execution",
4874
+ tools: ["request.execute"]
4875
+ },
4876
+ {
4877
+ id: "run-plan",
4878
+ text: 'Execute the "Regression smoke" plan and summarise which assertions failed.',
4879
+ description: "Drive a saved execution plan end-to-end.",
4880
+ category: "execution",
4881
+ tools: ["plan.read", "plan.execute"]
4882
+ },
4883
+ {
4884
+ id: "inspect-history",
4885
+ text: "Show me the last 5 requests I ran and their status codes.",
4886
+ description: "Quick triage when something just broke.",
4887
+ category: "execution",
4888
+ tools: ["history.read"]
4889
+ },
4890
+ // ── Mocks ─────────────────────────────────────────────────────────
4891
+ {
4892
+ id: "mock-start",
4893
+ text: 'Start the "Petstore" mock on port 4010 and tell me its base URL.',
4894
+ description: "Spin up a local mock so requests can hit it.",
4895
+ category: "mocks",
4896
+ tools: ["mock.list", "mock.start"]
4897
+ },
4898
+ {
4899
+ id: "mock-list",
4900
+ text: "List every running mock with its port, served spec, and request count.",
4901
+ description: "Status snapshot of every active mock runtime.",
4902
+ category: "mocks",
4903
+ tools: ["mock.list"]
4904
+ },
4905
+ {
4906
+ id: "mock-stop",
4907
+ text: "Stop every running mock.",
4908
+ description: "Clean shutdown of all mock servers in one go.",
4909
+ category: "mocks",
4910
+ tools: ["mock.stopAll"]
4911
+ },
4912
+ // ── Auth ──────────────────────────────────────────────────────────
4913
+ {
4914
+ id: "auth-set-bearer",
4915
+ text: 'Set the "Get user" request to use bearer auth with token={{ACCESS_TOKEN}}.',
4916
+ description: "Wire bearer auth onto a single request via env-var reference.",
4917
+ category: "auth",
4918
+ tools: ["request.update"]
4919
+ },
4920
+ {
4921
+ id: "auth-oauth2",
4922
+ text: 'Configure the "Create order" request to use OAuth2 client-credentials against https://auth.example.com/token with the "orders.write" scope.',
4923
+ description: "Full OAuth2 client-credentials wiring without leaving the chat.",
4924
+ category: "auth",
4925
+ tools: ["request.update"]
4926
+ },
4927
+ // ── Imports ───────────────────────────────────────────────────────
4928
+ {
4929
+ id: "import-openapi",
4930
+ text: "Import the OpenAPI spec at ./openapi.yaml and create one request per operation.",
4931
+ description: "Bulk import of a spec file from the workspace.",
4932
+ category: "imports",
4933
+ tools: ["import.openapi"]
4934
+ },
4935
+ {
4936
+ id: "import-curl",
4937
+ text: 'I am going to paste a cURL command \u2014 turn it into a saved request named "Webhook test".',
4938
+ description: "cURL \u2192 saved request with a name you control.",
4939
+ category: "imports",
4940
+ tools: ["import.curl"]
4941
+ }
4942
+ ];
4943
+
3156
4944
  // src/index.ts
4945
+ var import_streamableHttp = require("@modelcontextprotocol/sdk/server/streamableHttp.js");
4946
+ var import_stdio2 = require("@modelcontextprotocol/sdk/server/stdio.js");
3157
4947
  function createMcpServer(options) {
3158
4948
  const workspaces = options.workspaces ?? new SingleWorkspaceAdapter(
3159
4949
  options.workspace,
@@ -3172,15 +4962,23 @@ function createMcpServer(options) {
3172
4962
  }
3173
4963
  // Annotate the CommonJS export names for ESM import in node:
3174
4964
  0 && (module.exports = {
4965
+ AI_CLIENTS,
3175
4966
  FileBackedWorkspaceProvider,
4967
+ GitBackedWorkspaceProvider,
3176
4968
  InMemoryWorkspaceProvider,
3177
4969
  InProcessMockController,
4970
+ MCP_PROMPTS,
4971
+ MCP_PROMPT_CATEGORIES,
3178
4972
  McpHost,
3179
4973
  MultiWorkspaceProvider,
3180
4974
  SingleWorkspaceAdapter,
4975
+ StdioServerTransport,
4976
+ StreamableHTTPServerTransport,
3181
4977
  TOOL_REGISTRY,
3182
4978
  WorkspaceNotFoundError,
4979
+ buildSnippetVariants,
3183
4980
  createMcpServer,
3184
- getTool
4981
+ getTool,
4982
+ resolveAiClientConfigPath
3185
4983
  });
3186
4984
  //# sourceMappingURL=index.cjs.map