@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.js CHANGED
@@ -1,3 +1,8 @@
1
+ import {
2
+ MCP_PROMPTS,
3
+ MCP_PROMPT_CATEGORIES
4
+ } from "./chunk-N7LZVN3U.js";
5
+
1
6
  // src/host/McpHost.ts
2
7
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -6,9 +11,10 @@ import { z } from "zod";
6
11
  // package.json
7
12
  var package_default = {
8
13
  name: "@apicircle/mcp-server",
9
- version: "1.0.9",
14
+ version: "1.1.0",
10
15
  private: false,
11
16
  type: "module",
17
+ sideEffects: false,
12
18
  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.",
13
19
  keywords: [
14
20
  "apicircle",
@@ -51,7 +57,8 @@ var package_default = {
51
57
  "apicircle-mcp": "./dist/bin/mcp-server.cjs"
52
58
  },
53
59
  exports: {
54
- ".": "./src/index.ts"
60
+ ".": "./src/index.ts",
61
+ "./prompts": "./src/prompts/mcpPrompts.ts"
55
62
  },
56
63
  files: [
57
64
  "dist"
@@ -70,6 +77,16 @@ var package_default = {
70
77
  types: "./dist/index.d.cts",
71
78
  default: "./dist/index.cjs"
72
79
  }
80
+ },
81
+ "./prompts": {
82
+ import: {
83
+ types: "./dist/prompts/mcpPrompts.d.ts",
84
+ default: "./dist/prompts/mcpPrompts.js"
85
+ },
86
+ require: {
87
+ types: "./dist/prompts/mcpPrompts.d.cts",
88
+ default: "./dist/prompts/mcpPrompts.cjs"
89
+ }
73
90
  }
74
91
  }
75
92
  },
@@ -87,6 +104,7 @@ var package_default = {
87
104
  zod: "^3.23.0"
88
105
  },
89
106
  devDependencies: {
107
+ "@apicircle/git": "workspace:*",
90
108
  "@types/node": "^20.0.0",
91
109
  tsup: "^8.3.0",
92
110
  typescript: "^5.4.0",
@@ -559,6 +577,7 @@ var workspaceListTool = {
559
577
  import { z as z5 } from "zod";
560
578
  import { generateId as generateId2 } from "@apicircle/shared";
561
579
  import { parseApicircleEnvironmentDoc } from "@apicircle/core";
580
+ var FULL_REQUEST_AUTH = z5.object({ type: z5.string() }).passthrough();
562
581
  var HTTP_METHOD = z5.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]);
563
582
  var requestCreateTool = {
564
583
  name: "request.create",
@@ -646,16 +665,18 @@ var requestDeleteTool = {
646
665
  };
647
666
  var folderCreateTool = {
648
667
  name: "folder.create",
649
- description: "Create a folder under an optional parent folder.",
668
+ 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.",
650
669
  inputSchema: z5.object({
651
670
  name: z5.string().default("New folder"),
652
- parentId: z5.string().nullable().optional()
671
+ parentId: z5.string().nullable().optional(),
672
+ auth: FULL_REQUEST_AUTH.optional()
653
673
  }),
654
674
  async handler(input, ctx) {
655
675
  const folder = {
656
676
  id: generateId2(),
657
677
  name: input.name,
658
- parentId: input.parentId ?? null
678
+ parentId: input.parentId ?? null,
679
+ ...input.auth ? { auth: input.auth } : {}
659
680
  };
660
681
  const out = await ctx.workspace.apply({ kind: "folder.create", folder });
661
682
  return { id: folder.id, changedIds: out.changedIds };
@@ -679,18 +700,39 @@ var folderReadTool = {
679
700
  };
680
701
  var folderUpdateTool = {
681
702
  name: "folder.update",
682
- description: "Move a folder to a new parent (or to root with parentId: null).",
703
+ 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`).',
683
704
  inputSchema: z5.object({
684
705
  id: z5.string(),
685
- parentId: z5.string().nullable()
706
+ parentId: z5.string().nullable().optional(),
707
+ name: z5.string().optional(),
708
+ auth: FULL_REQUEST_AUTH.optional(),
709
+ clearAuth: z5.boolean().optional()
710
+ }).refine((v) => !(v.auth !== void 0 && v.clearAuth === true), {
711
+ message: "Pass either `auth` or `clearAuth: true`, not both."
686
712
  }),
687
713
  async handler(input, ctx) {
688
- const out = await ctx.workspace.apply({
689
- kind: "folder.move",
690
- id: input.id,
691
- newParentId: input.parentId
692
- });
693
- return { changedIds: out.changedIds };
714
+ const changedIds = [];
715
+ if (input.parentId !== void 0) {
716
+ const out = await ctx.workspace.apply({
717
+ kind: "folder.move",
718
+ id: input.id,
719
+ newParentId: input.parentId
720
+ });
721
+ changedIds.push(...out.changedIds);
722
+ }
723
+ const updatePatch = {};
724
+ if (input.name !== void 0) updatePatch.name = input.name;
725
+ if (input.auth !== void 0) updatePatch.auth = input.auth;
726
+ else if (input.clearAuth === true) updatePatch.auth = void 0;
727
+ if (input.name !== void 0 || input.auth !== void 0 || input.clearAuth === true) {
728
+ const out = await ctx.workspace.apply({
729
+ kind: "folder.update",
730
+ id: input.id,
731
+ patch: updatePatch
732
+ });
733
+ changedIds.push(...out.changedIds);
734
+ }
735
+ return { changedIds: Array.from(new Set(changedIds)) };
694
736
  }
695
737
  };
696
738
  var folderDeleteTool = {
@@ -1517,7 +1559,12 @@ var codebaseExtractCollectionTool = {
1517
1559
 
1518
1560
  // src/tools/prompt.ts
1519
1561
  import { z as z9 } from "zod";
1520
- import { generateId as generateId3, makeDefaultMockResponse, makeDefaultRequestSchema } from "@apicircle/shared";
1562
+ import {
1563
+ generateId as generateId3,
1564
+ makeDefaultMockResponse,
1565
+ makeDefaultRequestSchema,
1566
+ MAX_RESPONSE_MULTIPLIERS
1567
+ } from "@apicircle/shared";
1521
1568
  var promptCreateEnvironmentTool = {
1522
1569
  name: "prompt.create_environment",
1523
1570
  description: "Create a new environment from an LLM-shaped JSON envelope. The model produces { name, variables: [{ key, value, encrypted }] }; this tool validates and persists it.",
@@ -1670,7 +1717,9 @@ var CONDITION_CLAUSE_NL = z9.object({
1670
1717
  var RESPONSE_RULE_NL = z9.object({
1671
1718
  name: z9.string(),
1672
1719
  enabled: z9.boolean().default(true),
1673
- when: z9.array(CONDITION_CLAUSE_NL).default([]),
1720
+ // At least one clause — a no-condition rule is dead (the runtime engine skips
1721
+ // clause-less rules), so it never fires (mirrors the VS Code parser's reject).
1722
+ when: z9.array(CONDITION_CLAUSE_NL).min(1),
1674
1723
  response: z9.object({
1675
1724
  status: z9.number().int().min(100).max(599).default(200),
1676
1725
  jsonBody: z9.string().default("{}")
@@ -1720,7 +1769,7 @@ function buildEndpoint(input) {
1720
1769
  };
1721
1770
  const validationRules = input.validationRules ?? [];
1722
1771
  const responseRules = input.responseRules ?? [];
1723
- const multipliers = input.multipliers ?? [];
1772
+ const multipliers = (input.multipliers ?? []).slice(0, MAX_RESPONSE_MULTIPLIERS);
1724
1773
  if (multipliers.length > 0) {
1725
1774
  defaultResponse.multipliers = multipliers.map((m) => ({
1726
1775
  id: generateId3(),
@@ -1953,10 +2002,13 @@ var promptSetPlanVariablesTool = {
1953
2002
  };
1954
2003
  var promptCreateMockServerTool = {
1955
2004
  name: "prompt.create_mock_server",
1956
- 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.",
2005
+ 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.",
1957
2006
  inputSchema: z9.object({
1958
2007
  name: z9.string().min(1),
1959
- defaultPort: z9.number().int().positive().nullable().optional(),
2008
+ // Mirrors mock.create_manual / mock.start / mock.set_default_port:
2009
+ // reject out-of-range ports at the tool boundary so a prompt that
2010
+ // returns a stray port (1, 80, 999999) never leaks into the synced doc.
2011
+ defaultPort: z9.number().int().min(1024).max(65535).nullable().optional(),
1960
2012
  endpoints: z9.array(ENDPOINT_INPUT).default([])
1961
2013
  }),
1962
2014
  async handler(input, ctx) {
@@ -1983,7 +2035,7 @@ var promptCreateMockServerTool = {
1983
2035
  };
1984
2036
  var promptAddMockEndpointTool = {
1985
2037
  name: "prompt.add_mock_endpoint",
1986
- 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.",
2038
+ 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.",
1987
2039
  inputSchema: z9.object({
1988
2040
  mockId: z9.string(),
1989
2041
  method: HTTP_METHOD2,
@@ -2086,7 +2138,7 @@ var promptSetEndpointResponseRulesTool = {
2086
2138
  };
2087
2139
  var promptSetEndpointMultipliersTool = {
2088
2140
  name: "prompt.set_endpoint_multipliers",
2089
- 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.",
2141
+ 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.",
2090
2142
  inputSchema: z9.object({
2091
2143
  mockId: z9.string(),
2092
2144
  endpointId: z9.string(),
@@ -2097,6 +2149,9 @@ var promptSetEndpointMultipliersTool = {
2097
2149
  const mock = state.synced.mockServers[input.mockId];
2098
2150
  if (!mock) return { ok: false, error: "mock not found" };
2099
2151
  const multipliers = input.multipliers;
2152
+ if (multipliers.length > MAX_RESPONSE_MULTIPLIERS) {
2153
+ return { ok: false, error: "too many multipliers" };
2154
+ }
2100
2155
  const next = patchEndpoint(mock, input.endpointId, (e) => ({
2101
2156
  ...e,
2102
2157
  defaultResponse: {
@@ -2117,6 +2172,53 @@ var promptSetEndpointMultipliersTool = {
2117
2172
  return { ok: true, changedIds: out.changedIds };
2118
2173
  }
2119
2174
  };
2175
+ var PARAM_NL = z9.object({
2176
+ name: z9.string(),
2177
+ typeHint: z9.string().optional(),
2178
+ required: z9.boolean().optional(),
2179
+ description: z9.string().optional(),
2180
+ example: z9.string().optional()
2181
+ });
2182
+ var promptSetEndpointRequestSchemaTool = {
2183
+ name: "prompt.set_endpoint_request_schema",
2184
+ 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).",
2185
+ inputSchema: z9.object({
2186
+ mockId: z9.string(),
2187
+ endpointId: z9.string(),
2188
+ pathParams: z9.array(PARAM_NL).default([]),
2189
+ queryParams: z9.array(PARAM_NL).default([]),
2190
+ headers: z9.array(PARAM_NL).default([]),
2191
+ cookies: z9.array(PARAM_NL).default([]),
2192
+ body: z9.object({ description: z9.string().optional(), example: z9.string().optional() }).optional()
2193
+ }),
2194
+ async handler(input, ctx) {
2195
+ const state = await ctx.workspace.read();
2196
+ const mock = state.synced.mockServers[input.mockId];
2197
+ if (!mock) return { ok: false, error: "mock not found" };
2198
+ const toParams = (list) => list.map((p) => ({
2199
+ id: generateId3(),
2200
+ name: p.name,
2201
+ typeHint: p.typeHint,
2202
+ required: p.required,
2203
+ description: p.description,
2204
+ example: p.example
2205
+ }));
2206
+ const body = input.body && (input.body.description || input.body.example) ? input.body : void 0;
2207
+ const next = patchEndpoint(mock, input.endpointId, (e) => ({
2208
+ ...e,
2209
+ requestSchema: {
2210
+ pathParams: toParams(input.pathParams),
2211
+ queryParams: toParams(input.queryParams),
2212
+ headers: toParams(input.headers),
2213
+ cookies: toParams(input.cookies),
2214
+ body
2215
+ }
2216
+ }));
2217
+ if (!next) return { ok: false, error: "endpoint not found" };
2218
+ const out = await ctx.workspace.apply({ kind: "mock.upsert", mock: next });
2219
+ return { ok: true, changedIds: out.changedIds };
2220
+ }
2221
+ };
2120
2222
 
2121
2223
  // src/tools/globalAssets.ts
2122
2224
  import { z as z10 } from "zod";
@@ -2248,7 +2350,12 @@ var globalAssetsFilesDeleteTool = {
2248
2350
 
2249
2351
  // src/tools/mocks.ts
2250
2352
  import { z as z11 } from "zod";
2251
- import { generateId as generateId5, makeDefaultMockResponse as makeDefaultMockResponse2, makeDefaultRequestSchema as makeDefaultRequestSchema2 } from "@apicircle/shared";
2353
+ import {
2354
+ generateId as generateId5,
2355
+ makeDefaultMockResponse as makeDefaultMockResponse2,
2356
+ makeDefaultRequestSchema as makeDefaultRequestSchema2,
2357
+ MAX_RESPONSE_MULTIPLIERS as MAX_RESPONSE_MULTIPLIERS2
2358
+ } from "@apicircle/shared";
2252
2359
  import { parseSourceToEndpoints } from "@apicircle/mock-server-core";
2253
2360
  async function ingestSource(source, name) {
2254
2361
  const { endpoints, warnings } = await parseSourceToEndpoints(source);
@@ -2367,10 +2474,14 @@ var mockListTool = {
2367
2474
  };
2368
2475
  var mockStartTool = {
2369
2476
  name: "mock.start",
2370
- 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.",
2477
+ 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`.",
2371
2478
  inputSchema: z11.object({
2372
2479
  id: z11.string(),
2373
- port: z11.number().int().positive().optional()
2480
+ // Mirrors the UI 1024-65535 window: <1024 needs OS privileges, and
2481
+ // outside that window the runtime would throw INVALID_PORT anyway —
2482
+ // surface the rejection at the tool boundary so the client sees a
2483
+ // schema error instead of a runtime exception.
2484
+ port: z11.number().int().min(1024).max(65535).optional()
2374
2485
  }),
2375
2486
  async handler(input, ctx) {
2376
2487
  const state = await ctx.workspace.read();
@@ -2410,13 +2521,44 @@ var mockDeleteTool = {
2410
2521
  return { ok: true, changedIds: out.changedIds };
2411
2522
  }
2412
2523
  };
2524
+ var mockSetDefaultPortTool = {
2525
+ name: "mock.set_default_port",
2526
+ 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.",
2527
+ inputSchema: z11.object({
2528
+ id: z11.string(),
2529
+ defaultPort: z11.number().int().min(1024).max(65535).nullable()
2530
+ }),
2531
+ async handler(input, ctx) {
2532
+ const state = await ctx.workspace.read();
2533
+ const mock = state.synced.mockServers[input.id];
2534
+ if (!mock) return { ok: false, error: "mock not found" };
2535
+ if (mock.defaultPort === input.defaultPort) {
2536
+ return { ok: true, defaultPort: mock.defaultPort, changed: false };
2537
+ }
2538
+ const next = {
2539
+ ...mock,
2540
+ defaultPort: input.defaultPort,
2541
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2542
+ };
2543
+ const out = await ctx.workspace.apply({ kind: "mock.upsert", mock: next });
2544
+ return {
2545
+ ok: true,
2546
+ defaultPort: input.defaultPort,
2547
+ changed: true,
2548
+ changedIds: out.changedIds
2549
+ };
2550
+ }
2551
+ };
2413
2552
  var HTTP_METHOD3 = z11.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]);
2414
2553
  var mockCreateManualTool = {
2415
2554
  name: "mock.create_manual",
2416
2555
  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.",
2417
2556
  inputSchema: z11.object({
2418
2557
  name: z11.string().min(1),
2419
- defaultPort: z11.number().int().positive().nullable().optional()
2558
+ // Same 1024-65535 window as mock.start / mock.set_default_port — pin
2559
+ // the validation at the tool boundary so a malformed AI call doesn't
2560
+ // persist a port the runtime will later reject as INVALID_PORT.
2561
+ defaultPort: z11.number().int().min(1024).max(65535).nullable().optional()
2420
2562
  }),
2421
2563
  async handler(input, ctx) {
2422
2564
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -2618,7 +2760,10 @@ var RESPONSE_RULE = z11.object({
2618
2760
  id: z11.string().optional(),
2619
2761
  name: z11.string(),
2620
2762
  enabled: z11.boolean().default(true),
2621
- when: z11.array(CONDITION_CLAUSE).default([]),
2763
+ // At least one clause — a rule with no `when` is dead (the runtime engine
2764
+ // skips clause-less rules), so it can never fire. The VS Code endpoint parser
2765
+ // rejects the same shape, keeping the surfaces consistent.
2766
+ when: z11.array(CONDITION_CLAUSE).min(1),
2622
2767
  response: z11.object({
2623
2768
  status: z11.number().int().min(100).max(599).default(200),
2624
2769
  jsonBody: z11.string().default("{}")
@@ -2720,9 +2865,61 @@ var mockSetResponseRulesTool = {
2720
2865
  return { ok: true, changedIds: out.changedIds };
2721
2866
  }
2722
2867
  };
2868
+ var PARAM = z11.object({
2869
+ id: z11.string().optional(),
2870
+ name: z11.string(),
2871
+ typeHint: z11.string().optional(),
2872
+ required: z11.boolean().optional(),
2873
+ description: z11.string().optional(),
2874
+ example: z11.string().optional()
2875
+ });
2876
+ var REQUEST_SCHEMA_BODY = z11.object({ description: z11.string().optional(), example: z11.string().optional() }).optional();
2877
+ function normalizeSchemaBody(body) {
2878
+ if (!body || !body.description && !body.example) return void 0;
2879
+ return body;
2880
+ }
2881
+ var mockSetRequestSchemaTool = {
2882
+ name: "mock.set_request_schema",
2883
+ 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.",
2884
+ inputSchema: z11.object({
2885
+ mockId: z11.string(),
2886
+ endpointId: z11.string(),
2887
+ pathParams: z11.array(PARAM).default([]),
2888
+ queryParams: z11.array(PARAM).default([]),
2889
+ headers: z11.array(PARAM).default([]),
2890
+ cookies: z11.array(PARAM).default([]),
2891
+ body: REQUEST_SCHEMA_BODY
2892
+ }),
2893
+ async handler(input, ctx) {
2894
+ const state = await ctx.workspace.read();
2895
+ const mock = state.synced.mockServers[input.mockId];
2896
+ if (!mock) return { ok: false, error: "mock not found" };
2897
+ const toParams = (list) => list.map((p) => ({
2898
+ id: p.id ?? generateId5(),
2899
+ name: p.name,
2900
+ typeHint: p.typeHint,
2901
+ required: p.required,
2902
+ description: p.description,
2903
+ example: p.example
2904
+ }));
2905
+ const next = patchEndpoint2(mock, input.endpointId, (e) => ({
2906
+ ...e,
2907
+ requestSchema: {
2908
+ pathParams: toParams(input.pathParams),
2909
+ queryParams: toParams(input.queryParams),
2910
+ headers: toParams(input.headers),
2911
+ cookies: toParams(input.cookies),
2912
+ body: normalizeSchemaBody(input.body)
2913
+ }
2914
+ }));
2915
+ if (!next) return { ok: false, error: "endpoint not found" };
2916
+ const out = await ctx.workspace.apply({ kind: "mock.upsert", mock: next });
2917
+ return { ok: true, changedIds: out.changedIds };
2918
+ }
2919
+ };
2723
2920
  var mockSetMultipliersTool = {
2724
2921
  name: "mock.set_multipliers",
2725
- 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.",
2922
+ 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.",
2726
2923
  inputSchema: z11.object({
2727
2924
  mockId: z11.string(),
2728
2925
  endpointId: z11.string(),
@@ -2733,6 +2930,9 @@ var mockSetMultipliersTool = {
2733
2930
  const mock = state.synced.mockServers[input.mockId];
2734
2931
  if (!mock) return { ok: false, error: "mock not found" };
2735
2932
  const multipliers = input.multipliers;
2933
+ if (multipliers.length > MAX_RESPONSE_MULTIPLIERS2) {
2934
+ return { ok: false, error: "too many multipliers" };
2935
+ }
2736
2936
  const next = patchEndpoint2(mock, input.endpointId, (e) => ({
2737
2937
  ...e,
2738
2938
  defaultResponse: {
@@ -2754,6 +2954,1295 @@ var mockSetMultipliersTool = {
2754
2954
  }
2755
2955
  };
2756
2956
 
2957
+ // src/tools/releases.ts
2958
+ import { z as z12 } from "zod";
2959
+ import { buildReleaseEntry, sortVersionsDesc } from "@apicircle/core";
2960
+ var releaseListTool = {
2961
+ name: "release.list",
2962
+ description: "List this workspace's published releases (newest first) with their notes, snapshot fingerprint, and deprecated / withdrawn flags. Returns the current version too.",
2963
+ inputSchema: z12.object({}),
2964
+ async handler(_input, ctx) {
2965
+ const state = await ctx.workspace.read();
2966
+ const ledger = state.synced.releases.self;
2967
+ if (!ledger) {
2968
+ return { currentVersion: null, count: 0, versions: [] };
2969
+ }
2970
+ const order = sortVersionsDesc(ledger.versions.map((v) => v.version));
2971
+ const byVersion = new Map(ledger.versions.map((v) => [v.version, v]));
2972
+ const versions = order.map((v) => byVersion.get(v)).filter((v) => v !== void 0).map((v) => ({
2973
+ version: v.version,
2974
+ publishedAt: v.publishedAt,
2975
+ notes: v.notes,
2976
+ workspaceSnapshot: v.workspaceSnapshot,
2977
+ deprecated: v.deprecated,
2978
+ yanked: v.yanked,
2979
+ ...v.sha ? { sha: v.sha } : {},
2980
+ ...v.tagName ? { tagName: v.tagName } : {}
2981
+ }));
2982
+ return { currentVersion: ledger.currentVersion, count: versions.length, versions };
2983
+ }
2984
+ };
2985
+ var releasePublishTool = {
2986
+ name: "release.publish",
2987
+ 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.",
2988
+ inputSchema: z12.object({
2989
+ version: z12.string().min(1).describe('Semantic version, e.g. "1.2.0".'),
2990
+ notes: z12.string().default("").describe("Markdown release notes."),
2991
+ sha: z12.string().optional().describe("Optional source commit SHA for bookkeeping."),
2992
+ tagName: z12.string().optional().describe("Optional git tag name for bookkeeping.")
2993
+ }),
2994
+ async handler(input, ctx) {
2995
+ const state = await ctx.workspace.read();
2996
+ let entry;
2997
+ try {
2998
+ entry = await buildReleaseEntry(state.synced, {
2999
+ version: input.version,
3000
+ notes: input.notes,
3001
+ sha: input.sha,
3002
+ tagName: input.tagName
3003
+ });
3004
+ } catch (err) {
3005
+ return { ok: false, error: err instanceof Error ? err.message : "release.publish failed" };
3006
+ }
3007
+ try {
3008
+ const out = await ctx.workspace.apply({ kind: "release.publish", entry });
3009
+ const after = (await ctx.workspace.read()).synced.releases.self;
3010
+ return {
3011
+ ok: true,
3012
+ version: entry.version,
3013
+ currentVersion: after?.currentVersion ?? entry.version,
3014
+ workspaceSnapshot: entry.workspaceSnapshot,
3015
+ changedIds: out.changedIds
3016
+ };
3017
+ } catch (err) {
3018
+ return { ok: false, error: err instanceof Error ? err.message : "release.publish failed" };
3019
+ }
3020
+ }
3021
+ };
3022
+ var releaseDeprecateTool = {
3023
+ name: "release.deprecate",
3024
+ 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.",
3025
+ inputSchema: z12.object({ version: z12.string().min(1) }),
3026
+ async handler(input, ctx) {
3027
+ try {
3028
+ const out = await ctx.workspace.apply({ kind: "release.deprecate", version: input.version });
3029
+ return { ok: true, version: input.version, changedIds: out.changedIds };
3030
+ } catch (err) {
3031
+ return { ok: false, error: err instanceof Error ? err.message : "release.deprecate failed" };
3032
+ }
3033
+ }
3034
+ };
3035
+ var releaseYankTool = {
3036
+ name: "release.yank",
3037
+ 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.",
3038
+ inputSchema: z12.object({ version: z12.string().min(1) }),
3039
+ async handler(input, ctx) {
3040
+ try {
3041
+ const out = await ctx.workspace.apply({ kind: "release.yank", version: input.version });
3042
+ return { ok: true, version: input.version, changedIds: out.changedIds };
3043
+ } catch (err) {
3044
+ return { ok: false, error: err instanceof Error ? err.message : "release.yank failed" };
3045
+ }
3046
+ }
3047
+ };
3048
+
3049
+ // src/tools/linkedWorkspaces.ts
3050
+ import { z as z13 } from "zod";
3051
+ function summarize(link, currentVersion) {
3052
+ return {
3053
+ id: link.id,
3054
+ name: link.name,
3055
+ kind: link.kind,
3056
+ description: link.description,
3057
+ source: link.source,
3058
+ scope: link.scope,
3059
+ pinnedVersion: link.pinnedVersion,
3060
+ requiredSecretKeyIds: link.requiredSecretKeyIds,
3061
+ marketplace: link.marketplace,
3062
+ cachedCurrentVersion: currentVersion
3063
+ };
3064
+ }
3065
+ var linkedListTool = {
3066
+ name: "linked.list",
3067
+ 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.",
3068
+ inputSchema: z13.object({}),
3069
+ async handler(_input, ctx) {
3070
+ const state = await ctx.workspace.read();
3071
+ const links = Object.values(state.synced.linkedWorkspaces);
3072
+ return {
3073
+ count: links.length,
3074
+ links: links.map(
3075
+ (l) => summarize(l, state.synced.releases.perLink[l.id]?.currentVersion ?? null)
3076
+ )
3077
+ };
3078
+ }
3079
+ };
3080
+ var linkedGetTool = {
3081
+ name: "linked.get",
3082
+ description: "Read one linked workspace by id, including its cached release ledger (the versions available to pin to).",
3083
+ inputSchema: z13.object({ id: z13.string() }),
3084
+ async handler(input, ctx) {
3085
+ const state = await ctx.workspace.read();
3086
+ const link = state.synced.linkedWorkspaces[input.id];
3087
+ if (!link) return { ok: false, error: `Linked workspace ${input.id} not found` };
3088
+ const ledger = state.synced.releases.perLink[input.id] ?? null;
3089
+ return {
3090
+ ok: true,
3091
+ link: summarize(link, ledger?.currentVersion ?? null),
3092
+ ledger
3093
+ };
3094
+ }
3095
+ };
3096
+ var linkedSetConfigTool = {
3097
+ name: "linked.set_config",
3098
+ 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.",
3099
+ inputSchema: z13.object({
3100
+ id: z13.string(),
3101
+ name: z13.string().optional(),
3102
+ description: z13.string().optional(),
3103
+ pinnedVersion: z13.string().nullable().optional().describe("null = unpin (track source HEAD)."),
3104
+ scope: z13.array(z13.enum(["collections", "environments"])).optional(),
3105
+ sessionMode: z13.enum(["workspace", "dedicated"]).optional(),
3106
+ requiredSecretKeyIds: z13.array(z13.string()).optional(),
3107
+ marketplace: z13.object({
3108
+ listedAs: z13.string(),
3109
+ tags: z13.array(z13.string()),
3110
+ summary: z13.string()
3111
+ }).nullable().optional().describe("null = clear marketplace metadata.")
3112
+ }),
3113
+ async handler(input, ctx) {
3114
+ const state = await ctx.workspace.read();
3115
+ const link = state.synced.linkedWorkspaces[input.id];
3116
+ if (!link) return { ok: false, error: `Linked workspace ${input.id} not found` };
3117
+ if (input.pinnedVersion !== void 0 && input.pinnedVersion !== null) {
3118
+ const cached = state.synced.releases.perLink[input.id]?.versions ?? [];
3119
+ if (!cached.some((v) => v.version === input.pinnedVersion)) {
3120
+ return {
3121
+ ok: false,
3122
+ error: `Version ${input.pinnedVersion} is not in the cached ledger \u2014 refresh the link first`
3123
+ };
3124
+ }
3125
+ }
3126
+ const next = {
3127
+ ...link,
3128
+ ...input.name !== void 0 ? { name: input.name } : {},
3129
+ ...input.description !== void 0 ? { description: input.description } : {},
3130
+ ...input.pinnedVersion !== void 0 ? { pinnedVersion: input.pinnedVersion } : {},
3131
+ ...input.scope !== void 0 ? { scope: input.scope } : {},
3132
+ ...input.requiredSecretKeyIds !== void 0 ? { requiredSecretKeyIds: input.requiredSecretKeyIds } : {},
3133
+ ...input.sessionMode !== void 0 ? { source: { ...link.source, sessionMode: input.sessionMode } } : {}
3134
+ };
3135
+ if (input.marketplace !== void 0) {
3136
+ if (input.marketplace === null) {
3137
+ delete next.marketplace;
3138
+ } else {
3139
+ next.marketplace = input.marketplace;
3140
+ }
3141
+ }
3142
+ const out = await ctx.workspace.apply({ kind: "linkedWorkspace.upsert", link: next });
3143
+ return {
3144
+ ok: true,
3145
+ changedIds: out.changedIds,
3146
+ link: summarize(next, state.synced.releases.perLink[input.id]?.currentVersion ?? null)
3147
+ };
3148
+ }
3149
+ };
3150
+ var linkedUnlinkTool = {
3151
+ name: "linked.unlink",
3152
+ 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.",
3153
+ inputSchema: z13.object({ id: z13.string() }),
3154
+ async handler(input, ctx) {
3155
+ const state = await ctx.workspace.read();
3156
+ if (!state.synced.linkedWorkspaces[input.id]) {
3157
+ return { ok: false, error: `Linked workspace ${input.id} not found` };
3158
+ }
3159
+ const out = await ctx.workspace.apply({ kind: "linkedWorkspace.remove", id: input.id });
3160
+ return { ok: true, changedIds: out.changedIds };
3161
+ }
3162
+ };
3163
+
3164
+ // src/tools/githubOps.ts
3165
+ import { z as z14 } from "zod";
3166
+ import {
3167
+ fetchRemoteWorkspaceJson,
3168
+ parseLinkedWorkspaceJson,
3169
+ buildLinkedSnapshot,
3170
+ ledgerFromProbe
3171
+ } from "@apicircle/core";
3172
+ import { generateId as generateId6 } from "@apicircle/shared";
3173
+
3174
+ // ../git/src/github/errors.ts
3175
+ var GitHubError = class extends Error {
3176
+ constructor(message, status, body) {
3177
+ super(message);
3178
+ this.status = status;
3179
+ this.body = body;
3180
+ this.name = "GitHubError";
3181
+ }
3182
+ status;
3183
+ body;
3184
+ };
3185
+ var MissingScopeError = class extends GitHubError {
3186
+ /** Scope strings the API said are missing, e.g. ['pull_request']. */
3187
+ missingScopes;
3188
+ /** Scope strings the token currently grants, parsed from x-oauth-scopes. */
3189
+ grantedScopes;
3190
+ constructor(message, status, missingScopes, grantedScopes) {
3191
+ super(message, status);
3192
+ this.name = "MissingScopeError";
3193
+ this.missingScopes = missingScopes;
3194
+ this.grantedScopes = grantedScopes;
3195
+ }
3196
+ };
3197
+ var RateLimitedError = class extends GitHubError {
3198
+ /** Unix timestamp (ms) when the rate-limit window resets. */
3199
+ resetAtMs;
3200
+ constructor(message, status, resetAtMs) {
3201
+ super(message, status);
3202
+ this.name = "RateLimitedError";
3203
+ this.resetAtMs = resetAtMs;
3204
+ }
3205
+ };
3206
+ var UnauthorizedError = class extends GitHubError {
3207
+ constructor(message, status) {
3208
+ super(message, status);
3209
+ this.name = "UnauthorizedError";
3210
+ }
3211
+ };
3212
+ var TimeoutError = class extends GitHubError {
3213
+ /** Timeout that fired, in ms. Useful for the UI message. */
3214
+ timeoutMs;
3215
+ constructor(message, timeoutMs) {
3216
+ super(message, 0);
3217
+ this.name = "TimeoutError";
3218
+ this.timeoutMs = timeoutMs;
3219
+ }
3220
+ };
3221
+
3222
+ // ../git/src/github/api.ts
3223
+ var API_BASE = "https://api.github.com";
3224
+ var LOGIN_BASE = "https://github.com";
3225
+ var DEFAULT_TIMEOUT_MS = 15e3;
3226
+ var GitHubClient = class {
3227
+ baseUrl;
3228
+ loginBaseUrl;
3229
+ fetchImpl;
3230
+ timeoutMs;
3231
+ constructor(opts = {}) {
3232
+ this.baseUrl = opts.baseUrl ?? API_BASE;
3233
+ this.loginBaseUrl = (opts.loginBaseUrl ?? LOGIN_BASE).replace(/\/$/, "");
3234
+ this.fetchImpl = opts.fetchImpl ?? globalThis.fetch.bind(globalThis);
3235
+ this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
3236
+ }
3237
+ /**
3238
+ * Fetch the authenticated user. Doubles as a "verify token" probe — used
3239
+ * by the Secret Vault Sessions tab to refresh the granted-scopes list.
3240
+ */
3241
+ async getViewer(token, opts = {}) {
3242
+ const { json, response } = await this.call(token, "/user", opts);
3243
+ return {
3244
+ viewer: {
3245
+ login: json.login,
3246
+ id: json.id,
3247
+ name: json.name ?? null,
3248
+ avatarUrl: json.avatar_url ?? null
3249
+ },
3250
+ scopes: parseScopes(response.headers)
3251
+ };
3252
+ }
3253
+ /**
3254
+ * List repositories the authenticated user can access. Used by the repo
3255
+ * picker. Capped at 100 sorted by recent push; users with thousands of
3256
+ * repos can paginate later.
3257
+ */
3258
+ async listAccessibleRepos(token, opts = {}) {
3259
+ const { json } = await this.call(
3260
+ token,
3261
+ "/user/repos?per_page=100&sort=pushed&affiliation=owner,collaborator,organization_member",
3262
+ opts
3263
+ );
3264
+ return json.map(normalizeRepo);
3265
+ }
3266
+ /**
3267
+ * Fetch a specific repo. Validates the user-supplied owner/name pair
3268
+ * exists + is accessible, and exposes the default branch.
3269
+ */
3270
+ async getRepo(token, owner, name, opts = {}) {
3271
+ const { json } = await this.call(
3272
+ token,
3273
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`,
3274
+ opts
3275
+ );
3276
+ return normalizeRepo(json);
3277
+ }
3278
+ /**
3279
+ * Read the head SHA of a branch. Used to seed a new working branch from
3280
+ * main before any edits land.
3281
+ */
3282
+ async getBranchHead(token, owner, name, branch, opts = {}) {
3283
+ const { json } = await this.call(
3284
+ token,
3285
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/branches/${encodeURIComponent(branch)}`,
3286
+ opts
3287
+ );
3288
+ return { name: json.name, commitSha: json.commit.sha };
3289
+ }
3290
+ /**
3291
+ * List branches on a repo. Used by the Link Workspace repo-browser to
3292
+ * populate the branch dropdown after the user picks a repo. Capped at
3293
+ * 100 (GitHub's max page size); repos with more branches paginate.
3294
+ */
3295
+ async listBranches(token, owner, name, opts = {}) {
3296
+ const { json } = await this.call(
3297
+ token,
3298
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/branches?per_page=100`,
3299
+ opts
3300
+ );
3301
+ return json.map((b) => ({ name: b.name, commitSha: b.commit.sha }));
3302
+ }
3303
+ /**
3304
+ * Create a new branch ref pointing at `sha`. The auto-branch flow calls
3305
+ * this with the head SHA from `getBranchHead(main)`.
3306
+ *
3307
+ * GitHub returns 422 with "Reference already exists" when the branch
3308
+ * already exists; that surfaces as a GitHubError(422) so the UI can
3309
+ * prompt for a different name.
3310
+ */
3311
+ async createBranch(token, owner, name, branchName, sha, opts = {}) {
3312
+ const { json } = await this.call(
3313
+ token,
3314
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/git/refs`,
3315
+ {
3316
+ ...opts,
3317
+ method: "POST",
3318
+ body: { ref: `refs/heads/${branchName}`, sha },
3319
+ requiredScopes: ["repo"]
3320
+ }
3321
+ );
3322
+ return { name: branchName, commitSha: json.object.sha };
3323
+ }
3324
+ /**
3325
+ * Read a branch ref's current commit SHA. Used at the start of push-to-
3326
+ * save to find the parent commit before building the new tree.
3327
+ */
3328
+ async getRef(token, owner, name, branch, opts = {}) {
3329
+ const { json } = await this.call(
3330
+ token,
3331
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/git/refs/heads/${encodeURIComponent(branch)}`,
3332
+ opts
3333
+ );
3334
+ return { ref: json.ref, sha: json.object.sha };
3335
+ }
3336
+ /**
3337
+ * Read a commit's tree SHA. Used so the new tree can be built `base_tree`
3338
+ * — every path we don't override is inherited from the parent.
3339
+ */
3340
+ async getCommit(token, owner, name, sha, opts = {}) {
3341
+ const { json } = await this.call(
3342
+ token,
3343
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/git/commits/${encodeURIComponent(sha)}`,
3344
+ opts
3345
+ );
3346
+ return {
3347
+ sha: json.sha,
3348
+ treeSha: json.tree.sha,
3349
+ message: json.message
3350
+ };
3351
+ }
3352
+ /**
3353
+ * Upload a blob to the repo and return its SHA. Used by push-to-save
3354
+ * (P4.3b) for binary attachments — text files go straight into a tree
3355
+ * entry's `content`, but binary bytes have to go through a blob first.
3356
+ *
3357
+ * `content` is base64 when `encoding === 'base64'`. GitHub stores blobs
3358
+ * deduplicated by their git-sha1 (not our sha256), so re-uploading the
3359
+ * same bytes is cheap on their side; we save a roundtrip locally by
3360
+ * tracking lastPushedBlobSha per slot in a future revision.
3361
+ */
3362
+ async createBlob(token, owner, name, args, opts = {}) {
3363
+ const { json } = await this.call(
3364
+ token,
3365
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/git/blobs`,
3366
+ {
3367
+ ...opts,
3368
+ method: "POST",
3369
+ body: { content: args.content, encoding: args.encoding },
3370
+ requiredScopes: ["repo"]
3371
+ }
3372
+ );
3373
+ return { sha: json.sha, size: json.size ?? 0 };
3374
+ }
3375
+ /**
3376
+ * Build a new tree from `entries`, layered over `baseTreeSha`. Entries
3377
+ * with `content` are inlined (text path); entries with a pre-uploaded
3378
+ * `sha` reference an existing blob (binary path — used by attachments).
3379
+ */
3380
+ async createTree(token, owner, name, args, opts = {}) {
3381
+ const tree = args.entries.map((e) => ({
3382
+ path: e.path,
3383
+ mode: e.mode ?? "100644",
3384
+ type: e.type ?? "blob",
3385
+ ...e.content !== void 0 ? { content: e.content } : {},
3386
+ ...e.sha !== void 0 ? { sha: e.sha } : {}
3387
+ }));
3388
+ const { json } = await this.call(
3389
+ token,
3390
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/git/trees`,
3391
+ {
3392
+ ...opts,
3393
+ method: "POST",
3394
+ body: { base_tree: args.baseTreeSha, tree },
3395
+ requiredScopes: ["repo"]
3396
+ }
3397
+ );
3398
+ return { sha: json.sha };
3399
+ }
3400
+ /**
3401
+ * Create a new commit object pointing at the given tree, with the given
3402
+ * parents. Returns the new commit's SHA + the tree it points at.
3403
+ */
3404
+ async createCommit(token, owner, name, args, opts = {}) {
3405
+ const { json } = await this.call(
3406
+ token,
3407
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/git/commits`,
3408
+ {
3409
+ ...opts,
3410
+ method: "POST",
3411
+ body: {
3412
+ message: args.message,
3413
+ tree: args.treeSha,
3414
+ parents: args.parents
3415
+ },
3416
+ requiredScopes: ["repo"]
3417
+ }
3418
+ );
3419
+ return { sha: json.sha, treeSha: json.tree.sha };
3420
+ }
3421
+ /**
3422
+ * Fast-forward a branch ref to a new commit SHA. Pass `force: true` to
3423
+ * skip the FF check (we don't — push-to-save is always FF over the ref
3424
+ * we just read with getRef()).
3425
+ */
3426
+ async updateRef(token, owner, name, args, opts = {}) {
3427
+ const { json } = await this.call(
3428
+ token,
3429
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/git/refs/heads/${encodeURIComponent(args.branch)}`,
3430
+ {
3431
+ ...opts,
3432
+ method: "PATCH",
3433
+ body: { sha: args.sha, force: args.force ?? false },
3434
+ requiredScopes: ["repo"]
3435
+ }
3436
+ );
3437
+ return { ref: json.ref, sha: json.object.sha };
3438
+ }
3439
+ /**
3440
+ * Search GitHub for public API Circle workspaces. Appends
3441
+ * `topic:apicircle` to the user-supplied query so only repos carrying
3442
+ * the `apicircle` topic — the topic the Releases & Topics dialog
3443
+ * locks onto every workspace repo — surface in results. GitHub
3444
+ * matches the bare query against repository name, description, and
3445
+ * topics, so category words like `payments` narrow the marketplace by
3446
+ * topic. An empty query lists every public API Circle workspace. Top
3447
+ * 30 results. Token is optional — anonymous browsing is supported
3448
+ * (lower GitHub rate limits apply); pass a PAT when one is available
3449
+ * to lift them. `sort` controls ordering: omit for GitHub's
3450
+ * best-match relevance, or pass `'stars'` / `'updated'`.
3451
+ */
3452
+ async searchMarketplaceRepos(token, query, opts = {}) {
3453
+ const { sort, ...callOpts } = opts;
3454
+ const fullQuery = `${query.trim()} topic:apicircle`.trim();
3455
+ const sortParam = sort ? `&sort=${sort}&order=desc` : "";
3456
+ const path2 = `/search/repositories?q=${encodeURIComponent(fullQuery)}&per_page=30${sortParam}`;
3457
+ const { json } = await this.call(token, path2, callOpts);
3458
+ const items = json.items ?? [];
3459
+ return items.map(normalizeMarketplaceRepo);
3460
+ }
3461
+ /**
3462
+ * Start GitHub's OAuth Device Flow. Returns a user-facing code the
3463
+ * user types into github.com/login/device + a device_code the app
3464
+ * polls with. Pure browser-safe: no client_secret involved (device
3465
+ * flow is the only OAuth path GitHub supports for public clients).
3466
+ *
3467
+ * Requires the OAuth App to have "Enable Device Flow" turned on in
3468
+ * its GitHub settings — surface 400 with `not_supported` to the user
3469
+ * if the App owner hasn't done that yet.
3470
+ */
3471
+ async startDeviceFlow(clientId, scope, opts = {}) {
3472
+ const url = `${this.loginBaseUrl}/login/device/code`;
3473
+ const response = await this.fetchImpl(url, {
3474
+ method: "POST",
3475
+ headers: { Accept: "application/json", "Content-Type": "application/json" },
3476
+ body: JSON.stringify({ client_id: clientId, scope }),
3477
+ signal: opts.signal
3478
+ });
3479
+ if (!response.ok) {
3480
+ throw new GitHubError(
3481
+ `Device-flow start failed: HTTP ${response.status}`,
3482
+ response.status,
3483
+ {}
3484
+ );
3485
+ }
3486
+ const json = await response.json();
3487
+ if (json.error) {
3488
+ throw new GitHubError(json.error_description ?? json.error, 400, json);
3489
+ }
3490
+ return {
3491
+ deviceCode: json.device_code,
3492
+ userCode: json.user_code,
3493
+ verificationUri: json.verification_uri,
3494
+ expiresIn: json.expires_in,
3495
+ interval: json.interval
3496
+ };
3497
+ }
3498
+ /**
3499
+ * Poll for the access token after the user has authorized the device
3500
+ * code. GitHub returns `authorization_pending` until the user
3501
+ * completes the flow, `slow_down` if we polled too fast, then a real
3502
+ * token. Caller wraps this in a polling loop bounded by `expiresIn`.
3503
+ */
3504
+ async pollDeviceToken(clientId, deviceCode, opts = {}) {
3505
+ const url = `${this.loginBaseUrl}/login/oauth/access_token`;
3506
+ const response = await this.fetchImpl(url, {
3507
+ method: "POST",
3508
+ headers: { Accept: "application/json", "Content-Type": "application/json" },
3509
+ body: JSON.stringify({
3510
+ client_id: clientId,
3511
+ device_code: deviceCode,
3512
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code"
3513
+ }),
3514
+ signal: opts.signal
3515
+ });
3516
+ const json = await response.json();
3517
+ if (json.access_token) {
3518
+ return {
3519
+ kind: "granted",
3520
+ accessToken: json.access_token,
3521
+ tokenType: json.token_type ?? "bearer",
3522
+ scope: json.scope ?? ""
3523
+ };
3524
+ }
3525
+ if (json.error === "authorization_pending") return { kind: "pending", slowDown: false };
3526
+ if (json.error === "slow_down") return { kind: "pending", slowDown: true };
3527
+ if (json.error === "expired_token") return { kind: "expired" };
3528
+ if (json.error === "access_denied")
3529
+ return { kind: "denied", reason: json.error_description ?? "User denied authorization" };
3530
+ throw new GitHubError(
3531
+ json.error_description ?? json.error ?? "Device-token poll failed",
3532
+ response.status,
3533
+ json
3534
+ );
3535
+ }
3536
+ /**
3537
+ * Create a lightweight Git tag (a ref under `refs/tags/<name>`) on the
3538
+ * given commit SHA. Used by the publish-release flow when the user
3539
+ * opts in to "Create Git tag v<x.y.z>". Returns the resolved ref.
3540
+ *
3541
+ * GitHub returns 422 with "Reference already exists" when the tag is
3542
+ * a duplicate; that surfaces as a GitHubError(422) so the UI can warn
3543
+ * the user without ever overwriting an existing tag.
3544
+ */
3545
+ async createTag(token, owner, name, args, opts = {}) {
3546
+ const { json } = await this.call(
3547
+ token,
3548
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/git/refs`,
3549
+ {
3550
+ ...opts,
3551
+ method: "POST",
3552
+ body: { ref: `refs/tags/${args.tagName}`, sha: args.sha },
3553
+ requiredScopes: ["repo"]
3554
+ }
3555
+ );
3556
+ return { ref: json.ref, sha: json.object.sha };
3557
+ }
3558
+ /**
3559
+ * Compare two commits. Returns the relationship classification GitHub
3560
+ * gives us: `ahead` (head is descendant of base), `behind` (base is
3561
+ * descendant of head), `identical`, or `diverged` (the two histories
3562
+ * share a base but neither contains the other — typical of a force-push
3563
+ * that rewrote history under us).
3564
+ *
3565
+ * Used by the refresh path so we never silently 3-way-merge across a
3566
+ * history rewrite — divergence steers the user through an explicit
3567
+ * "history rewritten" modal instead of corrupting local state.
3568
+ */
3569
+ async compareCommits(token, owner, name, base, head, opts = {}) {
3570
+ const { json } = await this.call(
3571
+ token,
3572
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/compare/${encodeURIComponent(
3573
+ base
3574
+ )}...${encodeURIComponent(head)}`,
3575
+ { ...opts, requiredScopes: ["repo"] }
3576
+ );
3577
+ return {
3578
+ status: json.status,
3579
+ aheadBy: json.ahead_by,
3580
+ behindBy: json.behind_by
3581
+ };
3582
+ }
3583
+ /**
3584
+ * Is `ancestor` reachable from `descendant`? Thin wrapper around
3585
+ * `compareCommits` — "ahead" or "identical" means yes; "behind" or
3586
+ * "diverged" means the histories don't fit, so the answer is no.
3587
+ */
3588
+ async isAncestor(token, owner, name, ancestor, descendant, opts = {}) {
3589
+ if (ancestor === descendant) return true;
3590
+ const cmp = await this.compareCommits(token, owner, name, ancestor, descendant, opts);
3591
+ return cmp.status === "ahead" || cmp.status === "identical";
3592
+ }
3593
+ /**
3594
+ * Create a GitHub Release pointing at an existing tag. Used by the
3595
+ * publish-release flow when the user opts in to "Create GitHub
3596
+ * Release". Returns the release's HTML URL so the UI can show a
3597
+ * "Released — view on GitHub" link.
3598
+ *
3599
+ * Pass `prerelease: true` for semver pre-release identifiers (e.g.
3600
+ * `1.0.0-rc.1`); GitHub's Releases UI flags those distinctly.
3601
+ */
3602
+ async createRelease(token, owner, name, args, opts = {}) {
3603
+ const { json } = await this.call(token, `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/releases`, {
3604
+ ...opts,
3605
+ method: "POST",
3606
+ body: {
3607
+ tag_name: args.tagName,
3608
+ name: args.releaseName ?? args.tagName,
3609
+ body: args.body ?? "",
3610
+ draft: args.draft ?? false,
3611
+ prerelease: args.prerelease ?? false
3612
+ },
3613
+ requiredScopes: ["repo"]
3614
+ });
3615
+ return { id: json.id, htmlUrl: json.html_url, tagName: json.tag_name };
3616
+ }
3617
+ /**
3618
+ * Read a tag ref's current commit SHA. Used by the Release & topics
3619
+ * modal to detect whether a tag with the chosen name already exists
3620
+ * (so the UI can surface an "Override existing tag" toggle instead of
3621
+ * silently 422'ing through createTag).
3622
+ *
3623
+ * Returns `null` when the tag doesn't exist (404). Other failures
3624
+ * surface as typed errors.
3625
+ */
3626
+ async getTagSha(token, owner, name, tagName, opts = {}) {
3627
+ try {
3628
+ const { json } = await this.call(
3629
+ token,
3630
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/git/refs/tags/${encodeURIComponent(tagName)}`,
3631
+ opts
3632
+ );
3633
+ return json.object.sha;
3634
+ } catch (err) {
3635
+ if (err instanceof GitHubError && err.status === 404) return null;
3636
+ throw err;
3637
+ }
3638
+ }
3639
+ /**
3640
+ * Delete a ref. Used to support the "Override existing tag" path on
3641
+ * the Release & topics modal — we delete the existing tag ref, then
3642
+ * createTag against the new SHA. (GitHub doesn't have a single
3643
+ * "force-update tag" endpoint via the simple refs API.)
3644
+ *
3645
+ * `ref` is the bare suffix, e.g. `tags/v1.0.0` or `heads/feature-x`.
3646
+ */
3647
+ async deleteRef(token, owner, name, ref, opts = {}) {
3648
+ await this.call(
3649
+ token,
3650
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/git/refs/${ref.split("/").map(encodeURIComponent).join("/")}`,
3651
+ {
3652
+ ...opts,
3653
+ method: "DELETE",
3654
+ requiredScopes: ["repo"]
3655
+ }
3656
+ );
3657
+ }
3658
+ /**
3659
+ * Read the repo's current topic list. Topics drive marketplace
3660
+ * discoverability — public API Circle workspaces include `apicircle`
3661
+ * plus user-chosen category topics.
3662
+ *
3663
+ * Note: GitHub's topics API uses a custom Accept header, but we treat
3664
+ * that as transport detail; the `application/vnd.github.mercy-preview+json`
3665
+ * preview is now stable so the default Accept works.
3666
+ */
3667
+ async listRepoTopics(token, owner, name, opts = {}) {
3668
+ const { json } = await this.call(
3669
+ token,
3670
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/topics`,
3671
+ opts
3672
+ );
3673
+ return Array.isArray(json.names) ? json.names : [];
3674
+ }
3675
+ /**
3676
+ * Replace the repo's full topic list. GitHub's `PUT /topics` endpoint
3677
+ * is a full replace (not a merge), so the caller must pass the
3678
+ * complete desired list. Caps at 20 topics; each must match
3679
+ * `^[a-z0-9][a-z0-9-]*$` and be ≤ 50 chars (GitHub enforces this with
3680
+ * a 422). Returns the persisted list.
3681
+ */
3682
+ async setRepoTopics(token, owner, name, topics, opts = {}) {
3683
+ const { json } = await this.call(
3684
+ token,
3685
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/topics`,
3686
+ {
3687
+ ...opts,
3688
+ method: "PUT",
3689
+ body: { names: topics },
3690
+ requiredScopes: ["repo"]
3691
+ }
3692
+ );
3693
+ return Array.isArray(json.names) ? json.names : [];
3694
+ }
3695
+ /**
3696
+ * Fetch a single file's contents from a branch / commit. Returns
3697
+ * `null` when GitHub answers 404 (file simply doesn't exist on that
3698
+ * ref — the common case for the very first pull). Other failures
3699
+ * surface as the usual typed errors.
3700
+ *
3701
+ * Used by the refresh flow to read remote `workspace.json` so the
3702
+ * 3-way diff can compare it against the local doc.
3703
+ */
3704
+ async getContents(token, owner, name, path2, ref, opts = {}) {
3705
+ const query = `?ref=${encodeURIComponent(ref)}`;
3706
+ try {
3707
+ const { json } = await this.call(
3708
+ token,
3709
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/contents/${path2.split("/").map(encodeURIComponent).join("/")}${query}`,
3710
+ opts
3711
+ );
3712
+ if (Array.isArray(json) || json.type !== "file") {
3713
+ throw new GitHubError(`Path ${path2} is not a file`, 422, json);
3714
+ }
3715
+ const cleaned = json.content.replace(/\n/g, "");
3716
+ const decoded = decodeBase64Utf8(cleaned);
3717
+ return { content: decoded, sha: json.sha, path: json.path, size: json.size };
3718
+ } catch (err) {
3719
+ if (err instanceof GitHubError && err.status === 404) return null;
3720
+ throw err;
3721
+ }
3722
+ }
3723
+ /**
3724
+ * Create or update a file via the Contents API. The killer feature here
3725
+ * vs. the git-data flow (createBlob → createTree → createCommit →
3726
+ * updateRef) is that this works on **truly empty repos**: GitHub's git
3727
+ * database isn't initialized until the first commit lands, so all the
3728
+ * `/git/*` endpoints reject with 409 "Git Repository is empty" — but
3729
+ * `PUT /contents/{path}` atomically initializes the database with a
3730
+ * single-file commit on the supplied branch (defaulting to the repo's
3731
+ * default branch).
3732
+ *
3733
+ * Used by the seed-initial-commit flow to bootstrap a freshly-created
3734
+ * empty repo with a scaffold `workspace.json`.
3735
+ *
3736
+ * `contentBase64` must already be base64-encoded — caller chooses the
3737
+ * encoder (TextEncoder for UTF-8 strings, raw bytes for binaries).
3738
+ */
3739
+ async putContents(token, owner, name, path2, args, opts = {}) {
3740
+ const body = {
3741
+ message: args.message,
3742
+ content: args.contentBase64
3743
+ };
3744
+ if (args.branch) body.branch = args.branch;
3745
+ if (args.sha) body.sha = args.sha;
3746
+ const { json } = await this.call(
3747
+ token,
3748
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/contents/${path2.split("/").map(encodeURIComponent).join("/")}`,
3749
+ {
3750
+ ...opts,
3751
+ method: "PUT",
3752
+ body,
3753
+ requiredScopes: ["repo"]
3754
+ }
3755
+ );
3756
+ return { commitSha: json.commit.sha, contentSha: json.content.sha };
3757
+ }
3758
+ /**
3759
+ * Same as `getContents` but returns the raw bytes instead of UTF-8
3760
+ * decoding the file. Used by the refresh flow to pull
3761
+ * `.apicircle/workspace-<id>/attachments/<slotId>` blobs into local IDB without
3762
+ * mangling binary data through TextDecoder.
3763
+ */
3764
+ async getBinaryContents(token, owner, name, path2, ref, opts = {}) {
3765
+ const query = `?ref=${encodeURIComponent(ref)}`;
3766
+ try {
3767
+ const { json } = await this.call(
3768
+ token,
3769
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/contents/${path2.split("/").map(encodeURIComponent).join("/")}${query}`,
3770
+ opts
3771
+ );
3772
+ if (Array.isArray(json) || json.type !== "file") {
3773
+ throw new GitHubError(`Path ${path2} is not a file`, 422, json);
3774
+ }
3775
+ const cleaned = json.content.replace(/\n/g, "");
3776
+ const bytes = decodeBase64Bytes(cleaned);
3777
+ return { bytes, sha: json.sha, path: json.path, size: json.size };
3778
+ } catch (err) {
3779
+ if (err instanceof GitHubError && err.status === 404) return null;
3780
+ throw err;
3781
+ }
3782
+ }
3783
+ /**
3784
+ * Open a pull request from `head` (the working branch) into `base` (the
3785
+ * repo's default branch). PR creation needs the `pull_request` scope on
3786
+ * top of `repo`; missing-scope errors flow through MissingScopeError so
3787
+ * the UI can prompt the user to update the token without losing branch
3788
+ * state (Plan §3.7).
3789
+ *
3790
+ * GitHub returns 422 when:
3791
+ * - head/base are equal (nothing to merge)
3792
+ * - a PR already exists between this head and base
3793
+ * - the head branch doesn't exist
3794
+ * All three surface as a plain GitHubError(422); the UI message is
3795
+ * picked up from response.body.message.
3796
+ */
3797
+ /**
3798
+ * Fetch a single pull request by number. Used by the refresh flow to
3799
+ * detect whether a previously-opened PR has been merged on GitHub —
3800
+ * `merged: true` is what triggers the working-branch retirement path.
3801
+ *
3802
+ * Returns `null` on 404 (PR was deleted or never existed at this number);
3803
+ * other failures surface as the usual typed errors.
3804
+ */
3805
+ async getPullRequest(token, owner, name, number, opts = {}) {
3806
+ try {
3807
+ const { json } = await this.call(
3808
+ token,
3809
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pulls/${number}`,
3810
+ opts
3811
+ );
3812
+ return {
3813
+ number: json.number,
3814
+ htmlUrl: json.html_url,
3815
+ state: json.state,
3816
+ merged: json.merged === true
3817
+ };
3818
+ } catch (err) {
3819
+ if (err instanceof GitHubError && err.status === 404) return null;
3820
+ throw err;
3821
+ }
3822
+ }
3823
+ /**
3824
+ * List pull requests on a repo. The capability-probe path uses this with
3825
+ * `perPage: 1` to determine whether the token can read PRs (and, by
3826
+ * extension on classic PATs, whether it can also create them).
3827
+ *
3828
+ * Caller declares `requiredScopes` to surface a `MissingScopeError` on
3829
+ * 403, so the capability probe can recognise the missing-scope case
3830
+ * cleanly vs. transient 5xx/network failures.
3831
+ */
3832
+ async listPullRequests(token, owner, name, args = {}, opts = {}) {
3833
+ const params = new URLSearchParams();
3834
+ params.set("per_page", String(args.perPage ?? 30));
3835
+ if (args.state) params.set("state", args.state);
3836
+ const { json } = await this.call(
3837
+ token,
3838
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pulls?${params.toString()}`,
3839
+ {
3840
+ ...opts,
3841
+ requiredScopes: ["repo", "pull_request"]
3842
+ }
3843
+ );
3844
+ return json.map((pr) => ({
3845
+ number: pr.number,
3846
+ htmlUrl: pr.html_url,
3847
+ state: pr.state,
3848
+ title: pr.title
3849
+ }));
3850
+ }
3851
+ async createPullRequest(token, owner, name, args, opts = {}) {
3852
+ const { json } = await this.call(
3853
+ token,
3854
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pulls`,
3855
+ {
3856
+ ...opts,
3857
+ method: "POST",
3858
+ body: {
3859
+ title: args.title,
3860
+ body: args.body,
3861
+ head: args.head,
3862
+ base: args.base,
3863
+ draft: args.draft ?? false
3864
+ },
3865
+ requiredScopes: ["repo", "pull_request"]
3866
+ }
3867
+ );
3868
+ return {
3869
+ number: json.number,
3870
+ htmlUrl: json.html_url,
3871
+ state: json.state,
3872
+ title: json.title
3873
+ };
3874
+ }
3875
+ // --- low-level call ----------------------------------------------------
3876
+ async call(token, path2, opts = {}) {
3877
+ const url = path2.startsWith("http") ? path2 : `${this.baseUrl}${path2}`;
3878
+ const controller = new AbortController();
3879
+ const onExternalAbort = () => controller.abort(opts.signal.reason);
3880
+ if (opts.signal) {
3881
+ if (opts.signal.aborted) controller.abort(opts.signal.reason);
3882
+ else opts.signal.addEventListener("abort", onExternalAbort, { once: true });
3883
+ }
3884
+ const timeoutHandle = setTimeout(
3885
+ () => controller.abort(new Error(`GitHub request timed out after ${this.timeoutMs}ms`)),
3886
+ this.timeoutMs
3887
+ );
3888
+ let response;
3889
+ let timedOut = false;
3890
+ try {
3891
+ response = await this.fetchImpl(url, {
3892
+ method: opts.method ?? "GET",
3893
+ headers: {
3894
+ Accept: "application/vnd.github+json",
3895
+ "X-GitHub-Api-Version": "2022-11-28",
3896
+ ...token ? { Authorization: `Bearer ${token}` } : {},
3897
+ ...opts.body !== void 0 ? { "Content-Type": "application/json" } : {}
3898
+ },
3899
+ cache: "no-store",
3900
+ body: opts.body !== void 0 ? JSON.stringify(opts.body) : void 0,
3901
+ signal: controller.signal
3902
+ });
3903
+ } catch (err) {
3904
+ const isAbort = err instanceof DOMException && err.name === "AbortError";
3905
+ const callerAborted = opts.signal?.aborted ?? false;
3906
+ if (isAbort && !callerAborted) {
3907
+ timedOut = true;
3908
+ throw new TimeoutError(
3909
+ `GitHub request timed out after ${this.timeoutMs}ms. The write may have partially landed \u2014 refresh before retrying.`,
3910
+ this.timeoutMs
3911
+ );
3912
+ }
3913
+ throw err;
3914
+ } finally {
3915
+ clearTimeout(timeoutHandle);
3916
+ if (opts.signal) opts.signal.removeEventListener("abort", onExternalAbort);
3917
+ void timedOut;
3918
+ }
3919
+ if (response.ok) {
3920
+ if (response.status === 204 || response.status === 205) {
3921
+ return { json: {}, response };
3922
+ }
3923
+ const json = await response.json();
3924
+ return { json, response };
3925
+ }
3926
+ const errBody = await safeReadJson(response);
3927
+ throw classifyError(response, errBody, opts.requiredScopes ?? []);
3928
+ }
3929
+ };
3930
+ function normalizeMarketplaceRepo(raw) {
3931
+ return {
3932
+ fullName: raw.full_name,
3933
+ owner: raw.owner.login,
3934
+ name: raw.name,
3935
+ description: raw.description ?? "",
3936
+ topics: raw.topics ?? [],
3937
+ stargazers: raw.stargazers_count ?? 0,
3938
+ defaultBranch: raw.default_branch ?? "main"
3939
+ };
3940
+ }
3941
+ function decodeBase64Utf8(b64) {
3942
+ return new TextDecoder("utf-8").decode(decodeBase64Bytes(b64));
3943
+ }
3944
+ function decodeBase64Bytes(b64) {
3945
+ const binary = atob(b64);
3946
+ const bytes = new Uint8Array(binary.length);
3947
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
3948
+ return bytes;
3949
+ }
3950
+ function normalizeRepo(raw) {
3951
+ const visibility = raw.visibility ?? (raw.private === true ? "private" : "public");
3952
+ const isPrivate = raw.private ?? visibility !== "public";
3953
+ const pushable = raw.permissions?.push === true || raw.permissions?.admin === true;
3954
+ return {
3955
+ fullName: raw.full_name,
3956
+ owner: raw.owner.login,
3957
+ name: raw.name,
3958
+ defaultBranch: raw.default_branch,
3959
+ visibility,
3960
+ isPrivate,
3961
+ pushable
3962
+ };
3963
+ }
3964
+ function parseScopes(headers) {
3965
+ const raw = headers.get("x-oauth-scopes") ?? "";
3966
+ const granted = raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
3967
+ const acceptedHeader = headers.get("x-accepted-oauth-scopes") ?? "";
3968
+ const acceptedRequired = acceptedHeader.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
3969
+ return acceptedRequired.length > 0 ? { granted, acceptedRequired } : { granted };
3970
+ }
3971
+ function classifyError(response, body, callerRequiredScopes) {
3972
+ const message = extractMessage(body) ?? response.statusText;
3973
+ const status = response.status;
3974
+ if (status === 401) {
3975
+ return new UnauthorizedError(message || "Unauthorized \u2014 token rejected", status);
3976
+ }
3977
+ if (status === 403) {
3978
+ const remaining = response.headers.get("x-ratelimit-remaining");
3979
+ const reset = response.headers.get("x-ratelimit-reset");
3980
+ if (remaining === "0" && reset) {
3981
+ const resetAtMs = Number(reset) * 1e3;
3982
+ const deltaMs = Math.max(0, resetAtMs - Date.now());
3983
+ const totalSeconds = Math.ceil(deltaMs / 1e3);
3984
+ const human = totalSeconds < 60 ? `${totalSeconds}s` : totalSeconds < 3600 ? `${Math.ceil(totalSeconds / 60)} min` : `${Math.ceil(totalSeconds / 3600)} h`;
3985
+ return new RateLimitedError(
3986
+ `GitHub rate limit reached. Resets in ${human} (at ${new Date(resetAtMs).toISOString()}).`,
3987
+ status,
3988
+ resetAtMs
3989
+ );
3990
+ }
3991
+ const accepted = (response.headers.get("x-accepted-oauth-scopes") ?? "").split(",").map((s) => s.trim()).filter((s) => s.length > 0);
3992
+ const granted = (response.headers.get("x-oauth-scopes") ?? "").split(",").map((s) => s.trim()).filter((s) => s.length > 0);
3993
+ const missing = accepted.length > 0 ? accepted.filter((s) => !granted.includes(s)) : callerRequiredScopes.filter((s) => !granted.includes(s));
3994
+ if (missing.length > 0) {
3995
+ return new MissingScopeError(
3996
+ `GitHub denied this action: missing scopes ${missing.join(", ")}.`,
3997
+ status,
3998
+ missing,
3999
+ granted
4000
+ );
4001
+ }
4002
+ }
4003
+ return new GitHubError(message || "GitHub API call failed", status, body);
4004
+ }
4005
+ function extractMessage(body) {
4006
+ if (typeof body === "object" && body !== null && "message" in body) {
4007
+ const m = body.message;
4008
+ if (typeof m === "string") return m;
4009
+ }
4010
+ return null;
4011
+ }
4012
+ async function safeReadJson(response) {
4013
+ try {
4014
+ return await response.json();
4015
+ } catch {
4016
+ return null;
4017
+ }
4018
+ }
4019
+
4020
+ // src/tools/githubOps.ts
4021
+ function resolveToken(input) {
4022
+ const t = (input ?? process.env.GITHUB_TOKEN ?? "").trim();
4023
+ return t;
4024
+ }
4025
+ var TOKEN_HELP = "Pass `token`, or set the GITHUB_TOKEN env var on the MCP process.";
4026
+ var linkedLinkTool = {
4027
+ name: "linked.link",
4028
+ 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,
4029
+ inputSchema: z14.object({
4030
+ repoFullName: z14.string().describe("owner/name of the source workspace repo."),
4031
+ branch: z14.string().default("main"),
4032
+ pinnedVersion: z14.string().nullable().optional().describe("null/omitted = source current version."),
4033
+ kind: z14.enum(["private", "public"]).default("private"),
4034
+ token: z14.string().optional()
4035
+ }),
4036
+ async handler(input, ctx) {
4037
+ const token = resolveToken(input.token);
4038
+ const repoFullName = input.repoFullName.trim();
4039
+ if (!repoFullName.includes("/")) return { ok: false, error: "repoFullName must be owner/name" };
4040
+ if (input.kind === "private" && !token)
4041
+ return { ok: false, error: `A token is required for private repos. ${TOKEN_HELP}` };
4042
+ const [owner, name] = repoFullName.split("/", 2);
4043
+ const state = await ctx.workspace.read();
4044
+ const dup = Object.values(state.synced.linkedWorkspaces).find(
4045
+ (l) => l.source.repoFullName === repoFullName && l.source.branch === input.branch
4046
+ );
4047
+ if (dup)
4048
+ return { ok: false, error: `Already linked to ${repoFullName}@${input.branch} (${dup.id})` };
4049
+ const client = new GitHubClient();
4050
+ let result;
4051
+ try {
4052
+ result = await fetchRemoteWorkspaceJson(async (p) => {
4053
+ const f = await client.getContents(token, owner, name, p, input.branch);
4054
+ return f?.content ?? null;
4055
+ });
4056
+ } catch (e) {
4057
+ return {
4058
+ ok: false,
4059
+ error: e instanceof GitHubError ? e.message : e instanceof Error ? e.message : "fetch failed"
4060
+ };
4061
+ }
4062
+ if ("error" in result)
4063
+ return { ok: false, error: `${repoFullName}@${input.branch}: ${result.error}` };
4064
+ const probe = parseLinkedWorkspaceJson(result.content);
4065
+ const ledger = ledgerFromProbe(probe);
4066
+ const link = {
4067
+ id: generateId6(),
4068
+ kind: input.kind,
4069
+ name: repoFullName,
4070
+ sourceWorkspaceId: result.workspaceId,
4071
+ source: { provider: "github", repoFullName, branch: input.branch, sessionMode: "workspace" },
4072
+ scope: ["collections", "environments"],
4073
+ pinnedVersion: input.pinnedVersion ?? ledger.currentVersion,
4074
+ updatePolicy: "manual",
4075
+ linkedAt: (/* @__PURE__ */ new Date()).toISOString(),
4076
+ requiredSecretKeyIds: probe.secretKeys ? Object.keys(probe.secretKeys) : []
4077
+ };
4078
+ const snapshot = buildLinkedSnapshot(probe, link) ?? void 0;
4079
+ const out = await ctx.workspace.apply({
4080
+ kind: "linkedWorkspace.upsert",
4081
+ link,
4082
+ ledger,
4083
+ ...snapshot ? { snapshot } : {}
4084
+ });
4085
+ return { ok: true, id: link.id, pinnedVersion: link.pinnedVersion, changedIds: out.changedIds };
4086
+ }
4087
+ };
4088
+ var linkedRefreshTool = {
4089
+ name: "linked.refresh",
4090
+ description: "Re-pull a linked workspace's cached release ledger (+ bootstrap snapshot if missing) from GitHub. " + TOKEN_HELP,
4091
+ inputSchema: z14.object({ id: z14.string(), token: z14.string().optional() }),
4092
+ async handler(input, ctx) {
4093
+ const state = await ctx.workspace.read();
4094
+ const link = state.synced.linkedWorkspaces[input.id];
4095
+ if (!link) return { ok: false, error: `Linked workspace ${input.id} not found` };
4096
+ const token = resolveToken(input.token);
4097
+ if (link.kind === "private" && !token)
4098
+ return { ok: false, error: `A token is required for private links. ${TOKEN_HELP}` };
4099
+ const [owner, name] = link.source.repoFullName.split("/", 2);
4100
+ const client = new GitHubClient();
4101
+ let result;
4102
+ try {
4103
+ result = await fetchRemoteWorkspaceJson(async (p) => {
4104
+ const f = await client.getContents(token, owner, name, p, link.source.branch);
4105
+ return f?.content ?? null;
4106
+ });
4107
+ } catch (e) {
4108
+ return { ok: false, error: e instanceof Error ? e.message : "fetch failed" };
4109
+ }
4110
+ if ("error" in result)
4111
+ return {
4112
+ ok: false,
4113
+ error: `${link.source.repoFullName}@${link.source.branch}: ${result.error}`
4114
+ };
4115
+ const probe = parseLinkedWorkspaceJson(result.content);
4116
+ const ledger = ledgerFromProbe(probe);
4117
+ const needsSnapshot = !state.local.linkedCollections[input.id];
4118
+ const snapshot = needsSnapshot ? buildLinkedSnapshot(probe, link) ?? void 0 : void 0;
4119
+ await ctx.workspace.apply({
4120
+ kind: "linkedWorkspace.upsert",
4121
+ link,
4122
+ ledger,
4123
+ ...snapshot ? { snapshot } : {}
4124
+ });
4125
+ return {
4126
+ ok: true,
4127
+ currentVersion: ledger.currentVersion,
4128
+ versionCount: ledger.versions.length
4129
+ };
4130
+ }
4131
+ };
4132
+ var releaseTagTool = {
4133
+ name: "release.tag",
4134
+ 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,
4135
+ inputSchema: z14.object({
4136
+ owner: z14.string(),
4137
+ name: z14.string(),
4138
+ version: z14.string(),
4139
+ createGitHubRelease: z14.boolean().default(false),
4140
+ notes: z14.string().optional(),
4141
+ overrideExisting: z14.boolean().default(false),
4142
+ token: z14.string().optional()
4143
+ }),
4144
+ async handler(input, _ctx) {
4145
+ const token = resolveToken(input.token);
4146
+ if (!token) return { ok: false, error: `A token is required to tag. ${TOKEN_HELP}` };
4147
+ const client = new GitHubClient();
4148
+ const tagName = `v${input.version.replace(/^v/, "")}`;
4149
+ try {
4150
+ const repo = await client.getRepo(token, input.owner, input.name);
4151
+ const ref = await client.getRef(token, input.owner, input.name, repo.defaultBranch);
4152
+ const existing = await client.getTagSha(token, input.owner, input.name, tagName);
4153
+ if (existing !== null) {
4154
+ if (!input.overrideExisting) {
4155
+ return {
4156
+ ok: false,
4157
+ error: `Tag ${tagName} already exists at ${existing.slice(0, 7)}. Pass overrideExisting:true to replace.`
4158
+ };
4159
+ }
4160
+ await client.deleteRef(token, input.owner, input.name, `tags/${tagName}`);
4161
+ }
4162
+ await client.createTag(token, input.owner, input.name, { tagName, sha: ref.sha });
4163
+ let releaseUrl;
4164
+ if (input.createGitHubRelease) {
4165
+ const release = await client.createRelease(token, input.owner, input.name, {
4166
+ tagName,
4167
+ releaseName: tagName,
4168
+ body: input.notes ?? ""
4169
+ });
4170
+ releaseUrl = release.htmlUrl;
4171
+ }
4172
+ return {
4173
+ ok: true,
4174
+ tagName,
4175
+ sha: ref.sha,
4176
+ branch: repo.defaultBranch,
4177
+ ...releaseUrl ? { releaseUrl } : {}
4178
+ };
4179
+ } catch (e) {
4180
+ return { ok: false, error: e instanceof Error ? e.message : "tag failed" };
4181
+ }
4182
+ }
4183
+ };
4184
+ var marketplaceSearchTool = {
4185
+ name: "marketplace.search",
4186
+ 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,
4187
+ inputSchema: z14.object({
4188
+ query: z14.string().default("").describe("Search query \u2014 matches repo name, description, and topics. Empty = browse all."),
4189
+ sort: z14.enum(["best-match", "stars", "updated"]).default("best-match").describe(
4190
+ "Sort order: best-match (default relevance), stars (most starred first), updated (recently pushed first)."
4191
+ ),
4192
+ token: z14.string().optional()
4193
+ }),
4194
+ async handler(input, _ctx) {
4195
+ const token = resolveToken(input.token) || null;
4196
+ const client = new GitHubClient();
4197
+ try {
4198
+ const repos = await client.searchMarketplaceRepos(token, input.query, {
4199
+ sort: input.sort === "best-match" ? void 0 : input.sort
4200
+ });
4201
+ return { ok: true, count: repos.length, results: repos };
4202
+ } catch (e) {
4203
+ return {
4204
+ ok: false,
4205
+ error: e instanceof GitHubError ? e.message : e instanceof Error ? e.message : "search failed"
4206
+ };
4207
+ }
4208
+ }
4209
+ };
4210
+ var TOPIC_RE = /^[a-z0-9][a-z0-9-]*$/;
4211
+ var repoSetTopicsTool = {
4212
+ name: "repo.set_topics",
4213
+ 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,
4214
+ inputSchema: z14.object({
4215
+ owner: z14.string(),
4216
+ name: z14.string(),
4217
+ topics: z14.array(z14.string()),
4218
+ token: z14.string().optional()
4219
+ }),
4220
+ async handler(input, _ctx) {
4221
+ const token = resolveToken(input.token);
4222
+ if (!token) return { ok: false, error: `A token is required to set topics. ${TOKEN_HELP}` };
4223
+ const requested = input.topics;
4224
+ const normalized = Array.from(
4225
+ /* @__PURE__ */ new Set(["apicircle", ...requested.map((t) => t.trim().toLowerCase()).filter(Boolean)])
4226
+ );
4227
+ for (const t of normalized) {
4228
+ if (!TOPIC_RE.test(t))
4229
+ return {
4230
+ ok: false,
4231
+ error: `Invalid topic "${t}" \u2014 lowercase letters/digits/"-", starting with a letter or digit.`
4232
+ };
4233
+ if (t.length > 50) return { ok: false, error: `Topic "${t}" exceeds 50 characters.` };
4234
+ }
4235
+ if (normalized.length > 20) return { ok: false, error: "GitHub allows at most 20 topics." };
4236
+ const client = new GitHubClient();
4237
+ try {
4238
+ const saved = await client.setRepoTopics(token, input.owner, input.name, normalized);
4239
+ return { ok: true, topics: saved };
4240
+ } catch (e) {
4241
+ return { ok: false, error: e instanceof Error ? e.message : "set topics failed" };
4242
+ }
4243
+ }
4244
+ };
4245
+
2757
4246
  // src/tools/registry.ts
2758
4247
  var TOOL_REGISTRY = [
2759
4248
  importCurlTool,
@@ -2814,6 +4303,7 @@ var TOOL_REGISTRY = [
2814
4303
  promptSetEndpointValidationRulesTool,
2815
4304
  promptSetEndpointResponseRulesTool,
2816
4305
  promptSetEndpointMultipliersTool,
4306
+ promptSetEndpointRequestSchemaTool,
2817
4307
  globalAssetsFilesListTool,
2818
4308
  globalAssetsFilesCreateTool,
2819
4309
  globalAssetsFilesUpdateTool,
@@ -2833,7 +4323,22 @@ var TOOL_REGISTRY = [
2833
4323
  mockSetValidationRulesTool,
2834
4324
  mockSetResponseRulesTool,
2835
4325
  mockSetMultipliersTool,
2836
- mockImportPostmanMockCollectionTool
4326
+ mockSetRequestSchemaTool,
4327
+ mockSetDefaultPortTool,
4328
+ mockImportPostmanMockCollectionTool,
4329
+ releaseListTool,
4330
+ releasePublishTool,
4331
+ releaseDeprecateTool,
4332
+ releaseYankTool,
4333
+ linkedListTool,
4334
+ linkedGetTool,
4335
+ linkedSetConfigTool,
4336
+ linkedUnlinkTool,
4337
+ linkedLinkTool,
4338
+ linkedRefreshTool,
4339
+ releaseTagTool,
4340
+ repoSetTopicsTool,
4341
+ marketplaceSearchTool
2837
4342
  ];
2838
4343
  function getTool(name) {
2839
4344
  return TOOL_REGISTRY.find((t) => t.name === name);
@@ -2851,22 +4356,24 @@ var SingleWorkspaceAdapter = class {
2851
4356
  displayName;
2852
4357
  async list() {
2853
4358
  const state = await this.provider.read();
2854
- const id = state.synced.workspaceId;
4359
+ const s = state.synced;
4360
+ const id = s.workspaceId ?? this.workspaceId ?? "unknown";
2855
4361
  this.workspaceId = id;
4362
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2856
4363
  return [
2857
4364
  {
2858
4365
  id,
2859
4366
  name: this.displayName,
2860
4367
  isActive: true,
2861
- createdAt: state.synced.meta.createdAt,
2862
- lastOpenedAt: state.synced.meta.updatedAt,
2863
- counts: {
2864
- requests: Object.keys(state.synced.collections.requests).length,
2865
- folders: Object.keys(state.synced.collections.folders).length,
2866
- environments: Object.keys(state.synced.environments.items).length,
2867
- mockServers: Object.keys(state.synced.mockServers ?? {}).length,
2868
- plans: Object.keys(state.synced.executionPlans ?? {}).length
2869
- }
4368
+ createdAt: s.meta?.createdAt ?? now,
4369
+ lastOpenedAt: s.meta?.updatedAt ?? now,
4370
+ counts: s.collections ? {
4371
+ requests: Object.keys(s.collections.requests ?? {}).length,
4372
+ folders: Object.keys(s.collections.folders ?? {}).length,
4373
+ environments: Object.keys(s.environments?.items ?? {}).length,
4374
+ mockServers: Object.keys(s.mockServers ?? {}).length,
4375
+ plans: Object.keys(s.executionPlans ?? {}).length
4376
+ } : null
2870
4377
  }
2871
4378
  ];
2872
4379
  }
@@ -2956,6 +4463,52 @@ var FileBackedWorkspaceProvider = class {
2956
4463
  }
2957
4464
  };
2958
4465
 
4466
+ // src/providers/GitBackedWorkspaceProvider.ts
4467
+ import { applyMutation as applyMutation3 } from "@apicircle/core";
4468
+ import { loadFromFile as loadFromFile2, saveToFile as saveToFile2, withWorkspace as withWorkspace2 } from "@apicircle/core/workspace/file-backed";
4469
+ var GIT_SYNCED_FILENAME = "workspace.json";
4470
+ var GitBackedWorkspaceProvider = class {
4471
+ constructor(dir) {
4472
+ this.dir = dir;
4473
+ }
4474
+ dir;
4475
+ async read() {
4476
+ const out = await loadFromFile2(this.dir, {
4477
+ syncedFilename: GIT_SYNCED_FILENAME,
4478
+ allowMissing: true
4479
+ });
4480
+ if (!out) {
4481
+ throw new Error(
4482
+ `No workspace found at ${this.dir}. Expected .apicircle/registry.json and .apicircle/workspace-<id>/workspace.json in the repo.`
4483
+ );
4484
+ }
4485
+ return out;
4486
+ }
4487
+ async apply(patch) {
4488
+ let captured = null;
4489
+ await withWorkspace2(
4490
+ this.dir,
4491
+ async (state) => {
4492
+ const result = applyMutation3(state, patch);
4493
+ captured = { state: result.next, changedIds: result.changedIds };
4494
+ return { next: result.next };
4495
+ },
4496
+ { syncedFilename: GIT_SYNCED_FILENAME }
4497
+ );
4498
+ if (!captured) throw new Error("apply did not run");
4499
+ return captured;
4500
+ }
4501
+ async write(next) {
4502
+ const current = await this.read();
4503
+ const merged = {
4504
+ synced: next.synced ?? current.synced,
4505
+ local: next.local ?? current.local
4506
+ };
4507
+ await saveToFile2(this.dir, merged, { syncedFilename: GIT_SYNCED_FILENAME });
4508
+ return merged;
4509
+ }
4510
+ };
4511
+
2959
4512
  // src/providers/MultiWorkspaceProvider.ts
2960
4513
  import {
2961
4514
  loadRegistry,
@@ -3048,13 +4601,14 @@ var MultiWorkspaceProvider = class {
3048
4601
  let counts = null;
3049
4602
  try {
3050
4603
  const state = await loadWorkspaceById(this.registryRoot, entry.id);
3051
- if (state) {
4604
+ if (state?.synced?.collections) {
4605
+ const s = state.synced;
3052
4606
  counts = {
3053
- requests: Object.keys(state.synced.collections.requests).length,
3054
- folders: Object.keys(state.synced.collections.folders).length,
3055
- environments: Object.keys(state.synced.environments.items).length,
3056
- mockServers: Object.keys(state.synced.mockServers ?? {}).length,
3057
- plans: Object.keys(state.synced.executionPlans ?? {}).length
4607
+ requests: Object.keys(s.collections.requests ?? {}).length,
4608
+ folders: Object.keys(s.collections.folders ?? {}).length,
4609
+ environments: Object.keys(s.environments?.items ?? {}).length,
4610
+ mockServers: Object.keys(s.mockServers ?? {}).length,
4611
+ plans: Object.keys(s.executionPlans ?? {}).length
3058
4612
  };
3059
4613
  }
3060
4614
  } catch {
@@ -3137,7 +4691,85 @@ var InProcessMockController = class {
3137
4691
  }
3138
4692
  };
3139
4693
 
4694
+ // src/config/snippets.ts
4695
+ import * as path from "path";
4696
+ var AI_CLIENTS = [
4697
+ "claude-desktop",
4698
+ "claude-code",
4699
+ "codex",
4700
+ "cursor",
4701
+ "continue",
4702
+ "cline",
4703
+ "zed",
4704
+ "windsurf",
4705
+ "github-copilot",
4706
+ "chatgpt",
4707
+ "generic"
4708
+ ];
4709
+ function buildSnippetVariants(client, binary, workspace) {
4710
+ const forwardWorkspace = workspace.replace(/\\/g, "/");
4711
+ const render = client === "codex" ? renderTomlSnippet : renderJsonSnippet;
4712
+ const escaped = render(binary, workspace);
4713
+ const forwardSlash = render(binary, forwardWorkspace);
4714
+ return {
4715
+ forwardSlash,
4716
+ escaped,
4717
+ identical: forwardSlash === escaped
4718
+ };
4719
+ }
4720
+ function renderJsonSnippet(binary, workspace) {
4721
+ const entry = {
4722
+ command: binary,
4723
+ args: ["--workspace", workspace],
4724
+ env: { APICIRCLE_WORKSPACE: workspace }
4725
+ };
4726
+ return JSON.stringify({ mcpServers: { apicircle: entry } }, null, 2);
4727
+ }
4728
+ function renderTomlSnippet(binary, workspace) {
4729
+ const esc = (s) => s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
4730
+ return [
4731
+ `[mcp_servers.apicircle]`,
4732
+ `command = "${esc(binary)}"`,
4733
+ `args = ["--workspace", "${esc(workspace)}"]`,
4734
+ ``,
4735
+ `[mcp_servers.apicircle.env]`,
4736
+ `APICIRCLE_WORKSPACE = "${esc(workspace)}"`
4737
+ ].join("\n");
4738
+ }
4739
+ function resolveAiClientConfigPath(client, env) {
4740
+ const { homedir, platform, appdata } = env;
4741
+ switch (client) {
4742
+ case "claude-desktop":
4743
+ if (platform === "darwin") {
4744
+ return path.join(homedir, "Library/Application Support/Claude/claude_desktop_config.json");
4745
+ }
4746
+ if (platform === "win32") {
4747
+ return path.join(
4748
+ appdata ?? path.join(homedir, "AppData/Roaming"),
4749
+ "Claude/claude_desktop_config.json"
4750
+ );
4751
+ }
4752
+ return path.join(homedir, ".config/Claude/claude_desktop_config.json");
4753
+ case "claude-code":
4754
+ return path.join(homedir, ".claude/mcp.json");
4755
+ case "cursor":
4756
+ return path.join(homedir, ".cursor/mcp.json");
4757
+ case "continue":
4758
+ return path.join(homedir, ".continue/config.yaml");
4759
+ case "zed":
4760
+ return path.join(homedir, ".config/zed/settings.json");
4761
+ case "windsurf":
4762
+ return path.join(homedir, ".codeium/windsurf/mcp_config.json");
4763
+ case "codex":
4764
+ return path.join(homedir, ".codex/config.toml");
4765
+ default:
4766
+ return null;
4767
+ }
4768
+ }
4769
+
3140
4770
  // src/index.ts
4771
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
4772
+ import { StdioServerTransport as StdioServerTransport2 } from "@modelcontextprotocol/sdk/server/stdio.js";
3141
4773
  function createMcpServer(options) {
3142
4774
  const workspaces = options.workspaces ?? new SingleWorkspaceAdapter(
3143
4775
  options.workspace,
@@ -3155,15 +4787,23 @@ function createMcpServer(options) {
3155
4787
  });
3156
4788
  }
3157
4789
  export {
4790
+ AI_CLIENTS,
3158
4791
  FileBackedWorkspaceProvider,
4792
+ GitBackedWorkspaceProvider,
3159
4793
  InMemoryWorkspaceProvider,
3160
4794
  InProcessMockController,
4795
+ MCP_PROMPTS,
4796
+ MCP_PROMPT_CATEGORIES,
3161
4797
  McpHost,
3162
4798
  MultiWorkspaceProvider,
3163
4799
  SingleWorkspaceAdapter,
4800
+ StdioServerTransport2 as StdioServerTransport,
4801
+ StreamableHTTPServerTransport,
3164
4802
  TOOL_REGISTRY,
3165
4803
  WorkspaceNotFoundError,
4804
+ buildSnippetVariants,
3166
4805
  createMcpServer,
3167
- getTool
4806
+ getTool,
4807
+ resolveAiClientConfigPath
3168
4808
  };
3169
4809
  //# sourceMappingURL=index.js.map