@semanticist14/clco 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -61,7 +61,7 @@ clco
61
61
 
62
62
  1. **GitHub login**, once — a device-flow code to paste in your browser. The
63
63
  token is stored at `~/.config/clco/auth.json` (mode 600).
64
- 2. **Three questions**, once. Re-run them any time with `clco setup`:
64
+ 2. **Four questions**, once. Re-run them any time with `clco setup`:
65
65
 
66
66
  | Question | Default | What it does |
67
67
  |---|---|---|
@@ -71,7 +71,7 @@ clco
71
71
 
72
72
  3. **Pick a model**, then claude starts. Switch mid-session with `/model`.
73
73
 
74
- Answering yes to the first two together is worth understanding: claude runs
74
+ Answering yes to the permission and browser questions together is worth understanding: claude runs
75
75
  without permission prompts, and each session resolves the current
76
76
  `@playwright/mcp` release from npm (`bunx -y @playwright/mcp@latest`) and runs
77
77
  it with access to the browser tab you share. The version is re-resolved every
@@ -82,6 +82,14 @@ clco
82
82
  `CLCO_MCP_PACKAGE` pins or redirects the package — see
83
83
  [Browser control](#browser-control).
84
84
 
85
+ ## Web access
86
+
87
+ Claude's built-in Fetch works as-is: it fetches pages locally and only the
88
+ summarizing model call rides the adapter. Claude's built-in WebSearch cannot
89
+ work here - Anthropic executes it server-side, and Copilot's endpoint rejects
90
+ a native web_search tool - so the adapter drops it instead of sending a
91
+ broken request. No web MCP server is registered.
92
+
85
93
  ## Usage
86
94
 
87
95
  ```sh
@@ -0,0 +1,13 @@
1
+ # Third-party notices
2
+
3
+ ## pi-web-access
4
+
5
+ The `web_fetch` implementation in `src/webfetch.ts` is an independent adaptation of the core public-URL fetching and readable-content extraction approach from [pi-web-access](https://github.com/nicobailon/pi-web-access), used under the MIT License.
6
+
7
+ Copyright (c) 2025 nicobailon and contributors.
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files, to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
package/bun.lock CHANGED
@@ -1,11 +1,15 @@
1
1
  {
2
- "lockfileVersion": 2,
2
+ "lockfileVersion": 1,
3
3
  "configVersion": 1,
4
4
  "workspaces": {
5
5
  "": {
6
- "name": "clcopilot",
6
+ "name": "@semanticist14/clco",
7
7
  "dependencies": {
8
8
  "@clack/prompts": "^1.8.0",
9
+ "@mozilla/readability": "^0.6.0",
10
+ "linkedom": "^0.18.12",
11
+ "turndown": "^7.0.3",
12
+ "unpdf": "^1.6.2",
9
13
  },
10
14
  "devDependencies": {
11
15
  "@types/bun": "latest",
@@ -18,22 +22,74 @@
18
22
 
19
23
  "@clack/prompts": ["@clack/prompts@1.8.0", "", { "dependencies": { "@clack/core": "1.5.0", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-PXzLZ8N34rxmuo4dJg3xtOXhcBse94qGjDqsteoEYrFrrZ5FSjIGwMAuOcv64ln8rHVBBD06XeVGr+/JX+plcA=="],
20
24
 
25
+ "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
26
+
27
+ "@mozilla/readability": ["@mozilla/readability@0.6.0", "", {}, "sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ=="],
28
+
21
29
  "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="],
22
30
 
23
- "@types/node": ["@types/node@22.20.2", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw=="],
31
+ "@types/node": ["@types/node@26.5.1", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g=="],
32
+
33
+ "boolbase": ["boolbase@2.0.0", "", {}, "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA=="],
24
34
 
25
35
  "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
26
36
 
37
+ "css-select": ["css-select@7.0.0", "", { "dependencies": { "boolbase": "^2.0.0", "css-what": "^8.0.0", "domhandler": "^6.0.1", "domutils": "^4.0.2", "nth-check": "^3.0.1" } }, "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g=="],
38
+
39
+ "css-what": ["css-what@8.0.0", "", {}, "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw=="],
40
+
41
+ "cssom": ["cssom@0.5.0", "", {}, "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw=="],
42
+
43
+ "dom-serializer": ["dom-serializer@3.1.1", "", { "dependencies": { "domelementtype": "^3.0.0", "domhandler": "^6.0.0", "entities": "^8.0.0" } }, "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw=="],
44
+
45
+ "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
46
+
47
+ "domhandler": ["domhandler@6.0.1", "", { "dependencies": { "domelementtype": "^3.0.0" } }, "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg=="],
48
+
49
+ "domutils": ["domutils@4.0.2", "", { "dependencies": { "dom-serializer": "^3.0.0", "domelementtype": "^3.0.0", "domhandler": "^6.0.0" } }, "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA=="],
50
+
51
+ "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
52
+
27
53
  "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="],
28
54
 
29
55
  "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="],
30
56
 
31
57
  "fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="],
32
58
 
59
+ "html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="],
60
+
61
+ "htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="],
62
+
63
+ "linkedom": ["linkedom@0.18.13", "", { "dependencies": { "css-select": "^7.0.0", "cssom": "^0.5.0", "html-escaper": "^3.0.3", "htmlparser2": "^10.1.0", "uhyphen": "^0.2.0" }, "peerDependencies": { "canvas": ">= 2" }, "optionalPeers": ["canvas"] }, "sha512-ES/o9qotMpzpN2MHs+Iq/JcVoOj8Fa5wiQYrTdFpvAnwXL0g66XHHUc9WUMk6nAlBtGsFQ24ne+SYnvnaQ2FSw=="],
64
+
65
+ "nth-check": ["nth-check@3.0.1", "", { "dependencies": { "boolbase": "^2.0.0" } }, "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ=="],
66
+
33
67
  "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
34
68
 
69
+ "turndown": ["turndown@7.2.4", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ=="],
70
+
35
71
  "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
36
72
 
37
- "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
73
+ "uhyphen": ["uhyphen@0.2.0", "", {}, "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA=="],
74
+
75
+ "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
76
+
77
+ "unpdf": ["unpdf@1.8.1", "", { "peerDependencies": { "@napi-rs/canvas": "^0.1.69 || ^1.0.0" }, "optionalPeers": ["@napi-rs/canvas"] }, "sha512-xkURhy2SoGpOIH0a1gLHNkASPIQYonadDJs2AQwPEfUakafeD9EA1WTWWsaR++gfTCXJpV27W7tU1nXuk82UKQ=="],
78
+
79
+ "dom-serializer/domelementtype": ["domelementtype@3.0.0", "", {}, "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg=="],
80
+
81
+ "dom-serializer/entities": ["entities@8.1.0", "", {}, "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA=="],
82
+
83
+ "domhandler/domelementtype": ["domelementtype@3.0.0", "", {}, "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg=="],
84
+
85
+ "domutils/domelementtype": ["domelementtype@3.0.0", "", {}, "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg=="],
86
+
87
+ "htmlparser2/domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
88
+
89
+ "htmlparser2/domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
90
+
91
+ "htmlparser2/domutils/dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
92
+
93
+ "htmlparser2/domutils/dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
38
94
  }
39
95
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@semanticist14/clco",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Run Claude Code on a GitHub Copilot subscription",
@@ -18,6 +18,7 @@
18
18
  "uninstall.sh",
19
19
  "README.md",
20
20
  "LICENSE",
21
+ "THIRD_PARTY_NOTICES.md",
21
22
  "bun.lock"
22
23
  ],
23
24
  "scripts": {
@@ -30,6 +31,10 @@
30
31
  "typescript": "^5"
31
32
  },
32
33
  "dependencies": {
33
- "@clack/prompts": "^1.8.0"
34
+ "@clack/prompts": "^1.8.0",
35
+ "@mozilla/readability": "^0.6.0",
36
+ "unpdf": "^1.6.2",
37
+ "linkedom": "^0.18.12",
38
+ "turndown": "^7.0.3"
34
39
  }
35
40
  }
package/src/browsermcp.ts CHANGED
@@ -192,6 +192,28 @@ export function browserMcpConfig(
192
192
  })
193
193
  }
194
194
 
195
+ /** Compose the session-only MCP config without replacing a user's config file. */
196
+ export function combinedMcpConfig(
197
+ browserInstalled: boolean,
198
+ webEnabled = false,
199
+ searchModel?: string,
200
+ ): string | null {
201
+ const browser = browserMcpConfig(browserInstalled)
202
+ const mcpServers: Record<string, unknown> = browser
203
+ ? ((JSON.parse(browser) as { mcpServers: Record<string, unknown> }).mcpServers)
204
+ : {}
205
+ if (webEnabled) {
206
+ const env: Record<string, string> = {}
207
+ if (searchModel) env.CLCO_SEARCH_AGENT = searchModel
208
+ mcpServers.clco_web = {
209
+ command: process.execPath,
210
+ args: [join(import.meta.dir, "webmcp.ts")],
211
+ ...(Object.keys(env).length > 0 ? { env } : {}),
212
+ }
213
+ }
214
+ return Object.keys(mcpServers).length > 0 ? JSON.stringify({ mcpServers }) : null
215
+ }
216
+
195
217
  // The extension mints a base64url value; nothing else should be accepted.
196
218
  // A paste can easily pick up a shell prompt or a stray line, and storing that
197
219
  // silently produces a token that never works and no clue why.
package/src/claudehome.ts CHANGED
@@ -46,6 +46,35 @@ const PRIVATE_ENTRIES = new Set([
46
46
  * user's file every launch discarded whatever claude had persisted in the
47
47
  * private one.
48
48
  */
49
+ /**
50
+ * clco owns routing and auth: ANTHROPIC_BASE_URL and the bearer travel in
51
+ * the --settings tier. A provider env block in the user's own settings.json
52
+ * (e.g. z.ai's ANTHROPIC_AUTH_TOKEN) must not survive the copy: claude
53
+ * applies settings-file env over the child environment, so a surviving
54
+ * token would override clco's and every request would 401 against the
55
+ * adapter. ANTHROPIC_MODEL is stripped too: clco pins the session model
56
+ * itself, and a surviving pin makes every /model switch cosmetic.
57
+ */
58
+ const ROUTING_ENV = /^ANTHROPIC_(AUTH_TOKEN|API_KEY|BASE_URL|MODEL)$/
59
+ function stripRoutingEnv(parsed: Record<string, unknown>): void {
60
+ const env = parsed.env as Record<string, unknown> | undefined
61
+ if (env && typeof env === "object" && !Array.isArray(env)) {
62
+ for (const key of Object.keys(env)) if (ROUTING_ENV.test(key)) delete env[key]
63
+ }
64
+ }
65
+
66
+ // Claude's built-in WebSearch executes server-side on Anthropic's API and can
67
+ // never work through the adapter. Denying it in the private copy keeps the
68
+ // model from calling a tool that always no-ops and steering it toward the
69
+ // clco_web search tool instead. The user's own file is never touched.
70
+ function denyServerWebSearch(parsed: Record<string, unknown>): void {
71
+ const permissions = (parsed.permissions ?? {}) as Record<string, unknown>
72
+ const deny = Array.isArray(permissions.deny) ? [...permissions.deny] : []
73
+ if (!deny.includes("WebSearch")) deny.push("WebSearch")
74
+ permissions.deny = deny
75
+ parsed.permissions = permissions
76
+ }
77
+
49
78
  async function writeSettings(real: string, home: string): Promise<void> {
50
79
  const target = join(home, "settings.json")
51
80
  const source = await readFile(join(real, "settings.json"), "utf8").catch(
@@ -57,6 +86,8 @@ async function writeSettings(real: string, home: string): Promise<void> {
57
86
  try {
58
87
  const parsed = JSON.parse(raw) as Record<string, unknown>
59
88
  delete parsed.model
89
+ stripRoutingEnv(parsed)
90
+ denyServerWebSearch(parsed)
60
91
  await atomicWrite(target, JSON.stringify(parsed, null, 2) + "\n")
61
92
  } catch {
62
93
  // claude tolerates comments where JSON.parse does not. A line-based regex
@@ -68,6 +99,8 @@ async function writeSettings(real: string, home: string): Promise<void> {
68
99
  try {
69
100
  const parsed = JSON.parse(stripJsonComments(raw)) as Record<string, unknown>
70
101
  delete parsed.model
102
+ stripRoutingEnv(parsed)
103
+ denyServerWebSearch(parsed)
71
104
  await atomicWrite(target, JSON.stringify(parsed, null, 2) + "\n")
72
105
  } catch (err) {
73
106
  // Neither form parses: keep the previous private file rather than
package/src/cli.ts CHANGED
@@ -103,6 +103,13 @@ const pad = (rows: ReadonlyArray<[string, string]>, width: number) =>
103
103
 
104
104
  export const COMMAND_LIST = `Commands:\n${pad(COMMANDS, 20)}`
105
105
 
106
+ function searchAgentModel(models: ReadonlyArray<{ id: string }>): string | undefined {
107
+ // Copilot's own search-agent models: a plans web queries, b/c plan code
108
+ // search. Prefer the web one; anything is better than no planner.
109
+ return models.find((model) => model.id === "copilot-search-a")?.id ??
110
+ models.find((model) => model.id.startsWith("copilot-search"))?.id
111
+ }
112
+
106
113
  const HELP = `clco v${VERSION} — run Claude Code on your GitHub Copilot subscription
107
114
 
108
115
  Usage:
@@ -160,7 +167,8 @@ export function parseArgs(rawArgv: string[]): Args {
160
167
  if (
161
168
  arg === "--no-bypass" ||
162
169
  arg === "--no-select" ||
163
- arg === "--no-browser"
170
+ arg === "--no-browser" ||
171
+ arg === "--no-web"
164
172
  ) {
165
173
  overrides[arg.slice(5) as keyof SetupOverrides] = false
166
174
  i++
@@ -836,15 +844,13 @@ async function main(): Promise<void> {
836
844
  const browserEnabled =
837
845
  setup?.browser === true && args.overrides.browser !== false
838
846
  const browserExtension = browserEnabled ? await extensionInstalled() : false
847
+ // Web is on unless the user turned it off; absent prefs mean on.
848
+ const webEnabled = setup?.web !== false && args.overrides.web !== false
849
+ const searchAgent = searchAgentModel(list)
839
850
  // Computed from the arguments actually produced: clco stands aside when the
840
851
  // user passes their own --mcp-config, and claiming success there would be
841
852
  // the same trap as registering a server with no extension.
842
- const injected = setupClaudeArgs(
843
- setup,
844
- args.overrides,
845
- args.claudeArgs,
846
- browserExtension,
847
- )
853
+ const injected = setupClaudeArgs(setup, args.overrides, args.claudeArgs, browserExtension, searchAgent)
848
854
  const registered = injected.includes("--mcp-config")
849
855
  const browserLine = startupLine(
850
856
  browserEnabled,
@@ -860,6 +866,15 @@ async function main(): Promise<void> {
860
866
  browserEnabled && browserExtension ? registered : undefined,
861
867
  )
862
868
  if (browserLine) console.error(browserLine)
869
+ console.error(
870
+ webEnabled && !searchAgent
871
+ ? "+ web: unavailable (Copilot exposed no search-agent model)"
872
+ : webEnabled && injected.includes("--mcp-config")
873
+ ? `+ web: search via ${searchAgent} planner + live local execution`
874
+ : webEnabled
875
+ ? "+ web: enabled in setup, but not registered because --mcp-config was supplied"
876
+ : "+ web: disabled (run `clco setup` to enable)",
877
+ )
863
878
 
864
879
  if (args.command === "serve") {
865
880
  console.error("Adapter running... (Ctrl+C to stop)")
package/src/config.ts CHANGED
@@ -106,6 +106,8 @@ export interface SetupPrefs {
106
106
  * secret — the GitHub token lives in auth.json.
107
107
  */
108
108
  browserToken?: string
109
+ /** Register Copilot native web search and the local URL fetcher. */
110
+ web?: boolean
109
111
  }
110
112
 
111
113
  export interface Prefs {
package/src/responses.ts CHANGED
@@ -141,6 +141,16 @@ export function toResponsesRequest(
141
141
 
142
142
  const effort = effortFor(payload, allowedEfforts)
143
143
 
144
+ // Claude's built-in server-side tools (WebSearch, WebFetch) arrive without
145
+ // input_schema and cannot be executed by an upstream that only knows
146
+ // function tools - drop them rather than sending a broken schema. If
147
+ // tool_choice pinned one of the dropped tools, degrade to "auto", or the
148
+ // upstream rejects the request with "tool choice not found in tools".
149
+ const servable = (payload.tools ?? []).filter((t) => t.input_schema)
150
+ const choice = payload.tool_choice
151
+ const pinnedMissing =
152
+ choice?.type === "tool" && !servable.some((t) => t.name === choice.name)
153
+
144
154
  return {
145
155
  model: normalizeModel(payload.model),
146
156
  ...(instructions && { instructions }),
@@ -150,21 +160,22 @@ export function toResponsesRequest(
150
160
  max_output_tokens: payload.max_tokens,
151
161
  temperature: payload.temperature,
152
162
  top_p: payload.top_p,
153
- tools: payload.tools?.map((t) => ({
163
+ tools: servable.map((t) => ({
154
164
  type: "function" as const,
155
165
  name: t.name,
156
166
  description: t.description,
157
167
  parameters: t.input_schema,
158
168
  })),
159
- tool_choice:
160
- payload.tool_choice?.type === "auto"
161
- ? "auto"
162
- : payload.tool_choice?.type === "none"
163
- ? "none"
164
- : payload.tool_choice?.type === "any"
165
- ? "required"
166
- : payload.tool_choice?.name
167
- ? { type: "function", name: payload.tool_choice.name }
169
+ tool_choice: pinnedMissing
170
+ ? ("auto" as const)
171
+ : choice?.type === "auto"
172
+ ? ("auto" as const)
173
+ : choice?.type === "none"
174
+ ? ("none" as const)
175
+ : choice?.type === "any"
176
+ ? ("required" as const)
177
+ : choice?.name
178
+ ? { type: "function" as const, name: choice.name }
168
179
  : undefined,
169
180
  }
170
181
  }
package/src/search.ts ADDED
@@ -0,0 +1,131 @@
1
+ // Semi-native web search: Copilot's dedicated search-agent model plans the
2
+ // queries (copilot-search-a; GitHub's own web-search planner, callable with
3
+ // the same OAuth token), execution is ours - DuckDuckGo HTML for results and
4
+ // the local fetcher for pages - and the planner synthesizes the final answer.
5
+ // Verified live 2026-09-13: the planner emits real read_article URLs only
6
+ // after live results are fed back, so information genuinely flows web ->
7
+ // model. Rounds are capped; any failure surfaces as WebFetchError.
8
+ import { getCopilotToken } from "./token"
9
+ import { copilotBaseUrl, copilotFetch, copilotRequestHeaders } from "./api"
10
+ import { fetchContent, WebFetchError } from "./webfetch"
11
+
12
+ export const SEARCH_AGENT = process.env.CLCO_SEARCH_AGENT ?? "copilot-search-a"
13
+ const MAX_ROUNDS = 4
14
+ const MAX_CALLS_PER_ROUND = 3
15
+ const UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"
16
+
17
+ type ToolCall = { id: string; function: { name: string; arguments: string } }
18
+ type Msg = Record<string, unknown>
19
+
20
+ async function chat(token: string, messages: Msg[], withTools = true): Promise<{ content: string; calls: ToolCall[] }> {
21
+ const res = await copilotFetch(`${copilotBaseUrl()}/chat/completions`, {
22
+ method: "POST",
23
+ headers: copilotRequestHeaders(token, { accept: "application/json" }),
24
+ body: JSON.stringify({
25
+ model: SEARCH_AGENT,
26
+ messages,
27
+ ...(withTools
28
+ ? {
29
+ tools: [
30
+ { type: "web_search" },
31
+ { type: "function", function: { name: "read_article", description: "Read a web page by URL.", parameters: { type: "object", properties: { url: { type: "string" } }, required: ["url"] } } },
32
+ ],
33
+ }
34
+ : {}),
35
+ stream: false,
36
+ }),
37
+ signal: AbortSignal.timeout(60_000),
38
+ })
39
+ if (!res.ok) throw new WebFetchError(`search agent failed: HTTP ${res.status} ${(await res.text()).slice(0, 200)}`)
40
+ const json = (await res.json()) as { choices?: Array<{ message?: { content?: string; tool_calls?: ToolCall[] } }> }
41
+ const message = json.choices?.[0]?.message ?? {}
42
+ return { content: message.content ?? "", calls: message.tool_calls ?? [] }
43
+ }
44
+
45
+ async function ddg(query: string): Promise<string> {
46
+ const res = await fetch(`https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`, {
47
+ headers: { "User-Agent": UA },
48
+ signal: AbortSignal.timeout(20_000),
49
+ })
50
+ const html = await res.text()
51
+ const results = [...html.matchAll(/class="result__a"[^>]*>([^<]+)<\/a>[\s\S]*?class="result__snippet"[^>]*>([\s\S]*?)<\/a>/g)]
52
+ .slice(0, 5)
53
+ .map((m, i) => `${i + 1}. ${strip(m[1]!)}: ${strip(m[2]!).slice(0, 250)}`)
54
+ return results.join("\n") || "(no results)"
55
+ }
56
+
57
+ function strip(html: string): string {
58
+ return html.replace(/<[^>]+>/g, "").replace(/&amp;/g, "&").replace(/&#x27;/g, "'").replace(/&quot;/g, '"').trim()
59
+ }
60
+
61
+ async function readUrl(url: string): Promise<string> {
62
+ const { text } = await fetchContent(url, 4_000)
63
+ return text
64
+ }
65
+
66
+ type LoopDeps = {
67
+ /** One planner turn: returns its text plus any tool calls it wants. */
68
+ ask: (messages: Msg[]) => Promise<{ content: string; calls: ToolCall[] }>
69
+ /** Execute a search query. */
70
+ search: (query: string) => Promise<string>
71
+ /** Read a page by URL. */
72
+ read: (url: string) => Promise<string>
73
+ /** Final answer from gathered material; must not plan more tool calls. */
74
+ synthesize: (messages: Msg[]) => Promise<string>
75
+ }
76
+
77
+ /** The planner/executor loop, injectable for tests. */
78
+ export async function searchLoop(query: string, deps: LoopDeps): Promise<string> {
79
+ const messages: Msg[] = [
80
+ {
81
+ role: "system",
82
+ content:
83
+ "You are a web research assistant. Always call web_search FIRST to find live results; never guess or construct URLs yourself - only read_article URLs that appeared in web_search results. Finish with a concise answer and list the sources you used.",
84
+ },
85
+ { role: "user", content: query },
86
+ ]
87
+ const done = new Map<string, string>()
88
+ let material = ""
89
+ for (let round = 0; round < MAX_ROUNDS; round++) {
90
+ const { content, calls } = await deps.ask(messages)
91
+ if (!calls.length) return content.trim() || material || "(no results)"
92
+ messages.push({ role: "assistant", content: content || null, tool_calls: calls })
93
+ for (const call of calls.slice(0, MAX_CALLS_PER_ROUND)) {
94
+ let args: { query?: string; url?: string } = {}
95
+ try { args = JSON.parse(call.function.arguments || "{}") } catch { /* empty args stay empty */ }
96
+ // Re-running a failed or finished call only burns rounds; replay it.
97
+ const key = `${call.function.name}:${args.query ?? args.url ?? ""}`
98
+ let output = done.get(key)
99
+ if (output === undefined) {
100
+ if (call.function.name === "read_article" && args.url) {
101
+ output = await deps.read(args.url).catch((error) => `(fetch failed: ${(error as Error).message}. Use web_search to find a working source.)`)
102
+ material += `\n(source) ${args.url}\n${output.slice(0, 300)}`
103
+ } else {
104
+ output = await deps.search(args.query ?? query)
105
+ material += `\n${output}`
106
+ }
107
+ done.set(key, output)
108
+ }
109
+ messages.push({ role: "tool", tool_call_id: call.id, content: output })
110
+ }
111
+ }
112
+ // The planner always wants one more round, so conclude without tools:
113
+ // answer the original question from the gathered material only.
114
+ return (
115
+ await deps.synthesize([
116
+ ...messages,
117
+ { role: "user", content: "Stop searching. Answer the original question using only the results gathered above. Be concise and list the sources you used." },
118
+ ])
119
+ ).trim() || material.trim()
120
+ }
121
+
122
+ /** One search: plan with the agent, execute, feed back, return the answer. */
123
+ export async function runSearch(query: string): Promise<string> {
124
+ const token = await getCopilotToken()
125
+ return searchLoop(query, {
126
+ ask: (messages) => chat(token, messages),
127
+ search: ddg,
128
+ read: readUrl,
129
+ synthesize: async (messages) => (await chat(token, messages, false)).content,
130
+ })
131
+ }
package/src/server.ts CHANGED
@@ -540,9 +540,14 @@ async function handleMessages(
540
540
  // origin we actually bound instead.
541
541
  function authorize(req: Request, selfHost: string): Response | null {
542
542
  if (req.headers.get("host") !== selfHost) {
543
+ logLine(`[${timestamp()}] -> 403 ${new URL(req.url).pathname} host=${req.headers.get("host")}`)
543
544
  return anthropicError(403, "host header mismatch")
544
545
  }
545
- if (req.headers.get("authorization") !== `Bearer ${LOCAL_TOKEN}`) {
546
+ const auth = req.headers.get("authorization")
547
+ if (auth !== `Bearer ${LOCAL_TOKEN}`) {
548
+ // Never log the full value: enough to tell which credential source sent it.
549
+ const got = auth === null ? "(none)" : auth.startsWith("Bearer ") ? `Bearer ${auth.slice(7, 15)}…(${auth.length - 7} chars)` : `other(${auth.slice(0, 8)}…)`
550
+ logLine(`[${timestamp()}] -> 401 ${new URL(req.url).pathname} auth=${got} expected=Bearer ${LOCAL_TOKEN.slice(0, 8)}…`)
546
551
  return anthropicError(401, "missing or invalid adapter token")
547
552
  }
548
553
  return null
@@ -624,6 +629,9 @@ export async function startServer(
624
629
  const server = Bun.serve({
625
630
  hostname: "127.0.0.1",
626
631
  port: opts.port ?? 0,
632
+ // Search-planner and provider calls can hold the client socket silent
633
+ // for the whole upstream generation; Bun's 10s default killed those.
634
+ idleTimeout: 255,
627
635
  fetch: (req) =>
628
636
  handle(req, upstreamBase, mockToken, selfHost).catch((err) =>
629
637
  anthropicError(500, String(err)),
package/src/setup.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  import * as p from "@clack/prompts"
6
6
  import {
7
7
  TOKEN_ENV,
8
- browserMcpConfig,
8
+ combinedMcpConfig,
9
9
  extensionInstalled,
10
10
  parseToken,
11
11
  setupNote,
@@ -13,12 +13,13 @@ import {
13
13
  import { loadPrefs, savePrefs, type SetupPrefs } from "./config"
14
14
 
15
15
  /** Bump when an option is added, so existing users get told once. */
16
- export const SETUP_VERSION = 2
16
+ export const SETUP_VERSION = 3
17
17
 
18
18
  export interface SetupOverrides {
19
19
  bypass?: boolean
20
20
  select?: boolean
21
21
  browser?: boolean
22
+ web?: boolean
22
23
  }
23
24
 
24
25
  // What the first-run prompts come pre-filled with: these are the options
@@ -30,6 +31,7 @@ export const SETUP_DEFAULTS: Omit<SetupPrefs, "version"> = {
30
31
  bypass: true,
31
32
  select: true,
32
33
  browser: true,
34
+ web: true,
33
35
  }
34
36
 
35
37
  export async function runSetup(): Promise<SetupPrefs> {
@@ -62,6 +64,11 @@ export async function runSetup(): Promise<SetupPrefs> {
62
64
  current.bypass,
63
65
  )
64
66
 
67
+ setup.web = await ask(
68
+ "Search the web via Copilot's search planner (queries by copilot-search-a, live execution locally)?",
69
+ current.web ?? true,
70
+ )
71
+
65
72
  // Claude's own Chrome integration cannot work here, so there is nothing to
66
73
  // ask about it - only an alternative to offer.
67
74
  setup.browser = await ask(
@@ -98,7 +105,7 @@ export async function runSetup(): Promise<SetupPrefs> {
98
105
  await savePrefs({ ...prefs, setup })
99
106
  p.outro(
100
107
  "Saved. Re-run `clco setup` to change it, or turn one off for a single\n" +
101
- "run with --no-bypass / --no-select / --no-browser.",
108
+ "run with --no-bypass / --no-select / --no-browser / --no-web.",
102
109
  )
103
110
  return setup
104
111
  }
@@ -135,6 +142,8 @@ export function setupClaudeArgs(
135
142
  claudeArgs: string[],
136
143
  /** Whether the Playwright MCP Bridge extension is installed. */
137
144
  browserExtension = false,
145
+ /** The Copilot search-agent model the web tool plans with. */
146
+ searchAgent?: string,
138
147
  ): string[] {
139
148
  if (!setup) return []
140
149
  // Match the `--flag=value` form too: an exact comparison let a user's own
@@ -153,12 +162,10 @@ export function setupClaudeArgs(
153
162
  }
154
163
  // Registered per session rather than written into the user's MCP config, so
155
164
  // clco never edits configuration that outlives it.
156
- if (
157
- setup.browser &&
158
- overrides.browser !== false &&
159
- !has("--mcp-config")
160
- ) {
161
- const config = browserMcpConfig(browserExtension)
165
+ const browserRequested = setup.browser && overrides.browser !== false && browserExtension
166
+ const webRequested = setup.web === true && overrides.web !== false
167
+ if ((browserRequested || webRequested) && !has("--mcp-config")) {
168
+ const config = combinedMcpConfig(browserExtension, webRequested, searchAgent)
162
169
  if (config) out.push("--mcp-config", config)
163
170
  }
164
171
  return out
package/src/tokens.ts CHANGED
@@ -48,7 +48,7 @@ export function estimateTokens(payload: AnthropicRequest): number {
48
48
  count(payload.system)
49
49
  for (const tool of payload.tools ?? []) {
50
50
  chars += tool.name.length + (tool.description?.length ?? 0)
51
- chars += JSON.stringify(tool.input_schema).length
51
+ chars += JSON.stringify(tool.input_schema ?? {}).length
52
52
  }
53
53
  return Math.ceil(chars / CHARS_PER_TOKEN) + images * IMAGE_TOKENS +
54
54
  (payload.messages?.length ?? 0) * MESSAGE_TOKENS
package/src/translate.ts CHANGED
@@ -249,6 +249,7 @@ function translateTools(
249
249
 
250
250
  function translateToolChoice(
251
251
  choice: AnthropicRequest["tool_choice"],
252
+ servableNames: Set<string>,
252
253
  ): OpenAIRequest["tool_choice"] {
253
254
  if (!choice) return undefined
254
255
  switch (choice.type) {
@@ -259,9 +260,12 @@ function translateToolChoice(
259
260
  case "none":
260
261
  return "none"
261
262
  case "tool":
262
- return choice.name
263
+ // A pinned tool that was dropped (Claude's built-in server-side ones
264
+ // carry no input_schema) must degrade to "auto", or the upstream
265
+ // rejects with "tool choice not found in tools".
266
+ return choice.name && servableNames.has(choice.name)
263
267
  ? { type: "function", function: { name: choice.name } }
264
- : undefined
268
+ : "auto"
265
269
  default:
266
270
  return undefined
267
271
  }
@@ -298,7 +302,14 @@ export function translateRequest(
298
302
  top_p: payload.top_p,
299
303
  user: payload.metadata?.user_id ?? null,
300
304
  tools: translateTools(payload.tools),
301
- tool_choice: translateToolChoice(payload.tool_choice),
305
+ tool_choice: translateToolChoice(
306
+ payload.tool_choice,
307
+ new Set(
308
+ (payload.tools ?? [])
309
+ .filter((tool) => tool.input_schema)
310
+ .map((tool) => tool.name),
311
+ ),
312
+ ),
302
313
  }
303
314
  }
304
315
 
@@ -0,0 +1,60 @@
1
+ import { lookup } from "node:dns/promises"
2
+ import { Readability } from "@mozilla/readability"
3
+ import { parseHTML } from "linkedom"
4
+ import TurndownService from "turndown"
5
+
6
+ const MAX_BYTES = 5 * 1024 * 1024
7
+ const MAX_REDIRECTS = 5
8
+ const FETCH_TIMEOUT_MS = 20_000
9
+ export class WebFetchError extends Error {}
10
+
11
+ export function privateAddress(address: string): boolean {
12
+ const value = address.toLowerCase()
13
+ if (value.startsWith("::ffff:")) return privateAddress(value.slice(7))
14
+ if (value === "::" || value === "::1" || value.startsWith("fc") || value.startsWith("fd") || value.startsWith("fe8") || value.startsWith("fe9") || value.startsWith("fea") || value.startsWith("feb") || value.startsWith("ff") || value.startsWith("2001:db8:")) return true
15
+ const octets = value.split(".").map(Number)
16
+ if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false
17
+ const [a, b, c] = octets
18
+ return a === 0 || a === 10 || a === 127 || a >= 224 || (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 192 && b === 0 && (c === 0 || c === 2)) || (a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) || (a === 203 && b === 0 && c === 113)
19
+ }
20
+ async function publicAddress(url: URL): Promise<string> {
21
+ const addresses = await Promise.race([lookup(url.hostname, { all: true, verbatim: true }), new Promise<never>((_, reject) => setTimeout(() => reject(new WebFetchError("web_fetch DNS lookup timed out")), 5_000))])
22
+ if (!addresses.length || addresses.some(({ address }) => privateAddress(address))) throw new WebFetchError("web_fetch blocked a private or local destination")
23
+ return addresses[0]!.address
24
+ }
25
+ async function validateUrl(raw: string): Promise<URL> {
26
+ let url: URL
27
+ try { url = new URL(raw) } catch { throw new WebFetchError("web_fetch requires a valid URL") }
28
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) throw new WebFetchError("web_fetch only permits credential-free HTTP(S) URLs")
29
+ await publicAddress(url)
30
+ return url
31
+ }
32
+ async function pinnedFetch(url: URL, init: RequestInit): Promise<Response> {
33
+ const address = await publicAddress(url); const target = new URL(url); const originalHost = target.host; target.hostname = address; const headers = new Headers(init.headers); headers.set("host", originalHost); const options = { ...init, headers } as RequestInit & { tls?: { serverName: string } }; if (url.protocol === "https:") options.tls = { serverName: url.hostname }; return fetch(target, options as RequestInit)
34
+ }
35
+ async function readLimited(response: Response): Promise<Uint8Array> {
36
+ if (!response.body) throw new WebFetchError("web_fetch received an empty response")
37
+ const reader = response.body.getReader(); const chunks: Uint8Array[] = []; let total = 0
38
+ try { while (true) { const { done, value } = await reader.read(); if (done) break; total += value.byteLength; if (total > MAX_BYTES) throw new WebFetchError("web_fetch refused a response larger than 5 MB"); chunks.push(value) } } finally { await reader.cancel().catch(() => {}) }
39
+ const result = new Uint8Array(total); let offset = 0
40
+ for (const chunk of chunks) { result.set(chunk, offset); offset += chunk.byteLength }
41
+ return result
42
+ }
43
+ async function pdfText(bytes: Uint8Array): Promise<string> {
44
+ try { const module = await import("unpdf") as { extractText?: (data: Uint8Array) => Promise<unknown> }; if (!module.extractText) throw new Error(); const result = await module.extractText(bytes); if (typeof result === "string") return result; const text = (result as { text?: string | string[] } | null)?.text; return Array.isArray(text) ? text.join("\n") : text ?? "" } catch { throw new WebFetchError("web_fetch could not extract text from this PDF") }
45
+ }
46
+ export async function fetchContent(rawUrl: string, maxChars = 30_000): Promise<{ url: string; contentType: string; text: string; title?: string }> {
47
+ let url = await validateUrl(rawUrl); let response: Response | undefined
48
+ for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects++) { response = await pinnedFetch(url, { redirect: "manual", signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); if (![301, 302, 303, 307, 308].includes(response.status)) break; const location = response.headers.get("location"); if (!location || redirects === MAX_REDIRECTS) throw new WebFetchError("web_fetch followed too many redirects"); url = await validateUrl(new URL(location, url).toString()) }
49
+ if (!response) throw new WebFetchError("web_fetch did not receive a response"); if (!response.ok) throw new WebFetchError(`web_fetch failed: HTTP ${response.status}`)
50
+ const bytes = await readLimited(response); const contentType = (response.headers.get("content-type") ?? "application/octet-stream").split(";", 1)[0].trim().toLowerCase()
51
+ if (contentType === "application/pdf" || url.pathname.toLowerCase().endsWith(".pdf")) return { url: url.toString(), contentType, text: (await pdfText(bytes)).trim().slice(0, maxChars) }
52
+ const source = new TextDecoder().decode(bytes)
53
+ if (["application/json", "text/plain", "text/csv", "application/xml"].includes(contentType)) return { url: url.toString(), contentType, text: source.slice(0, maxChars) }
54
+ if (!contentType.includes("html")) throw new WebFetchError(`web_fetch does not support ${contentType || "this content type"}`)
55
+ const { document } = parseHTML(source); const article = new Readability(document as unknown as Document).parse()
56
+ if (!article?.content) throw new WebFetchError("web_fetch found no readable static content; use browser control for JavaScript-only pages")
57
+ const markdown = new TurndownService({ codeBlockStyle: "fenced" }).turndown(article.content).trim()
58
+ if (!markdown) throw new WebFetchError("web_fetch found no readable static content; use browser control for JavaScript-only pages")
59
+ return { url: url.toString(), contentType, title: article.title || undefined, text: markdown.slice(0, maxChars) }
60
+ }
package/src/webmcp.ts ADDED
@@ -0,0 +1,45 @@
1
+ // Session-scoped MCP server exposing one tool: web_search. The heavy lifting
2
+ // lives in search.ts (planner + execution loop); this file is the stdio
3
+ // JSON-RPC wrapper Claude Code talks to.
4
+ import { runSearch, SEARCH_AGENT } from "./search"
5
+ import { WebFetchError } from "./webfetch"
6
+ import { loadAuth } from "./config"
7
+
8
+ type Rpc = { jsonrpc: "2.0"; id?: string | number; method: string; params?: Record<string, unknown> }
9
+ function reply(id: Rpc["id"], result: unknown): void { if (id !== undefined) process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n") }
10
+ function failure(message: string): { content: [{ type: "text"; text: string }]; isError: true } { return { content: [{ type: "text", text: message }], isError: true } }
11
+
12
+ const tools = [
13
+ { name: "web_search", description: "Search the live web. Use this whenever the user asks to search, look up, or find current information - results are fresh, not from training data.", inputSchema: { type: "object", properties: { query: { type: "string" } }, required: ["query"], additionalProperties: false } },
14
+ ]
15
+
16
+ async function call(name: string, args: Record<string, unknown>): Promise<unknown> {
17
+ try {
18
+ if (name === "web_search") {
19
+ if (!(await loadAuth())?.github_token && !process.env.CLCO_UPSTREAM) return failure("GitHub authentication is required; run `clco login` before using web search")
20
+ return { content: [{ type: "text", text: await runSearch(String(args.query ?? "")) }] }
21
+ }
22
+ return failure(`unknown web tool: ${name}`)
23
+ } catch (error) {
24
+ return failure(error instanceof Error ? error.message : String(error))
25
+ }
26
+ }
27
+
28
+ process.stdin.setEncoding("utf8")
29
+ let input = ""
30
+ let queue = Promise.resolve()
31
+ process.stdin.on("data", (chunk: string) => {
32
+ input += chunk
33
+ for (const line of input.split("\n").slice(0, -1)) queue = queue.then(() => handle(line))
34
+ input = input.split("\n").pop() ?? ""
35
+ })
36
+ async function handle(line: string): Promise<void> {
37
+ if (!line.trim()) return
38
+ let message: Rpc
39
+ try { message = JSON.parse(line) as Rpc } catch { return }
40
+ if (message.method === "initialize") return reply(message.id, { protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: "clco-web", version: "0.1.0" }, instructions: "This server provides live web search. Whenever the user asks to search the web, look something up online, or needs current information (news, releases, prices, docs), call its web_search tool instead of answering from memory." })
41
+ if (message.method === "ping") return reply(message.id, {})
42
+ if (message.method === "tools/list") return reply(message.id, { tools })
43
+ if (message.method === "tools/call") { const params = message.params ?? {}; return reply(message.id, await call(String(params.name), (params.arguments ?? {}) as Record<string, unknown>)) }
44
+ if (message.id !== undefined) reply(message.id, { error: { code: -32601, message: `Method not found: ${message.method}` } })
45
+ }