@wirecat/cli-template 0.14.0 → 0.16.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.
Files changed (154) hide show
  1. package/.github/workflows/ci.yml +17 -0
  2. package/AGENTS.md +56 -2
  3. package/CHANGELOG.md +46 -0
  4. package/README.md +12 -4
  5. package/biome.json +2 -2
  6. package/contracts/mcp-tools.json +2 -2
  7. package/dist/bin/clidev.js +2 -2
  8. package/dist/bundle/{chunk-LU4A2T7Y.js → all-profiles-FS7AEOE7.js} +8 -5
  9. package/dist/bundle/{anthropic-ICTW7CNQ.js → anthropic-5RN7BGDI.js} +3 -3
  10. package/dist/bundle/{app-server-B6BCKXR7.js → app-server-WGTKZQKJ.js} +28 -29
  11. package/dist/bundle/{bun-sqlite-JE6FJFQT.js → bun-sqlite-WK2IGBGD.js} +2 -2
  12. package/dist/bundle/{chunk-AS2PJNQ2.js → chunk-2MWNE4L3.js} +1 -1
  13. package/dist/bundle/{chunk-2DAXUNTS.js → chunk-5HHIPDNW.js} +1 -1
  14. package/dist/bundle/{chunk-YOJLIVGO.js → chunk-5KAYQKBK.js} +1 -1
  15. package/dist/bundle/{chunk-62JRF2JA.js → chunk-6WOJIAYA.js} +1 -1
  16. package/dist/bundle/chunk-7EUX7AZV.js +141 -0
  17. package/dist/bundle/{chunk-7F5VMLVK.js → chunk-ADFSSIV4.js} +1 -1
  18. package/dist/bundle/{chunk-U6QD3J3F.js → chunk-ATHZEV2Z.js} +2 -2
  19. package/dist/bundle/{chunk-UT5TQ6MD.js → chunk-BPMKAZSS.js} +8 -8
  20. package/dist/bundle/{chunk-DZLHY4IA.js → chunk-C3GNOEJP.js} +1 -1
  21. package/dist/bundle/{chunk-PIJUGF5F.js → chunk-CTBHQRX6.js} +1 -1
  22. package/dist/bundle/{chunk-LFLSV353.js → chunk-DEHBTFIC.js} +1 -1
  23. package/dist/bundle/{chunk-NVNUUHT5.js → chunk-EA3TMI4A.js} +8 -8
  24. package/dist/bundle/{chunk-BEBD27QT.js → chunk-EILS4BTQ.js} +16 -18
  25. package/dist/bundle/{chunk-BVLPNNEL.js → chunk-ENDBOYQR.js} +14 -14
  26. package/dist/bundle/{chunk-6AW4DDK4.js → chunk-ETJXPB6A.js} +1 -1
  27. package/dist/bundle/{chunk-LATYBJ4T.js → chunk-FMO6F5Q2.js} +1 -1
  28. package/dist/bundle/{chunk-RJ7AS7UB.js → chunk-HTYLT6ME.js} +24 -7
  29. package/dist/bundle/{chunk-F34HGYWF.js → chunk-JFXWLQ2L.js} +1 -1
  30. package/dist/bundle/{chunk-IZKXJJ7V.js → chunk-JJZCPBH3.js} +29 -6
  31. package/dist/bundle/{chunk-G7W53MQP.js → chunk-LRHX62ZF.js} +2 -2
  32. package/dist/bundle/{chunk-SOYOOU2D.js → chunk-MBBOINK4.js} +2 -2
  33. package/dist/bundle/{chunk-BM5ZCDPQ.js → chunk-MEXT6XDP.js} +2 -2
  34. package/dist/bundle/{chunk-R2JKPHH5.js → chunk-NVPWZF67.js} +1 -1
  35. package/dist/bundle/{chunk-45244QQ6.js → chunk-OF56L2JU.js} +2 -2
  36. package/dist/bundle/{chunk-4PA6DJ7F.js → chunk-OGTRPW2K.js} +3 -3
  37. package/dist/bundle/{chunk-VXOR76ZL.js → chunk-ORV4EGNC.js} +1 -1
  38. package/dist/bundle/{chunk-TJJ2XSJP.js → chunk-QGOOPPEW.js} +2 -2
  39. package/dist/bundle/{chunk-NH6LZ3H3.js → chunk-RFJKNFVF.js} +3 -3
  40. package/dist/bundle/{chunk-R5RYPD23.js → chunk-RYOAQCVJ.js} +1 -1
  41. package/dist/bundle/{chunk-66VMJGV6.js → chunk-SKBRW5U3.js} +1 -1
  42. package/dist/bundle/{chunk-BWBZCYUN.js → chunk-TXHCL75B.js} +43 -6
  43. package/dist/bundle/{chunk-QXOSPYM3.js → chunk-VPCXYY46.js} +1 -1
  44. package/dist/bundle/{chunk-IOJ2DTCY.js → chunk-WDWP5N6M.js} +2 -0
  45. package/dist/bundle/{chunk-HYTHTSJP.js → chunk-WM5AMTRY.js} +4 -4
  46. package/dist/bundle/{chunk-ASHVHS52.js → chunk-YNROUY3R.js} +10 -10
  47. package/dist/bundle/{chunk-DQIBGCF3.js → chunk-Z5NL6YNU.js} +2 -2
  48. package/dist/bundle/{chunk-5SAF3XOL.js → chunk-ZSY4WAGI.js} +28 -21
  49. package/dist/bundle/clidev.js +3 -3
  50. package/dist/bundle/{command-contract-JMVMT5HT.js → command-contract-ZDU73EPH.js} +9 -9
  51. package/dist/bundle/{decode-NXB5UKMO.js → decode-QMLOGZ6R.js} +2 -2
  52. package/dist/bundle/{embed-IKZ24BMC.js → embed-PIR2LJR2.js} +4 -4
  53. package/dist/bundle/{embedder-GMQ24DOQ.js → embedder-6KBRPAEQ.js} +4 -4
  54. package/dist/bundle/{first-run-MDD4SIUK.js → first-run-JO3CMY3Z.js} +7 -7
  55. package/dist/bundle/{first-screen-376CXGXJ.js → first-screen-3T5IXTFL.js} +10 -10
  56. package/dist/bundle/{node-sqlite-73Z4SFOA.js → node-sqlite-LIK6OYNF.js} +2 -2
  57. package/dist/bundle/{oauth-EVTPAMZP.js → oauth-7FVF7HQQ.js} +4 -4
  58. package/dist/bundle/{open-46WZAPJ3.js → open-CRZDEATN.js} +2 -2
  59. package/dist/bundle/{openai-GWRDAHDU.js → openai-WAJ6WREQ.js} +11 -32
  60. package/dist/bundle/{program-B3CY3FP7.js → program-ZFFFPBRS.js} +2263 -300
  61. package/dist/bundle/{prompt-IVEYRLS5.js → prompt-QQIVHO3M.js} +3 -3
  62. package/dist/bundle/{recognize-WVEKHODM.js → recognize-4KLBZDEC.js} +2 -2
  63. package/dist/bundle/{remote-4K5VPMC7.js → remote-BABMZTHA.js} +5 -5
  64. package/dist/bundle/{rerank-V24UJZYJ.js → rerank-P3IQQJIL.js} +4 -4
  65. package/dist/bundle/{chunk-EMHCXXNJ.js → serve-25SSAKRZ.js} +6 -4
  66. package/dist/bundle/server-G4GCDMYI.js +52 -0
  67. package/dist/bundle/{chunk-PX2KP4QE.js → server-P4XUYY5A.js} +27 -7
  68. package/dist/bundle/{state-ZPYGECVP.js → state-QCQALQBJ.js} +3 -3
  69. package/dist/bundle/{store-6JQCLPQF.js → store-ZECPRGGH.js} +7 -7
  70. package/dist/bundle/store-maintenance-command-ECUO5G6L.js +42 -0
  71. package/dist/commands/comments.d.ts +3 -0
  72. package/dist/commands/comments.d.ts.map +1 -0
  73. package/dist/commands/comments.js +114 -0
  74. package/dist/commands/comments.js.map +1 -0
  75. package/dist/commands/session.d.ts +3 -0
  76. package/dist/commands/session.d.ts.map +1 -0
  77. package/dist/commands/session.js +96 -0
  78. package/dist/commands/session.js.map +1 -0
  79. package/dist/config.d.ts +2 -0
  80. package/dist/config.d.ts.map +1 -1
  81. package/dist/config.js +4 -2
  82. package/dist/config.js.map +1 -1
  83. package/dist/context.d.ts +83 -0
  84. package/dist/context.d.ts.map +1 -0
  85. package/dist/context.js +58 -0
  86. package/dist/context.js.map +1 -0
  87. package/dist/program.d.ts.map +1 -1
  88. package/dist/program.js +26 -5
  89. package/dist/program.js.map +1 -1
  90. package/dist/sample/comments.d.ts +61 -0
  91. package/dist/sample/comments.d.ts.map +1 -0
  92. package/dist/sample/comments.js +39 -0
  93. package/dist/sample/comments.js.map +1 -0
  94. package/dist/sample/transport.d.ts +42 -0
  95. package/dist/sample/transport.d.ts.map +1 -0
  96. package/dist/sample/transport.js +152 -0
  97. package/dist/sample/transport.js.map +1 -0
  98. package/dist/token.d.ts +16 -0
  99. package/dist/token.d.ts.map +1 -0
  100. package/dist/token.js +32 -0
  101. package/dist/token.js.map +1 -0
  102. package/dist/version.d.ts +1 -1
  103. package/dist/version.js +1 -1
  104. package/docs/commands.md +234 -12
  105. package/docs/configuration.md +1 -1
  106. package/docs/dev/reference-clis.md +3 -2
  107. package/docs/dev/test-matrix.md +36 -5
  108. package/docs/index.md +4 -3
  109. package/docs/security.md +4 -2
  110. package/kit/dist/new.js +6 -3
  111. package/lint/overrides.json +9 -1
  112. package/package.json +3 -4
  113. package/scripts/test-matrix-untested.ts +19 -0
  114. package/skills/clidev-cli/SKILL.md +1 -1
  115. package/src/commands/comments.ts +167 -0
  116. package/src/commands/session.ts +144 -0
  117. package/src/comments.test.ts +258 -0
  118. package/src/config.ts +5 -2
  119. package/src/context.ts +78 -0
  120. package/src/mcp.test.ts +71 -76
  121. package/src/program.test.ts +39 -17
  122. package/src/program.ts +30 -5
  123. package/src/sample/comments.ts +51 -0
  124. package/src/sample/transport.test.ts +117 -0
  125. package/src/sample/transport.ts +184 -0
  126. package/src/testing/fake-comments.ts +167 -0
  127. package/src/testing/harness.ts +51 -0
  128. package/src/token.ts +42 -0
  129. package/src/version.ts +1 -1
  130. package/dist/bundle/all-profiles-NVB2HT2S.js +0 -24
  131. package/dist/bundle/chunk-GUFPQZUA.js +0 -48
  132. package/dist/bundle/chunk-NL2DQPT7.js +0 -2
  133. package/dist/bundle/mcp-KHUZOJO6.js +0 -82
  134. package/dist/bundle/mcp-MZHWJXAE.js +0 -152
  135. package/dist/bundle/serve-PJWSOV7F.js +0 -19
  136. package/dist/bundle/server-2ZN7KZHZ.js +0 -40
  137. package/dist/bundle/server-5VYM7CCF.js +0 -53
  138. package/dist/bundle/store-maintenance-command-7XICIYHW.js +0 -42
  139. package/dist/commands/doctor.d.ts +0 -77
  140. package/dist/commands/doctor.d.ts.map +0 -1
  141. package/dist/commands/doctor.js +0 -74
  142. package/dist/commands/doctor.js.map +0 -1
  143. package/dist/commands/mcp.d.ts +0 -3
  144. package/dist/commands/mcp.d.ts.map +0 -1
  145. package/dist/commands/mcp.js +0 -97
  146. package/dist/commands/mcp.js.map +0 -1
  147. package/dist/mcp.d.ts +0 -29
  148. package/dist/mcp.d.ts.map +0 -1
  149. package/dist/mcp.js +0 -98
  150. package/dist/mcp.js.map +0 -1
  151. package/src/commands/doctor.ts +0 -86
  152. package/src/commands/mcp.ts +0 -130
  153. package/src/mcp-http.test.ts +0 -172
  154. package/src/mcp.ts +0 -140
package/src/mcp.test.ts CHANGED
@@ -1,88 +1,83 @@
1
- import { Client } from "@modelcontextprotocol/client"
2
- import { InMemoryTransport } from "@modelcontextprotocol/server"
3
- import { describeProgram } from "@wirecat/cli-base/commands"
4
- import { changesAuthorityAt } from "@wirecat/cli-core/cli"
5
- import { afterEach, expect, it } from "vitest"
6
- import { configuration } from "./config.js"
7
- import { createMcpServer, serveMcp } from "./mcp.js"
8
- import { createProgram, run } from "./program.js"
1
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs"
2
+ import { join } from "node:path"
3
+ import type { RunOptions } from "@wirecat/cli-core/cli"
4
+ import { mcpOverStdio } from "@wirecat/cli-core/testing"
5
+ import { expect, it } from "vitest"
6
+ import type { ServiceEnvironment } from "./context.js"
7
+ import { IDENTITY } from "./identity.js"
8
+ import { run } from "./program.js"
9
+ import { fakeComments } from "./testing/fake-comments.js"
10
+ import { runCli, signedIn } from "./testing/harness.js"
9
11
 
10
- const clients: Client[] = []
11
- afterEach(async () => {
12
- for (const client of clients.splice(0)) await client.close()
12
+ it("mcp serve offers search, read and write, and runs a command through the CLI", async () => {
13
+ const mcp = await mcpOverStdio((streams) => run(["personal", "mcp", "serve"], { tty: false, ...streams }))
14
+ try {
15
+ expect((await mcp.tools()).sort()).toEqual(["clidev_read", "clidev_tools_search", "clidev_write"])
16
+ const doctor = await mcp.call("clidev_read", { command: "doctor" })
17
+ expect(doctor.body).toMatchObject({ item: { version: IDENTITY.version } })
18
+ } finally {
19
+ expect(await mcp.close()).toBe(0)
20
+ }
13
21
  })
14
22
 
15
- const connect = async () => {
16
- const program = createProgram()
17
- const server = createMcpServer({
18
- profile: "personal",
19
- commands: describeProgram(program),
20
- levels: () => configuration.resolveSettings({ profile: "personal" }).permissions,
21
- authority: changesAuthorityAt(program),
22
- run,
23
- })
24
- const [clientSide, serverSide] = InMemoryTransport.createLinkedPair()
25
- await server.connect(serverSide)
26
- const client = new Client({ name: "test", version: "1.0.0" })
27
- await client.connect(clientSide)
28
- clients.push(client)
29
- return client
30
- }
31
-
32
- const isRecord = (value: unknown): value is Record<string, unknown> =>
33
- typeof value === "object" && value !== null && !Array.isArray(value)
34
- const structured = (result: unknown) => {
35
- if (!isRecord(result) || !isRecord(result.structuredContent))
36
- throw new Error("the tool answered without structured content")
37
- return result.structuredContent
38
- }
39
-
40
- it("offers search, read and write", async () => {
41
- const client = await connect()
42
- const { tools } = await client.listTools()
43
- expect(tools.map((tool) => tool.name).sort()).toEqual(["clidev_read", "clidev_tools_search", "clidev_write"])
23
+ it("mcp serve refuses a --permission that names no command, and a --profiles this computer lacks", async () => {
24
+ const unknown = await runCli(["personal", "mcp", "serve", "--permission", "comments.nope=deny"])
25
+ expect(unknown.code).toBe(2)
26
+ expect(unknown.err).toContain("comments.nope")
27
+ const missing = await runCli(["mcp", "serve", "--profiles", "nope"])
28
+ expect(missing.code).toBe(2)
29
+ expect(missing.err).toContain("nope")
44
30
  })
45
31
 
46
- it("finds writes as well as reads", async () => {
47
- const client = await connect()
48
- const found = structured(await client.callTool({ name: "clidev_tools_search", arguments: {} }))
49
- expect(found.items).toContainEqual(expect.objectContaining({ writes: true }))
50
- expect(found.items).not.toContainEqual(expect.objectContaining({ command: expect.stringMatching(/^mcp\b/) }))
32
+ it("mcp config prints an entry that serves the named profile, or every profile only when told", async () => {
33
+ const one = await runCli(["personal", "mcp", "config", "--permission", "comments.create=deny", "--json"])
34
+ const entry = one.json().item as { config: { mcpServers: Record<string, { args: string[] }> } }
35
+ expect(entry.config.mcpServers["clidev-personal"]?.args.slice(-5)).toEqual([
36
+ "personal",
37
+ "mcp",
38
+ "serve",
39
+ "--permission",
40
+ "comments.create=deny",
41
+ ])
42
+ const all = await runCli(["mcp", "config", "--profiles", "all", "--json"])
43
+ expect(JSON.stringify(all.json())).toContain('"--profiles","all"')
51
44
  })
52
45
 
53
- it("runs a command through the CLI and answers with its result", async () => {
54
- const client = await connect()
55
- const answer = structured(await client.callTool({ name: "clidev_read", arguments: { command: "doctor" } }))
56
- expect(answer.item).toMatchObject({ version: expect.any(String), profiles: [] })
46
+ it("mcp revoke answers for the profile it names", async () => {
47
+ const revoked = await runCli(["personal", "mcp", "revoke", "--json"])
48
+ expect(revoked.code).toBe(0)
49
+ expect(revoked.json().item).toMatchObject({ profile: "personal" })
57
50
  })
58
51
 
59
- it("serves until the command is stopped", async () => {
60
- const program = createProgram()
61
- const [clientSide, serverSide] = InMemoryTransport.createLinkedPair()
62
- const stop = new AbortController()
63
- const serving = serveMcp({
64
- profile: "personal",
65
- commands: describeProgram(program),
66
- levels: () => ({}),
67
- authority: () => false,
68
- run,
69
- signal: stop.signal,
70
- transport: serverSide,
71
- })
72
- const client = new Client({ name: "test", version: "1.0.0" })
73
- await client.connect(clientSide)
74
- expect((await client.listTools()).tools).toHaveLength(3)
75
- stop.abort()
76
- await expect(serving).resolves.toBeUndefined()
52
+ it("doctor --online answers even when npm cannot be reached", async () => {
53
+ const online = await runCli(["doctor", "--online", "--json"])
54
+ expect(online.code).toBe(0)
55
+ expect(online.json().item).toMatchObject({ version: IDENTITY.version, online: expect.any(Object) })
77
56
  })
78
57
 
79
- it("clidev <profile> mcp serve starts the server and ends on a signal", async () => {
80
- const stop = new AbortController()
81
- const serving = run(["personal", "mcp", "serve"], {
82
- signal: stop.signal,
83
- tty: false,
84
- streams: { data: () => {}, diagnostic: () => {} },
85
- })
86
- stop.abort()
87
- expect(await serving).toBe(0)
58
+ it("a denied command is offered as unavailable and refused before any request (WC-PERM-23)", async () => {
59
+ const file = join(process.env.CLIDEV_CONFIG_DIR ?? "", "config.json")
60
+ mkdirSync(join(file, ".."), { recursive: true })
61
+ writeFileSync(file, JSON.stringify({ profiles: { personal: { permissions: { "comments.list": "deny" } } } }))
62
+ const service = fakeComments()
63
+ const options: RunOptions & ServiceEnvironment = { tty: false, keyring: signedIn(), fetch: service.fetch }
64
+ const mcp = await mcpOverStdio((streams) => run(["personal", "mcp", "serve"], { ...options, ...streams }))
65
+ try {
66
+ const found = await mcp.call("clidev_tools_search", { query: "comments list" })
67
+ expect(found.body).toMatchObject({
68
+ items: expect.arrayContaining([
69
+ expect.objectContaining({
70
+ command: "comments list",
71
+ availability: expect.objectContaining({ available: false }),
72
+ }),
73
+ ]),
74
+ })
75
+ const listed = await mcp.call("clidev_read", { command: "comments list" })
76
+ expect(listed.isError).toBe(true)
77
+ expect(JSON.stringify(listed.body)).toContain("permission_error")
78
+ expect(service.calls).toEqual([])
79
+ } finally {
80
+ expect(await mcp.close()).toBe(0)
81
+ rmSync(file, { force: true })
82
+ }
88
83
  })
@@ -1,10 +1,11 @@
1
1
  import { chmodSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"
2
2
  import { join } from "node:path"
3
- import { expect, it, vi } from "vitest"
4
- import { doctorReport } from "./commands/doctor.js"
5
- import { configuration } from "./config.js"
3
+ import { annotate } from "@wirecat/cli-base/commands"
4
+ import { REMOTE_READ, run as runShared } from "@wirecat/cli-core/cli"
5
+ import { Command } from "commander"
6
+ import { expect, it } from "vitest"
6
7
  import { IDENTITY } from "./identity.js"
7
- import { run } from "./program.js"
8
+ import { definition, run } from "./program.js"
8
9
 
9
10
  const ran = async (argv: string[]) => {
10
11
  const out: string[] = []
@@ -66,20 +67,15 @@ it("doctor reports an unknown setting with the command that removes it", async (
66
67
  )
67
68
  })
68
69
 
69
- it("doctor reports settings an environment variable breaks, and still answers", () => {
70
- const { report } = doctorReport({ ...process.env, CLIDEV_EMBEDDING_DIMS: "many" })
71
- expect(report).toMatchObject({ config: { issues: [] }, settings: { error: expect.any(String) }, profiles: null })
72
- })
73
-
74
- it("doctor answers when reading the settings fails with an unexpected error", () => {
75
- const spy = vi.spyOn(configuration, "resolveSettings").mockImplementation(() => {
76
- throw new TypeError("boom")
70
+ it("doctor reports settings an environment variable breaks, and still answers", async () => {
71
+ const out: string[] = []
72
+ const code = await run(["doctor", "--json"], {
73
+ streams: { data: (text) => out.push(text), diagnostic: () => {} },
74
+ tty: false,
75
+ env: { ...process.env, CLIDEV_EMBEDDING_DIMS: "many" },
77
76
  })
78
- try {
79
- expect(doctorReport(process.env).report).toMatchObject({ settings: { error: "boom" }, profiles: null })
80
- } finally {
81
- spy.mockRestore()
82
- }
77
+ expect(code).toBe(0)
78
+ expect(json(out.join("")).item).toMatchObject({ config: { issues: [] }, version: IDENTITY.version })
83
79
  })
84
80
 
85
81
  it.skipIf(process.platform === "win32")(
@@ -233,3 +229,29 @@ it("config repair --dry-run changes nothing, and config repair fixes the JSON wi
233
229
  if (backup) rmSync(backup)
234
230
  }
235
231
  })
232
+
233
+ it("a deny in the profile stops a read command before its action runs", async () => {
234
+ const reached: string[] = []
235
+ const probe = annotate(new Command("probe"), { effects: REMOTE_READ, offline: false }).action(() => {
236
+ reached.push("probe")
237
+ })
238
+ const withProbe = { ...definition(), commands: () => [...definition().commands(), probe] }
239
+ const dir = process.env.CLIDEV_CONFIG_DIR ?? ""
240
+ mkdirSync(dir, { recursive: true })
241
+ writeFileSync(
242
+ join(dir, "config.json"),
243
+ JSON.stringify({ profiles: { personal: { permissions: { probe: "deny" } } } }),
244
+ )
245
+ const err: string[] = []
246
+ try {
247
+ const code = await runShared(["personal", "probe", "--json"], withProbe, {
248
+ streams: { data: () => {}, diagnostic: (text) => err.push(text) },
249
+ tty: false,
250
+ })
251
+ expect(code).not.toBe(0)
252
+ expect(err.join("\n")).toContain("permission_error")
253
+ expect(reached).toEqual([])
254
+ } finally {
255
+ rmSync(join(dir, "config.json"))
256
+ }
257
+ })
package/src/program.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  import { join } from "node:path"
2
- import { resolvePaths } from "@wirecat/cli-base"
2
+ import { type KeyringStore, resolvePaths } from "@wirecat/cli-base"
3
3
  import {
4
+ appDoctorCommand,
5
+ appMcpCommand,
4
6
  commandsCommand,
5
7
  configCommand,
6
8
  createProgram as createSharedProgram,
9
+ environmentOf,
7
10
  outputFor,
8
11
  type ProgramDefinition,
9
12
  type ProgramOptions,
@@ -15,10 +18,12 @@ import {
15
18
  } from "@wirecat/cli-core/cli"
16
19
  import { appUpdate } from "@wirecat/cli-core/update"
17
20
  import type { Command } from "commander"
18
- import { doctorCommand } from "./commands/doctor.js"
19
- import { mcpCommand } from "./commands/mcp.js"
21
+ import { commentsCommand } from "./commands/comments.js"
22
+ import { sessionCommand } from "./commands/session.js"
20
23
  import { configuration } from "./config.js"
21
24
  import { IDENTITY, PACKAGE, SKILL } from "./identity.js"
25
+ import { SERVICE } from "./sample/transport.js"
26
+ import { holdsToken } from "./token.js"
22
27
 
23
28
  const update = appUpdate({
24
29
  packageName: PACKAGE,
@@ -31,12 +36,32 @@ const update = appUpdate({
31
36
  offVariables: [`${IDENTITY.envPrefix}_NO_UPDATE_CHECK`],
32
37
  })
33
38
 
39
+ const mcpCommand = () => {
40
+ // cli-core asks `signedIn` with no keyring; the run's own one, a test's `memoryKeyring()`, is taken here. Each
41
+ // program builds its own `mcp`, so a tool call run beside it never swaps the server's keyring (WC-ARCH-22).
42
+ let keyring: KeyringStore | undefined
43
+ const mcp = appMcpCommand(IDENTITY, configuration, {
44
+ name: SERVICE,
45
+ // SAMPLE: name the kinds of text other people wrote that your answers carry (WC-MCP-11).
46
+ untrusted: ["comment text", "author names"],
47
+ permissionExample: "comments.create=deny",
48
+ skill: SKILL,
49
+ signedIn: (profile, env) => holdsToken(env, profile, keyring),
50
+ })
51
+ return mcp.hook("preAction", (_mcp, action) => {
52
+ keyring = environmentOf(action).keyring
53
+ })
54
+ }
55
+
34
56
  export const definition = (): ProgramDefinition => ({
35
57
  app: IDENTITY,
36
- configuration: { resolveSettings: configuration.resolveSettings },
58
+ // The whole object: cli-core refuses a command by its key only when it gets the permission table too.
59
+ configuration,
37
60
  commands: () => [
38
61
  commandsCommand(IDENTITY),
39
- doctorCommand(),
62
+ sessionCommand(),
63
+ commentsCommand(),
64
+ appDoctorCommand(IDENTITY, configuration, { packageName: PACKAGE }),
40
65
  skillCommand(IDENTITY, SKILL),
41
66
  mcpCommand(),
42
67
  upgradeCommand(IDENTITY, PACKAGE, (command) => ({
@@ -0,0 +1,51 @@
1
+ import * as v from "valibot"
2
+ import type { Transport } from "./transport.js"
3
+
4
+ // SAMPLE: replace these shapes with your service's. Each wire name comes from the service's docs or a captured answer,
5
+ // never from memory; the strict fake in src/testing/fake-comments.ts refuses any other.
6
+ const wireComment = v.object({
7
+ id: v.string(),
8
+ body: v.string(),
9
+ authorName: v.string(),
10
+ createdAt: v.string(),
11
+ })
12
+ const wirePage = v.object({ comments: v.array(wireComment), hasMore: v.boolean() })
13
+ const wireUser = v.object({ id: v.string(), name: v.string() })
14
+
15
+ /**
16
+ * The answer's own shape, in snake_case (WC-OUT-12). `answerEntity` publishes it, so `--json=id,text` and the MCP
17
+ * schema both come from here (WC-CMD-19). No wire type leaves this file (WC-ARCH-3).
18
+ */
19
+ export const commentSchema = v.strictObject({
20
+ id: v.string(),
21
+ text: v.string(),
22
+ author: v.string(),
23
+ created_at: v.string(),
24
+ })
25
+ export type Comment = v.InferOutput<typeof commentSchema>
26
+
27
+ export const commentOf = (wire: v.InferOutput<typeof wireComment>): Comment => ({
28
+ id: wire.id,
29
+ text: wire.body,
30
+ author: wire.authorName,
31
+ created_at: wire.createdAt,
32
+ })
33
+
34
+ /** Every call the sample makes: a method, a path template and only the fields the service documents. */
35
+ export const commentsService = (api: Transport) => ({
36
+ me: () => api.send({ method: "GET", path: "me" }, wireUser),
37
+ list: async ({ limit, offset, author }: { limit: number; offset: number; author?: string | undefined }) => {
38
+ const page = await api.send({ method: "GET", path: "comments", query: { limit, offset, by: author } }, wirePage)
39
+ return { items: page.comments.map(commentOf), has_more: page.hasMore }
40
+ },
41
+ show: async (id: string) =>
42
+ commentOf(await api.send({ method: "GET", path: "comments/{id}", ids: { id } }, wireComment)),
43
+ create: async (text: string) =>
44
+ commentOf(await api.send({ method: "POST", path: "comments", body: { body: text } }, wireComment)),
45
+ update: async (id: string, text: string) =>
46
+ commentOf(
47
+ await api.send({ method: "PATCH", path: "comments/{id}", ids: { id }, body: { body: text } }, wireComment),
48
+ ),
49
+ })
50
+
51
+ export type CommentsService = ReturnType<typeof commentsService>
@@ -0,0 +1,117 @@
1
+ import type { Pacer } from "@wirecat/cli-core/sends"
2
+ import * as v from "valibot"
3
+ import { expect, it } from "vitest"
4
+ import { FAKE_TOKEN, fakeComments } from "../testing/fake-comments.js"
5
+ import { commentOf, commentsService } from "./comments.js"
6
+ import { failureOf, transport } from "./transport.js"
7
+
8
+ const anything = v.unknown()
9
+ const failing = (error: unknown) => async () => Promise.reject(error)
10
+ const timedOut = () => new DOMException("timed out", "TimeoutError")
11
+
12
+ it("maps the wire shape to the answer's own", () => {
13
+ expect(commentOf({ id: "c1", body: "Hi", authorName: "Test Person", createdAt: "2026-01-02T03:04:05Z" })).toEqual({
14
+ id: "c1",
15
+ text: "Hi",
16
+ author: "Test Person",
17
+ created_at: "2026-01-02T03:04:05Z",
18
+ })
19
+ })
20
+
21
+ it.each([
22
+ [401, false, "authentication_error"],
23
+ [404, false, "not_found"],
24
+ [429, true, "rate_limited"],
25
+ [503, false, "provider_unavailable"],
26
+ [503, true, "outcome_unknown"],
27
+ [400, true, "validation_error"],
28
+ ])("HTTP %i (a write: %s) is %s", (status, writes, code) => {
29
+ expect(failureOf(status, writes).code).toBe(code)
30
+ })
31
+
32
+ it("a write that failed on the service is never marked safe to retry", () => {
33
+ expect(failureOf(500, true).details).toMatchObject({ retryable: false })
34
+ expect(failureOf(429, false, 2000).details).toMatchObject({ retryable: true, retry_after_ms: 2000 })
35
+ })
36
+
37
+ it("sends the token in the Authorization header only, and refuses a redirect", async () => {
38
+ const service = fakeComments()
39
+ const sent: RequestInit[] = []
40
+ await commentsService(
41
+ transport({
42
+ token: FAKE_TOKEN,
43
+ fetch: (input, init = {}) => {
44
+ sent.push(init)
45
+ return service.fetch(input, init)
46
+ },
47
+ }),
48
+ ).me()
49
+ expect(service.calls[0]?.url).not.toContain(FAKE_TOKEN)
50
+ expect(service.calls[0]?.headers.authorization).toBe(`Bearer ${FAKE_TOKEN}`)
51
+ expect(sent[0]?.redirect).toBe("error")
52
+ })
53
+
54
+ it('refuses an id of "." or ".." before any request, since it would reach another endpoint', async () => {
55
+ const service = fakeComments()
56
+ const comments = commentsService(transport({ token: FAKE_TOKEN, fetch: service.fetch }))
57
+ await expect(comments.show("..")).rejects.toMatchObject({ code: "validation_error" })
58
+ expect(service.calls).toEqual([])
59
+ })
60
+
61
+ it("a read that got no answer is a retryable timeout or network error; a write is outcome_unknown", async () => {
62
+ const send = (error: unknown, method: "GET" | "POST") =>
63
+ transport({ token: FAKE_TOKEN, fetch: failing(error) }).send({ method, path: "comments", body: {} }, anything)
64
+ await expect(send(timedOut(), "GET")).rejects.toMatchObject({ code: "timeout", details: { retryable: true } })
65
+ await expect(send(new TypeError("fetch failed"), "GET")).rejects.toMatchObject({ code: "network_error" })
66
+ await expect(send(timedOut(), "POST")).rejects.toMatchObject({
67
+ code: "outcome_unknown",
68
+ details: { retryable: false },
69
+ })
70
+ })
71
+
72
+ it("a stop during a read cancels it; during a write it stays outcome_unknown", async () => {
73
+ const stop = new AbortController()
74
+ stop.abort()
75
+ const send = (method: "GET" | "PATCH") =>
76
+ transport({ token: FAKE_TOKEN, fetch: failing(new Error("aborted")), signal: stop.signal }).send(
77
+ { method, path: "comments" },
78
+ anything,
79
+ )
80
+ await expect(send("GET")).rejects.toMatchObject({ code: "cancelled" })
81
+ await expect(send("PATCH")).rejects.toMatchObject({ code: "outcome_unknown" })
82
+ })
83
+
84
+ it("an answer that is not JSON is invalid_response", async () => {
85
+ const fetch = async () => new Response("<html>", { status: 200 })
86
+ await expect(commentsService(transport({ token: FAKE_TOKEN, fetch })).me()).rejects.toMatchObject({
87
+ code: "invalid_response",
88
+ })
89
+ })
90
+
91
+ it("an answer in another shape is invalid_response", async () => {
92
+ const fetch = async () => new Response(JSON.stringify({ unexpected: true }), { status: 200 })
93
+ await expect(commentsService(transport({ token: FAKE_TOKEN, fetch })).me()).rejects.toMatchObject({
94
+ code: "invalid_response",
95
+ })
96
+ })
97
+
98
+ it("waits its turn under the profile's pace, and ends the run instead of a long wait", async () => {
99
+ const service = fakeComments()
100
+ const waits: number[] = []
101
+ const paced = (at: number) =>
102
+ commentsService(
103
+ transport({
104
+ token: FAKE_TOKEN,
105
+ fetch: service.fetch,
106
+ now: () => 1_000,
107
+ pacer: { reserve: async () => at } as unknown as Pacer,
108
+ sleep: async (ms) => {
109
+ waits.push(ms)
110
+ },
111
+ }),
112
+ ).me()
113
+ await paced(1_500)
114
+ expect(waits).toEqual([500])
115
+ await expect(paced(1_000 + 120_000)).rejects.toMatchObject({ code: "rate_limited" })
116
+ expect(service.calls).toHaveLength(1)
117
+ })
@@ -0,0 +1,184 @@
1
+ import { CliError, isCliError, realSleep, type SleepLike, wallClock } from "@wirecat/cli-base"
2
+ import { providerWaitMs, readJsonResponse, statusToCode } from "@wirecat/cli-base/http"
3
+ import { type EventSink, providerRequest } from "@wirecat/cli-core/cli"
4
+ import type { Pacer } from "@wirecat/cli-core/sends"
5
+ import * as v from "valibot"
6
+
7
+ // SAMPLE: the Example Comments service does not exist; `.invalid` never resolves, so a real run reaches nothing.
8
+ // Replace the address, the paths and the answer shapes with your service's, each from its docs or a captured answer.
9
+ export const SERVICE_URL = "https://comments.example.invalid/v1/"
10
+ export const SERVICE = "Example Comments"
11
+
12
+ const TIMEOUT_MS = 30_000
13
+ const LARGEST_ANSWER = 8 * 1024 * 1024
14
+ /** A pace wait longer than this ends the run with `rate_limited` instead of sitting in it. */
15
+ const LONGEST_PACE_WAIT_MS = 60_000
16
+
17
+ export interface Request {
18
+ method: "GET" | "POST" | "PATCH"
19
+ /** The path as a template, `comments/{id}`: the run record keeps the template, never an id. */
20
+ path: string
21
+ ids?: Record<string, string>
22
+ query?: Record<string, string | number | undefined>
23
+ body?: Record<string, unknown>
24
+ }
25
+
26
+ export interface TransportOptions {
27
+ token: string
28
+ fetch?: typeof fetch
29
+ signal?: AbortSignal | undefined
30
+ /** The run's sink from `baseContext(...).run(events => …)`, so each request lands in the run record (WC-LIFE-15). */
31
+ events?: EventSink
32
+ /** cli-core's per-profile pace, shared by every process of the profile through a file. */
33
+ pacer?: Pacer
34
+ sleep?: SleepLike
35
+ now?: () => number
36
+ timeoutMs?: number
37
+ }
38
+
39
+ /** `new URL` folds a `.` or `..` segment into its parent, so such an id would reach another endpoint. */
40
+ const segment = (name: string, value: string) => {
41
+ if (value === "" || value === "." || value === "..")
42
+ throw new CliError("validation_error", `${name} must name one item; an empty, "." or ".." id is refused`)
43
+ return encodeURIComponent(value)
44
+ }
45
+
46
+ const urlOf = ({ path, ids = {}, query = {} }: Request) => {
47
+ const url = new URL(
48
+ path.replace(/\{(\w+)\}/g, (_, name: string) => segment(name, ids[name] ?? "")),
49
+ SERVICE_URL,
50
+ )
51
+ for (const [key, value] of Object.entries(query)) if (value !== undefined) url.searchParams.set(key, String(value))
52
+ return url
53
+ }
54
+
55
+ /**
56
+ * A refusal as a CliError with cli-base's code for its status. The service's own message is never repeated: it can
57
+ * quote what was sent (WC-SECR-6). A write that failed on the service's side may have happened, so it is
58
+ * `outcome_unknown` and never retryable (WC-OUT-20, WC-LIFE-9).
59
+ */
60
+ export const failureOf = (status: number, writes: boolean, retryAfterMs?: number): CliError => {
61
+ if (status === 401)
62
+ return new CliError("authentication_error", `${SERVICE} refused the token; sign in again with session start`, {
63
+ status,
64
+ })
65
+ if (status === 429)
66
+ return new CliError("rate_limited", `${SERVICE} asked to slow down; retry the command later`, {
67
+ status,
68
+ retryable: true,
69
+ ...(retryAfterMs === undefined ? {} : { retry_after_ms: retryAfterMs }),
70
+ })
71
+ if (status >= 500 && writes)
72
+ return new CliError("outcome_unknown", `${SERVICE} failed during a write; check it before repeating the write`, {
73
+ status,
74
+ retryable: false,
75
+ })
76
+ if (status === 404) return new CliError("not_found", `${SERVICE} has no such item, or this token cannot see it`)
77
+ return new CliError(statusToCode(status), `${SERVICE} refused the request (HTTP ${status})`, {
78
+ status,
79
+ retryable: status >= 500,
80
+ })
81
+ }
82
+
83
+ /**
84
+ * The only code that holds the token: it goes in the Authorization header, never in an address, an error or a run
85
+ * record (WC-SECR-7). Nothing here retries. A read the caller may run again; a write is never sent twice, since the
86
+ * first may have landed (WC-OUT-20).
87
+ */
88
+ export const transport = (options: TransportOptions) => {
89
+ const fetchOf = options.fetch ?? fetch
90
+ const sleep = options.sleep ?? realSleep
91
+ const now = options.now ?? Date.now
92
+
93
+ const turn = async () => {
94
+ if (!options.pacer) return
95
+ const wait = (await options.pacer.reserve()) - now()
96
+ if (wait <= 0) return
97
+ if (wait > LONGEST_PACE_WAIT_MS)
98
+ throw new CliError("rate_limited", `this profile must wait ${Math.ceil(wait / 1000)} s before its next request`, {
99
+ retry_after_ms: wait,
100
+ })
101
+ await sleep(wait, options.signal, "retry")
102
+ }
103
+
104
+ const unanswered = (writes: boolean, error: unknown): CliError => {
105
+ // Before the stop check: a write stopped in flight may still have landed (WC-LIFE-9).
106
+ if (writes)
107
+ return new CliError("outcome_unknown", `${SERVICE} did not answer a write; check it before repeating the write`, {
108
+ retryable: false,
109
+ })
110
+ if (options.signal?.aborted) return new CliError("cancelled", `the ${SERVICE} request was stopped`)
111
+ const timedOut = (error as { name?: string } | undefined)?.name === "TimeoutError"
112
+ return timedOut
113
+ ? new CliError("timeout", `${SERVICE} did not answer in time`, { retryable: true })
114
+ : new CliError("network_error", `${SERVICE} could not be reached`, { retryable: true })
115
+ }
116
+
117
+ const send = async <T>(request: Request, answer: v.GenericSchema<unknown, T>): Promise<T> => {
118
+ const writes = request.method !== "GET"
119
+ const url = urlOf(request)
120
+ await turn()
121
+ // Not AbortSignal.timeout: its timer keeps the run's async context alive until it fires.
122
+ const deadline = new AbortController()
123
+ const timer = setTimeout(
124
+ () => deadline.abort(new DOMException("timed out", "TimeoutError")),
125
+ options.timeoutMs ?? TIMEOUT_MS,
126
+ )
127
+ timer.unref()
128
+ const signal = options.signal ? AbortSignal.any([options.signal, deadline.signal]) : deadline.signal
129
+ const end = providerRequest(`${request.method} /${request.path}`, options.events, {
130
+ method: request.method,
131
+ transport: "http",
132
+ })
133
+ try {
134
+ let response: Response
135
+ try {
136
+ response = await fetchOf(url, {
137
+ method: request.method,
138
+ headers: {
139
+ authorization: `Bearer ${options.token}`,
140
+ accept: "application/json",
141
+ ...(request.body ? { "content-type": "application/json" } : {}),
142
+ },
143
+ ...(request.body ? { body: JSON.stringify(request.body) } : {}),
144
+ // A redirect would carry the Authorization header to an address we did not choose.
145
+ redirect: "error",
146
+ signal,
147
+ })
148
+ } catch (error) {
149
+ const failure = unanswered(writes, deadline.signal.reason ?? error)
150
+ end({ errorCode: failure.code })
151
+ throw failure
152
+ }
153
+ if (!response.ok) {
154
+ const failure = failureOf(response.status, writes, providerWaitMs(response.headers, wallClock))
155
+ end({ status: response.status, errorCode: failure.code })
156
+ throw failure
157
+ }
158
+ let body: unknown
159
+ try {
160
+ body = await readJsonResponse(response, { maxBytes: LARGEST_ANSWER, signal })
161
+ } catch (error) {
162
+ const failure =
163
+ isCliError(error) && error.code !== "invalid_response"
164
+ ? error
165
+ : new CliError("invalid_response", `${SERVICE} answered with something other than bounded JSON`)
166
+ end({ status: response.status, errorCode: failure.code })
167
+ throw failure
168
+ }
169
+ const parsed = v.safeParse(answer, body)
170
+ if (!parsed.success) {
171
+ end({ status: response.status, errorCode: "invalid_response" })
172
+ throw new CliError("invalid_response", `${SERVICE}'s answer does not have the documented shape`)
173
+ }
174
+ end({ status: response.status })
175
+ return parsed.output
176
+ } finally {
177
+ clearTimeout(timer)
178
+ }
179
+ }
180
+
181
+ return { send }
182
+ }
183
+
184
+ export type Transport = ReturnType<typeof transport>