@wirecat/cli-template 0.15.0 → 0.17.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/.github/workflows/ci.yml +35 -0
- package/.github/workflows/release-pr.yml +37 -0
- package/AGENTS.md +56 -2
- package/CHANGELOG.md +50 -0
- package/README.md +12 -4
- package/bin/release +10 -3
- package/biome.json +8 -2
- package/contracts/mcp-tools.json +2 -2
- package/contracts/plan.json +2 -1
- package/dist/bin/clidev.js +2 -2
- package/dist/bundle/{chunk-LU4A2T7Y.js → all-profiles-CCT2XPJL.js} +10 -7
- package/dist/bundle/{anthropic-ICTW7CNQ.js → anthropic-QLLP7EE4.js} +4 -4
- package/dist/bundle/{app-server-OW5LT5XV.js → app-server-ELI5TGL7.js} +36 -37
- package/dist/bundle/{bun-ILPS6AYA.js → bun-C7JS2V7G.js} +2 -2
- package/dist/bundle/{bun-sqlite-JE6FJFQT.js → bun-sqlite-OM72TCYT.js} +3 -3
- package/dist/bundle/{chunk-7F5VMLVK.js → chunk-2Y5NBD7U.js} +4 -4
- package/dist/bundle/chunk-33JGD2CR.js +141 -0
- package/dist/bundle/{chunk-F34HGYWF.js → chunk-3VVIEZRY.js} +2 -2
- package/dist/bundle/{chunk-CGFQT3LQ.js → chunk-6PHTRAZA.js} +1 -1
- package/dist/bundle/{chunk-VS5LGSTM.js → chunk-6VCFDEHT.js} +1 -1
- package/dist/bundle/{chunk-NENYWEXF.js → chunk-7OQF5ZMN.js} +1 -1
- package/dist/bundle/{chunk-62JRF2JA.js → chunk-AKUMR7PE.js} +2 -2
- package/dist/bundle/{chunk-7UPBPE3Z.js → chunk-ASO23RNM.js} +152 -79
- package/dist/bundle/{chunk-VOTEB3AN.js → chunk-AUVPTFUS.js} +1 -1
- package/dist/bundle/{chunk-KLFYY7KC.js → chunk-BOCIYBBO.js} +53 -44
- package/dist/bundle/{chunk-TJJ2XSJP.js → chunk-BXZRTMXA.js} +5 -5
- package/dist/bundle/{chunk-66VMJGV6.js → chunk-DVLM23HB.js} +2 -2
- package/dist/bundle/{chunk-YIVY2WI3.js → chunk-E4KQOKQ5.js} +1 -1
- package/dist/bundle/{chunk-GHF72SHT.js → chunk-EQDVQN4E.js} +306 -258
- package/dist/bundle/{chunk-NH6LZ3H3.js → chunk-FF3AGTHY.js} +6 -6
- package/dist/bundle/{chunk-NG7MOJBT.js → chunk-GG5PXNMH.js} +61 -21
- package/dist/bundle/{chunk-LFLSV353.js → chunk-GUCMIUNB.js} +2 -2
- package/dist/bundle/{chunk-R5RYPD23.js → chunk-HVAEHUSZ.js} +2 -2
- package/dist/bundle/{chunk-PIJUGF5F.js → chunk-ITPFCKCL.js} +13 -13
- package/dist/bundle/{chunk-DQIBGCF3.js → chunk-J2RUBXCI.js} +6 -6
- package/dist/bundle/{chunk-7Z4HR6GO.js → chunk-KRXN3FV3.js} +1 -1
- package/dist/bundle/{chunk-45244QQ6.js → chunk-LFLN6PVS.js} +4 -4
- package/dist/bundle/{chunk-MWUHBEDN.js → chunk-LN53L2V5.js} +11 -11
- package/dist/bundle/{chunk-IOJ2DTCY.js → chunk-MLSN3UP4.js} +28 -26
- package/dist/bundle/{chunk-3MDNAYQA.js → chunk-O6PBO4BU.js} +1 -1
- package/dist/bundle/{chunk-YOJLIVGO.js → chunk-QJS4WWYK.js} +63 -63
- package/dist/bundle/{chunk-NVNUUHT5.js → chunk-QLBS6OC2.js} +2362 -1225
- package/dist/bundle/{chunk-2DAXUNTS.js → chunk-QVPTZDRA.js} +2 -2
- package/dist/bundle/{chunk-4MU2UM4C.js → chunk-R5CPFSPI.js} +7 -7
- package/dist/bundle/{chunk-AS2PJNQ2.js → chunk-R5IY4O25.js} +4 -4
- package/dist/bundle/{chunk-DZLHY4IA.js → chunk-RGKXMVOZ.js} +3 -3
- package/dist/bundle/{chunk-G45R7M42.js → chunk-RT46AWIH.js} +1 -1
- package/dist/bundle/{chunk-NRZV7K4M.js → chunk-RUHNHEAF.js} +3 -3
- package/dist/bundle/{chunk-VXOR76ZL.js → chunk-S465TCR3.js} +8 -8
- package/dist/bundle/{chunk-NVGOHYTE.js → chunk-SQKP6V4S.js} +27 -13
- package/dist/bundle/{chunk-FC53WQHG.js → chunk-SQOXYNKF.js} +1 -1
- package/dist/bundle/{chunk-R2JKPHH5.js → chunk-URPKIONS.js} +8 -8
- package/dist/bundle/{chunk-VVGPAHUN.js → chunk-UW4PJZVJ.js} +298 -170
- package/dist/bundle/{chunk-EX2FV7YJ.js → chunk-WKUJCPSP.js} +89 -45
- package/dist/bundle/{chunk-LATYBJ4T.js → chunk-WQBBSVZM.js} +2 -2
- package/dist/bundle/{chunk-G7W53MQP.js → chunk-WRLOK7XC.js} +3 -3
- package/dist/bundle/{chunk-SOYOOU2D.js → chunk-X44ZT64I.js} +3 -3
- package/dist/bundle/{chunk-U6QD3J3F.js → chunk-XKL5FRH3.js} +3 -3
- package/dist/bundle/{chunk-6AW4DDK4.js → chunk-XS56LIQ7.js} +4 -4
- package/dist/bundle/{chunk-3WDYHOAF.js → chunk-YIH3PYWJ.js} +1 -1
- package/dist/bundle/{chunk-5GTMQTQH.js → chunk-ZRNBKTPP.js} +27 -29
- package/dist/bundle/clidev.js +3 -3
- package/dist/bundle/{command-contract-3MPKXBVL.js → command-contract-RV5ZOEAT.js} +11 -11
- package/dist/bundle/{decode-NXB5UKMO.js → decode-V7HNHZBC.js} +3 -3
- package/dist/bundle/{echarts-DRDGGHJT.js → echarts-Y42YP2OL.js} +2 -2
- package/dist/bundle/{embed-IKZ24BMC.js → embed-JSQZL7JF.js} +4 -4
- package/dist/bundle/{embedder-GMQ24DOQ.js → embedder-7BHRCXWL.js} +7 -7
- package/dist/bundle/{first-run-PURIGDLD.js → first-run-BJ36KGST.js} +8 -8
- package/dist/bundle/first-screen-5YHQVNY4.js +26 -0
- package/dist/bundle/{instructions-EOQOXY6V.js → instructions-UWERND54.js} +1 -1
- package/dist/bundle/{mcp-7HBLFGCW.js → mcp-P4FSBSRS.js} +2 -2
- package/dist/bundle/{node-52WF2326.js → node-PZDNPKN7.js} +2 -2
- package/dist/bundle/{node-sqlite-73Z4SFOA.js → node-sqlite-4F5NLRNT.js} +4 -4
- package/dist/bundle/{oauth-EVTPAMZP.js → oauth-VDXNA3DK.js} +5 -5
- package/dist/bundle/{open-46WZAPJ3.js → open-EUJRQQNS.js} +3 -3
- package/dist/bundle/{openai-GWRDAHDU.js → openai-EIBV2K72.js} +14 -35
- package/dist/bundle/{process-YIQPKLZI.js → process-XD5ZIFIL.js} +1 -1
- package/dist/bundle/{program-37NVH5YJ.js → program-TJTVMIOX.js} +3067 -736
- package/dist/bundle/{prompt-IVEYRLS5.js → prompt-LYUZNHDK.js} +3 -3
- package/dist/bundle/{recognize-WVEKHODM.js → recognize-LQMHLOD5.js} +2 -2
- package/dist/bundle/{remote-4K5VPMC7.js → remote-34HUWSZI.js} +5 -5
- package/dist/bundle/{rerank-V24UJZYJ.js → rerank-ONBASQ5Z.js} +5 -5
- package/dist/bundle/{chunk-EMHCXXNJ.js → serve-UJ7FDYAM.js} +8 -6
- package/dist/bundle/{chunk-KJ3UADR3.js → server-GNT76BXM.js} +29 -9
- package/dist/bundle/server-J4YIXKMI.js +52 -0
- package/dist/bundle/{state-ZPYGECVP.js → state-IW5OO6IZ.js} +4 -4
- package/dist/bundle/{store-6JQCLPQF.js → store-55UB27H2.js} +12 -10
- package/dist/bundle/store-maintenance-command-BLDKUZ3L.js +42 -0
- package/dist/commands/comments.d.ts +3 -0
- package/dist/commands/comments.d.ts.map +1 -0
- package/dist/commands/comments.js +114 -0
- package/dist/commands/comments.js.map +1 -0
- package/dist/commands/session.d.ts +3 -0
- package/dist/commands/session.d.ts.map +1 -0
- package/dist/commands/session.js +96 -0
- package/dist/commands/session.js.map +1 -0
- package/dist/context.d.ts +83 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +58 -0
- package/dist/context.js.map +1 -0
- package/dist/program.d.ts.map +1 -1
- package/dist/program.js +25 -4
- package/dist/program.js.map +1 -1
- package/dist/sample/comments.d.ts +61 -0
- package/dist/sample/comments.d.ts.map +1 -0
- package/dist/sample/comments.js +39 -0
- package/dist/sample/comments.js.map +1 -0
- package/dist/sample/transport.d.ts +42 -0
- package/dist/sample/transport.d.ts.map +1 -0
- package/dist/sample/transport.js +152 -0
- package/dist/sample/transport.js.map +1 -0
- package/dist/token.d.ts +16 -0
- package/dist/token.d.ts.map +1 -0
- package/dist/token.js +32 -0
- package/dist/token.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/commands.md +234 -12
- package/docs/configuration.md +1 -1
- package/docs/dev/reference-clis.md +3 -2
- package/docs/dev/skills/release/SKILL.md +9 -3
- package/docs/dev/skills/release/fresh-audit.md +1 -1
- package/docs/dev/test-matrix.md +36 -5
- package/docs/index.md +4 -3
- package/docs/security.md +4 -2
- package/kit/dist/new.js +7 -3
- package/lint/overrides.json +9 -1
- package/package.json +6 -7
- package/scripts/test-matrix-untested.ts +19 -0
- package/skills/clidev-cli/SKILL.md +1 -1
- package/src/commands/comments.ts +167 -0
- package/src/commands/session.ts +144 -0
- package/src/comments.test.ts +258 -0
- package/src/context.ts +78 -0
- package/src/mcp.test.ts +71 -76
- package/src/program.test.ts +9 -16
- package/src/program.ts +29 -4
- package/src/sample/comments.ts +51 -0
- package/src/sample/transport.test.ts +117 -0
- package/src/sample/transport.ts +184 -0
- package/src/testing/fake-comments.ts +167 -0
- package/src/testing/harness.ts +51 -0
- package/src/token.ts +42 -0
- package/src/version.ts +1 -1
- package/dist/bundle/all-profiles-NVB2HT2S.js +0 -24
- package/dist/bundle/chunk-GUFPQZUA.js +0 -48
- package/dist/bundle/chunk-NL2DQPT7.js +0 -2
- package/dist/bundle/first-screen-B2CF2CET.js +0 -26
- package/dist/bundle/mcp-ASNYBQ5B.js +0 -152
- package/dist/bundle/mcp-EY2NUYDQ.js +0 -82
- package/dist/bundle/serve-PJWSOV7F.js +0 -19
- package/dist/bundle/server-3TPCEZ6P.js +0 -40
- package/dist/bundle/server-GQX5MZTT.js +0 -53
- package/dist/bundle/store-maintenance-command-ND4ADMGC.js +0 -42
- package/dist/commands/doctor.d.ts +0 -77
- package/dist/commands/doctor.d.ts.map +0 -1
- package/dist/commands/doctor.js +0 -74
- package/dist/commands/doctor.js.map +0 -1
- package/dist/commands/mcp.d.ts +0 -3
- package/dist/commands/mcp.d.ts.map +0 -1
- package/dist/commands/mcp.js +0 -97
- package/dist/commands/mcp.js.map +0 -1
- package/dist/mcp.d.ts +0 -29
- package/dist/mcp.d.ts.map +0 -1
- package/dist/mcp.js +0 -98
- package/dist/mcp.js.map +0 -1
- package/src/commands/doctor.ts +0 -86
- package/src/commands/mcp.ts +0 -130
- package/src/mcp-http.test.ts +0 -172
- package/src/mcp.ts +0 -140
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { CliError } from "@wirecat/cli-base"
|
|
2
|
+
import { annotate } from "@wirecat/cli-base/commands"
|
|
3
|
+
import {
|
|
4
|
+
answerEntity,
|
|
5
|
+
commandPath,
|
|
6
|
+
itemOf,
|
|
7
|
+
outputFor,
|
|
8
|
+
pagedList,
|
|
9
|
+
pageOf,
|
|
10
|
+
queryTerms,
|
|
11
|
+
REMOTE_READ,
|
|
12
|
+
REMOTE_WRITE,
|
|
13
|
+
renderAnswer,
|
|
14
|
+
termField,
|
|
15
|
+
terminalAsker,
|
|
16
|
+
withPaging,
|
|
17
|
+
withQuery,
|
|
18
|
+
} from "@wirecat/cli-core/cli"
|
|
19
|
+
import { guardedWrite, guardFor, keyedGuard, levelFor, newOperationId } from "@wirecat/cli-core/sends"
|
|
20
|
+
import { Command } from "commander"
|
|
21
|
+
import { permissions } from "../config.js"
|
|
22
|
+
import { contextOf, withService } from "../context.js"
|
|
23
|
+
import { IDENTITY } from "../identity.js"
|
|
24
|
+
import { type Comment, type CommentsService, commentSchema } from "../sample/comments.js"
|
|
25
|
+
import { SERVICE } from "../sample/transport.js"
|
|
26
|
+
|
|
27
|
+
// SAMPLE: `comments` is the reference command group. Keep its shape; replace the noun and the calls with your
|
|
28
|
+
// service's. Every command path is its own permission key (`comments.create`), from `commandPermissions()`.
|
|
29
|
+
|
|
30
|
+
const CLI = IDENTITY.command
|
|
31
|
+
|
|
32
|
+
/** The list's query: one field the service filters itself (WC-CMD-20). Nothing is filtered in memory. */
|
|
33
|
+
const COMMENTS_QUERY = {
|
|
34
|
+
entity: "comment",
|
|
35
|
+
backend: "native",
|
|
36
|
+
fields: [termField("author", "kw", "author:u2")],
|
|
37
|
+
} as const
|
|
38
|
+
|
|
39
|
+
/** --all walks pages until the service has no more, at most this many, so a huge account cannot hold the run open. */
|
|
40
|
+
const ALL_PAGES = 20
|
|
41
|
+
const ALL_PAGE_SIZE = 100
|
|
42
|
+
|
|
43
|
+
const listAll = async (service: CommentsService, author: string | undefined) => {
|
|
44
|
+
const items: Comment[] = []
|
|
45
|
+
for (let page = 0; page < ALL_PAGES; page++) {
|
|
46
|
+
const found = await service.list({ limit: ALL_PAGE_SIZE, offset: page * ALL_PAGE_SIZE, author })
|
|
47
|
+
for (const item of found.items) items.push(item)
|
|
48
|
+
if (!found.has_more) return { items, has_more: false }
|
|
49
|
+
}
|
|
50
|
+
return { items, has_more: true }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const listCommand = () =>
|
|
54
|
+
withPaging(
|
|
55
|
+
withQuery(
|
|
56
|
+
answerEntity(
|
|
57
|
+
annotate(new Command("list"), {
|
|
58
|
+
effects: REMOTE_READ,
|
|
59
|
+
offline: false,
|
|
60
|
+
examples: [`${CLI} personal comments list`, `${CLI} personal comments list author:u2 --json=id,text`],
|
|
61
|
+
}),
|
|
62
|
+
"comment",
|
|
63
|
+
commentSchema,
|
|
64
|
+
),
|
|
65
|
+
COMMENTS_QUERY,
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
.description(`list the comments in ${SERVICE}, newest first`)
|
|
69
|
+
.action(async function (this: Command, words: string[]) {
|
|
70
|
+
const path = commandPath(this)
|
|
71
|
+
const author = queryTerms(words, COMMENTS_QUERY, path).get("author")
|
|
72
|
+
const { settings } = contextOf(this)
|
|
73
|
+
const found = await withService(this, (service) =>
|
|
74
|
+
settings.all ? listAll(service, author) : service.list({ ...pageOf(settings), author }),
|
|
75
|
+
)
|
|
76
|
+
renderAnswer(this, outputFor(this), pagedList(path, settings, found))
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
const showCommand = () =>
|
|
80
|
+
answerEntity(
|
|
81
|
+
annotate(new Command("show"), {
|
|
82
|
+
effects: REMOTE_READ,
|
|
83
|
+
offline: false,
|
|
84
|
+
examples: [`${CLI} personal comments show c1`, `${CLI} personal comments show c1 --json`],
|
|
85
|
+
}),
|
|
86
|
+
"comment",
|
|
87
|
+
commentSchema,
|
|
88
|
+
)
|
|
89
|
+
.description(`show one comment in ${SERVICE}`)
|
|
90
|
+
.argument("<id>", "the comment's id, as list prints it")
|
|
91
|
+
.action(async function (this: Command, id: string) {
|
|
92
|
+
renderAnswer(this, outputFor(this), itemOf(await withService(this, (service) => service.show(id))))
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Every write's one path (WC-PERM-13). The profile's level for the command's key refuses, or asks once; the send
|
|
97
|
+
* journal records the attempt and its outcome, never its content (WC-PERM-17); the answer carries the `operation_id`
|
|
98
|
+
* (WC-OUT-10). cli-core's `run` has already answered `--dry-run` and refused under `<PREFIX>_READ_ONLY` before this
|
|
99
|
+
* runs. Never retried: a write that got no answer is `outcome_unknown`, with the id to look it up by (WC-LIFE-9).
|
|
100
|
+
*/
|
|
101
|
+
const write = async (command: Command, act: (service: CommentsService) => Promise<Comment>) => {
|
|
102
|
+
const { settings, env, renderer } = contextOf(command)
|
|
103
|
+
const key = commandPath(command).replaceAll(" ", ".")
|
|
104
|
+
const operationId = newOperationId()
|
|
105
|
+
const levelOf = (one: string) => levelFor(settings.permissions, one, permissions.defaults).level
|
|
106
|
+
const guard = keyedGuard(
|
|
107
|
+
(ask) =>
|
|
108
|
+
guardFor(
|
|
109
|
+
IDENTITY,
|
|
110
|
+
// SAMPLE: the service limits its own rate, so this guard keeps the levels and the journal, not a send cap.
|
|
111
|
+
{ ...settings, sendsPerHour: Number.MAX_SAFE_INTEGER, permissionDefaults: permissions.defaults },
|
|
112
|
+
renderer.warn,
|
|
113
|
+
env,
|
|
114
|
+
ask,
|
|
115
|
+
),
|
|
116
|
+
terminalAsker(command),
|
|
117
|
+
levelOf,
|
|
118
|
+
[],
|
|
119
|
+
)
|
|
120
|
+
const request = { chatId: null, kind: "edit" as const, key, operationId }
|
|
121
|
+
guard.refuse(request)
|
|
122
|
+
let comment: Comment
|
|
123
|
+
try {
|
|
124
|
+
comment = await guardedWrite(guard, request, () => withService(command, act))
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (error instanceof CliError && error.code === "outcome_unknown")
|
|
127
|
+
throw new CliError("outcome_unknown", error.message, { ...error.details, operation_id: operationId })
|
|
128
|
+
throw error
|
|
129
|
+
}
|
|
130
|
+
renderAnswer(command, outputFor(command), itemOf({ operation_id: operationId, comment }))
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const createCommand = () =>
|
|
134
|
+
annotate(new Command("create"), {
|
|
135
|
+
effects: REMOTE_WRITE,
|
|
136
|
+
offline: false,
|
|
137
|
+
examples: [
|
|
138
|
+
`${CLI} personal comments create --text "Looks good to me"`,
|
|
139
|
+
`${CLI} personal comments create --text "Looks good to me" --dry-run`,
|
|
140
|
+
],
|
|
141
|
+
})
|
|
142
|
+
.description(`post a comment in ${SERVICE}`)
|
|
143
|
+
.requiredOption("--text <text>", "the comment's text")
|
|
144
|
+
.action(async function (this: Command, args: { text: string }) {
|
|
145
|
+
await write(this, (service) => service.create(args.text))
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
const updateCommand = () =>
|
|
149
|
+
annotate(new Command("update"), {
|
|
150
|
+
effects: REMOTE_WRITE,
|
|
151
|
+
offline: false,
|
|
152
|
+
examples: [`${CLI} personal comments update c1 --text "Looks good now"`],
|
|
153
|
+
})
|
|
154
|
+
.description(`change the text of your comment in ${SERVICE}`)
|
|
155
|
+
.argument("<id>", "the comment's id, as list prints it")
|
|
156
|
+
.requiredOption("--text <text>", "the new text")
|
|
157
|
+
.action(async function (this: Command, id: string, args: { text: string }) {
|
|
158
|
+
await write(this, (service) => service.update(id, args.text))
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
export const commentsCommand = (): Command =>
|
|
162
|
+
annotate(new Command("comments"), { effects: REMOTE_READ })
|
|
163
|
+
.description(`read and write comments in ${SERVICE}, the sample service`)
|
|
164
|
+
.addCommand(listCommand())
|
|
165
|
+
.addCommand(showCommand())
|
|
166
|
+
.addCommand(createCommand())
|
|
167
|
+
.addCommand(updateCommand())
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { CliError } from "@wirecat/cli-base"
|
|
2
|
+
import { annotate } from "@wirecat/cli-base/commands"
|
|
3
|
+
import { readSecret } from "@wirecat/cli-core"
|
|
4
|
+
import {
|
|
5
|
+
commandWords,
|
|
6
|
+
environmentOf,
|
|
7
|
+
FETCH,
|
|
8
|
+
itemOf,
|
|
9
|
+
LOCAL_READ,
|
|
10
|
+
LOCAL_WRITE,
|
|
11
|
+
listOf,
|
|
12
|
+
logsInOrOut,
|
|
13
|
+
outputFor,
|
|
14
|
+
profileFree,
|
|
15
|
+
profilesHere,
|
|
16
|
+
REMOTE_READ,
|
|
17
|
+
refuseCommandName,
|
|
18
|
+
refuseUnderReadOnly,
|
|
19
|
+
rememberAccount,
|
|
20
|
+
renderAnswer,
|
|
21
|
+
rootOf,
|
|
22
|
+
secretInputOf,
|
|
23
|
+
} from "@wirecat/cli-core/cli"
|
|
24
|
+
import { Command } from "commander"
|
|
25
|
+
import { configuration } from "../config.js"
|
|
26
|
+
import { contextOf, refuseOffline, serviceFor, withService } from "../context.js"
|
|
27
|
+
import { IDENTITY } from "../identity.js"
|
|
28
|
+
import { SERVICE } from "../sample/transport.js"
|
|
29
|
+
import { TOKEN_VARIABLE, type TokenStorage, tokenStore } from "../token.js"
|
|
30
|
+
|
|
31
|
+
// `session start | status | end | list` is every provider CLI's sign-in (WC-ACCT-12). Keep it; replace the token
|
|
32
|
+
// prompt and the `me` check with your service's. A service with OAuth signs in through cli-core's oauth module
|
|
33
|
+
// instead (WC-SECR-22); see gcal-cli's src/commands/session.ts.
|
|
34
|
+
|
|
35
|
+
const CLI = IDENTITY.command
|
|
36
|
+
|
|
37
|
+
const answer = (command: Command, item: unknown) => renderAnswer(command, outputFor(command), itemOf(item))
|
|
38
|
+
|
|
39
|
+
const storageOf = (raw: string): TokenStorage => {
|
|
40
|
+
if (raw === "keyring" || raw === "file") return raw
|
|
41
|
+
throw new CliError("validation_error", `--credential-storage takes keyring or file; got ${raw}`)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const startCommand = () =>
|
|
45
|
+
logsInOrOut(
|
|
46
|
+
annotate(new Command("start"), {
|
|
47
|
+
effects: FETCH,
|
|
48
|
+
lifetime: "interactive",
|
|
49
|
+
mcp: false,
|
|
50
|
+
examples: [`${CLI} personal session start`, `${CLI} personal session start --credential-storage file`],
|
|
51
|
+
}),
|
|
52
|
+
)
|
|
53
|
+
.summary(`sign in to ${SERVICE} with an API token`)
|
|
54
|
+
.description(
|
|
55
|
+
`sign this profile in: the ${SERVICE} API token is asked hidden or read from stdin, never taken as an ` +
|
|
56
|
+
"argument, checked against the service, and only then kept in the keyring, or in a private file",
|
|
57
|
+
)
|
|
58
|
+
.option(
|
|
59
|
+
"--credential-storage <storage>",
|
|
60
|
+
"where the token is kept: keyring, which falls back to a private file by itself, or file",
|
|
61
|
+
storageOf,
|
|
62
|
+
"keyring" as TokenStorage,
|
|
63
|
+
)
|
|
64
|
+
.action(async function (this: Command, args: { credentialStorage: TokenStorage }) {
|
|
65
|
+
const { env, flags, profile, tokens, run } = contextOf(this)
|
|
66
|
+
refuseCommandName(profile, commandWords(rootOf(this)), CLI)
|
|
67
|
+
refuseOffline(flags)
|
|
68
|
+
// A secret is never an argument: it would land in shell history and in the run record (WC-SECR-5).
|
|
69
|
+
const token = (await readSecret(`${SERVICE} API token: `, secretInputOf(this))).trim()
|
|
70
|
+
if (!token) throw new CliError("validation_error", "no token was given")
|
|
71
|
+
const user = await run((events) => serviceFor(this, token, events).me())
|
|
72
|
+
const storedIn = tokens.write(profile, token, args.credentialStorage)
|
|
73
|
+
rememberAccount(IDENTITY, profile, user.id, env)
|
|
74
|
+
answer(this, { profile, state: "saved", stored_in: storedIn, verified: true, user })
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const statusCommand = () =>
|
|
78
|
+
annotate(new Command("status"), {
|
|
79
|
+
effects: REMOTE_READ,
|
|
80
|
+
examples: [`${CLI} personal session status`, `${CLI} personal session status --check --json`],
|
|
81
|
+
})
|
|
82
|
+
.summary("show where this profile's token comes from; never the token")
|
|
83
|
+
.description(
|
|
84
|
+
`show where this profile's token comes from: ${TOKEN_VARIABLE}, the keyring or a file, never the token; ` +
|
|
85
|
+
`--check asks ${SERVICE} whose token it is`,
|
|
86
|
+
)
|
|
87
|
+
.option("--check", `ask ${SERVICE} whether the token still works, and whose it is`)
|
|
88
|
+
.action(async function (this: Command, args: { check?: boolean }) {
|
|
89
|
+
const { profile, tokens } = contextOf(this)
|
|
90
|
+
const saved = tokens.read(profile)
|
|
91
|
+
const status = { profile, state: saved ? "saved" : "missing", token_from: saved?.source ?? null }
|
|
92
|
+
if (!args.check || !saved) return answer(this, { ...status, verified: false })
|
|
93
|
+
answer(this, { ...status, verified: true, user: await withService(this, (service) => service.me()) })
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
const endCommand = () =>
|
|
97
|
+
logsInOrOut(
|
|
98
|
+
annotate(new Command("end"), {
|
|
99
|
+
effects: LOCAL_WRITE,
|
|
100
|
+
mcp: false,
|
|
101
|
+
examples: [`${CLI} personal session end`],
|
|
102
|
+
}),
|
|
103
|
+
)
|
|
104
|
+
.summary("forget this profile's token on this computer")
|
|
105
|
+
.description(`forget this profile's token on this computer; the token itself stays valid in ${SERVICE}`)
|
|
106
|
+
.action(function (this: Command) {
|
|
107
|
+
refuseUnderReadOnly(this, "session end")
|
|
108
|
+
const { profile, tokens } = contextOf(this)
|
|
109
|
+
answer(this, { profile, ended: tokens.remove(profile) })
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
const listCommand = () =>
|
|
113
|
+
profileFree(
|
|
114
|
+
annotate(new Command("list"), {
|
|
115
|
+
effects: LOCAL_READ,
|
|
116
|
+
examples: [`${CLI} session list`, `${CLI} session list --json`],
|
|
117
|
+
}),
|
|
118
|
+
)
|
|
119
|
+
.summary("list the profiles here and whether each is signed in")
|
|
120
|
+
.description("list the profiles on this computer and whether each has a token; picks none")
|
|
121
|
+
.action(function (this: Command) {
|
|
122
|
+
const environment = environmentOf(this)
|
|
123
|
+
const env = environment.env ?? process.env
|
|
124
|
+
// biome-ignore lint/plugin/no-own-settings: baseContext needs a profile, and this local read names none
|
|
125
|
+
const { configuredProfiles } = configuration.resolveSettings({}, { env, needsProfile: false })
|
|
126
|
+
const tokens = tokenStore(env, environment.keyring, outputFor(this).renderer.warn)
|
|
127
|
+
const items = profilesHere(IDENTITY, configuredProfiles, env).map((profile) => {
|
|
128
|
+
const saved = tokens.read(profile)
|
|
129
|
+
return { profile, signed_in: saved !== undefined, token_from: saved?.source ?? null }
|
|
130
|
+
})
|
|
131
|
+
renderAnswer(
|
|
132
|
+
this,
|
|
133
|
+
outputFor(this),
|
|
134
|
+
listOf("session list", { items, limit: Math.max(1, items.length), has_more: false }),
|
|
135
|
+
)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
export const sessionCommand = (): Command =>
|
|
139
|
+
annotate(new Command("session"), { effects: LOCAL_READ })
|
|
140
|
+
.description(`keep this profile's ${SERVICE} token in the OS keyring`)
|
|
141
|
+
.addCommand(startCommand())
|
|
142
|
+
.addCommand(statusCommand())
|
|
143
|
+
.addCommand(endCommand())
|
|
144
|
+
.addCommand(listCommand())
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { tmpdir } from "node:os"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import { brokenKeyring, memoryKeyring } from "@wirecat/cli-base"
|
|
5
|
+
import type { RunOptions } from "@wirecat/cli-core/cli"
|
|
6
|
+
import { mcpOverStdio } from "@wirecat/cli-core/testing"
|
|
7
|
+
import { afterEach, expect, it } from "vitest"
|
|
8
|
+
import type { ServiceEnvironment } from "./context.js"
|
|
9
|
+
import { run } from "./program.js"
|
|
10
|
+
import { FAKE_TOKEN, fakeComments } from "./testing/fake-comments.js"
|
|
11
|
+
import { runCli, signedIn } from "./testing/harness.js"
|
|
12
|
+
import { holdsToken } from "./token.js"
|
|
13
|
+
|
|
14
|
+
const configFile = join(process.env.CLIDEV_CONFIG_DIR ?? "", "config.json")
|
|
15
|
+
const withPermissions = (permissions: Record<string, string>) => {
|
|
16
|
+
mkdirSync(join(configFile, ".."), { recursive: true })
|
|
17
|
+
writeFileSync(configFile, JSON.stringify({ profiles: { personal: { permissions } } }))
|
|
18
|
+
}
|
|
19
|
+
afterEach(() => rmSync(configFile, { force: true }))
|
|
20
|
+
|
|
21
|
+
const filesUnder = (dir: string): string[] =>
|
|
22
|
+
readdirSync(dir, { recursive: true, encoding: "utf8" })
|
|
23
|
+
.map((name) => join(dir, name))
|
|
24
|
+
.filter((path) => statSync(path).isFile())
|
|
25
|
+
|
|
26
|
+
it("lists comments in the answer's own shape, asking the service only what it documents", async () => {
|
|
27
|
+
const service = fakeComments()
|
|
28
|
+
const listed = await runCli(["personal", "comments", "list", "--json"], { service })
|
|
29
|
+
expect(listed.code).toBe(0)
|
|
30
|
+
expect(listed.json().items).toEqual([
|
|
31
|
+
{ id: "c1", text: "First!", author: "Test Person u2", created_at: "2026-01-02T03:04:05Z" },
|
|
32
|
+
{ id: "c2", text: "Agreed", author: "Test Person u3", created_at: "2026-01-02T03:04:05Z" },
|
|
33
|
+
])
|
|
34
|
+
expect(service.refusals).toEqual([])
|
|
35
|
+
expect(service.calls.map((call) => call.url)).toEqual([
|
|
36
|
+
"https://comments.example.invalid/v1/comments?limit=20&offset=0",
|
|
37
|
+
])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("narrows the list with its query, pages it, and keeps only the --json fields asked for", async () => {
|
|
41
|
+
const service = fakeComments()
|
|
42
|
+
const narrowed = await runCli(["personal", "comments", "list", "author:u3", "--json=id,text"], { service })
|
|
43
|
+
expect(narrowed.json().items).toEqual([{ id: "c2", text: "Agreed" }])
|
|
44
|
+
const paged = await runCli(["personal", "comments", "list", "--limit", "1", "--page", "2", "--json"], { service })
|
|
45
|
+
expect(paged.json()).toMatchObject({ items: [{ id: "c2" }] })
|
|
46
|
+
expect(service.calls.at(-1)?.query).toEqual({ limit: "1", offset: "1" })
|
|
47
|
+
const all = await runCli(["personal", "comments", "list", "--all", "--json"], { service })
|
|
48
|
+
expect(all.json()).toMatchObject({ items: [{ id: "c1" }, { id: "c2" }] })
|
|
49
|
+
expect(service.refusals).toEqual([])
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("refuses a query field the list does not have, before any request", async () => {
|
|
53
|
+
const service = fakeComments()
|
|
54
|
+
const { code, json } = await runCli(["personal", "comments", "list", "colour:red", "--json"], { service })
|
|
55
|
+
expect(code).toBe(2)
|
|
56
|
+
expect(json()).toMatchObject({ error: { code: "validation_error" } })
|
|
57
|
+
expect(service.calls).toEqual([])
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it("shows one comment, and says not_found for an id the service does not have", async () => {
|
|
61
|
+
expect((await runCli(["personal", "comments", "show", "c2", "--json"])).json().item).toMatchObject({ id: "c2" })
|
|
62
|
+
const missing = await runCli(["personal", "comments", "show", "c9", "--json"])
|
|
63
|
+
expect(missing.json()).toMatchObject({ error: { code: "not_found" } })
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("creates and updates a comment, each answer with its operation_id", async () => {
|
|
67
|
+
const service = fakeComments()
|
|
68
|
+
const created = await runCli(["personal", "comments", "create", "--text", "Plan", "--json"], { service })
|
|
69
|
+
expect(created.code).toBe(0)
|
|
70
|
+
expect(created.json().item).toMatchObject({ operation_id: expect.any(String), comment: { id: "c3", text: "Plan" } })
|
|
71
|
+
const updated = await runCli(["personal", "comments", "update", "c3", "--text", "Plans", "--json"], { service })
|
|
72
|
+
expect(updated.json().item).toMatchObject({ operation_id: expect.any(String), comment: { text: "Plans" } })
|
|
73
|
+
expect(service.refusals).toEqual([])
|
|
74
|
+
expect(service.calls.map((call) => call.body)).toEqual([{ body: "Plan" }, { body: "Plans" }])
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it("--dry-run names the write's permission and sends nothing", async () => {
|
|
78
|
+
const service = fakeComments()
|
|
79
|
+
const preview = await runCli(["personal", "comments", "create", "--text", "Plan", "--dry-run", "--json"], { service })
|
|
80
|
+
expect(preview.code).toBe(0)
|
|
81
|
+
expect(preview.json().item).toMatchObject({ permission: { key: "comments.create" } })
|
|
82
|
+
expect(service.calls).toEqual([])
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it("a deny on a read stops it before any request", async () => {
|
|
86
|
+
withPermissions({ "comments.list": "deny" })
|
|
87
|
+
const service = fakeComments()
|
|
88
|
+
const { code, json } = await runCli(["personal", "comments", "list", "--json"], { service })
|
|
89
|
+
expect(code).toBe(5)
|
|
90
|
+
expect(json()).toMatchObject({ error: { code: "permission_error" } })
|
|
91
|
+
expect(service.calls).toEqual([])
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it("a deny on a write, or the read-only lock, stops it before any request", async () => {
|
|
95
|
+
const service = fakeComments()
|
|
96
|
+
withPermissions({ "comments.create": "deny" })
|
|
97
|
+
expect((await runCli(["personal", "comments", "create", "--text", "Plan", "--json"], { service })).code).toBe(5)
|
|
98
|
+
rmSync(configFile)
|
|
99
|
+
const locked = await runCli(["personal", "comments", "create", "--text", "Plan", "--json"], {
|
|
100
|
+
service,
|
|
101
|
+
env: { CLIDEV_READ_ONLY: "1" },
|
|
102
|
+
})
|
|
103
|
+
expect(locked.code).toBe(5)
|
|
104
|
+
expect(service.calls).toEqual([])
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it("a write at ask asks once; no sends nothing, yes sends it", async () => {
|
|
108
|
+
withPermissions({ "comments.create": "ask" })
|
|
109
|
+
const service = fakeComments()
|
|
110
|
+
const asked: string[] = []
|
|
111
|
+
const answering = (reply: string) => (question: string) => {
|
|
112
|
+
asked.push(question)
|
|
113
|
+
return reply
|
|
114
|
+
}
|
|
115
|
+
const no = await runCli(["personal", "comments", "create", "--text", "Plan"], { service, answer: answering("n") })
|
|
116
|
+
expect(no.code).not.toBe(0)
|
|
117
|
+
expect(asked).toHaveLength(1)
|
|
118
|
+
expect(service.calls).toEqual([])
|
|
119
|
+
const yes = await runCli(["personal", "comments", "create", "--text", "Plan"], { service, answer: answering("y") })
|
|
120
|
+
expect(yes.code).toBe(0)
|
|
121
|
+
expect(asked).toHaveLength(2)
|
|
122
|
+
expect(service.calls).toHaveLength(1)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it("a write whose answer is lost is outcome_unknown with its operation_id, and is sent once", async () => {
|
|
126
|
+
const service = fakeComments({ loseNextWriteAnswer: true })
|
|
127
|
+
const lost = await runCli(["personal", "comments", "create", "--text", "Plan", "--json"], { service })
|
|
128
|
+
expect(lost.json()).toMatchObject({
|
|
129
|
+
error: { code: "outcome_unknown", details: { operation_id: expect.any(String) } },
|
|
130
|
+
})
|
|
131
|
+
expect(service.calls).toHaveLength(1)
|
|
132
|
+
expect(service.comments[0]?.body).toBe("Plan")
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it("a write still in flight when --timeout ends the run is outcome_unknown, never a plain timeout", async () => {
|
|
136
|
+
const service = fakeComments({ hangWrites: true })
|
|
137
|
+
const cut = await runCli(["--timeout", "50ms", "personal", "comments", "create", "--text", "Plan", "--json"], {
|
|
138
|
+
service,
|
|
139
|
+
})
|
|
140
|
+
expect(cut.json()).toMatchObject({
|
|
141
|
+
error: { code: "outcome_unknown", details: { operation_id: expect.any(String) } },
|
|
142
|
+
})
|
|
143
|
+
expect(service.calls).toHaveLength(1)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it("a server failure is outcome_unknown for a write and retryable for a read", async () => {
|
|
147
|
+
const service = fakeComments({ failWith: 503 })
|
|
148
|
+
const write = await runCli(["personal", "comments", "create", "--text", "Plan", "--json"], { service })
|
|
149
|
+
expect(write.json()).toMatchObject({ error: { code: "outcome_unknown" } })
|
|
150
|
+
const read = await runCli(["personal", "comments", "list", "--json"], { service })
|
|
151
|
+
expect(read.json()).toMatchObject({ error: { code: "provider_unavailable", retry: { safe: true } } })
|
|
152
|
+
expect(service.calls).toHaveLength(2)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it("without a sign-in a command says how to sign in and sends nothing", async () => {
|
|
156
|
+
const service = fakeComments()
|
|
157
|
+
const { json } = await runCli(["personal", "comments", "list", "--json"], { service, keyring: memoryKeyring() })
|
|
158
|
+
expect(json()).toMatchObject({ error: { code: "authentication_error" } })
|
|
159
|
+
expect(JSON.stringify(json())).toContain("session start")
|
|
160
|
+
expect(service.calls).toEqual([])
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it("--offline refuses a command that needs the service", async () => {
|
|
164
|
+
const service = fakeComments()
|
|
165
|
+
expect((await runCli(["--offline", "personal", "comments", "list", "--json"], { service })).code).toBe(5)
|
|
166
|
+
expect(service.calls).toEqual([])
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it("a recorded run is named after the command and holds its request", async () => {
|
|
170
|
+
const listed = await runCli(["--record", "personal", "comments", "list", "--json"])
|
|
171
|
+
const runId = String((listed.json().meta as Record<string, unknown>).run_id)
|
|
172
|
+
expect(runId).toContain("comments")
|
|
173
|
+
expect(runId).not.toContain("-process-")
|
|
174
|
+
expect((await runCli(["runs", "show", runId, "--json"])).out).toContain("GET /comments")
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it("session start reads the token from stdin and checks it before keeping it; status, list and end", async () => {
|
|
178
|
+
const keyring = memoryKeyring()
|
|
179
|
+
const service = fakeComments()
|
|
180
|
+
const started = await runCli(["personal", "session", "start", "--json"], { keyring, service, stdin: FAKE_TOKEN })
|
|
181
|
+
expect(started.json().item).toMatchObject({ verified: true, user: { id: "u1" } })
|
|
182
|
+
const status = await runCli(["personal", "session", "status", "--check", "--json"], { keyring, service })
|
|
183
|
+
expect(status.json().item).toMatchObject({ state: "saved", token_from: "keyring", verified: true })
|
|
184
|
+
expect((await runCli(["session", "list", "--json"], { keyring })).json().items).toContainEqual(
|
|
185
|
+
expect.objectContaining({ profile: "personal", signed_in: true }),
|
|
186
|
+
)
|
|
187
|
+
expect((await runCli(["personal", "session", "end", "--json"], { keyring })).code).toBe(0)
|
|
188
|
+
const ended = await runCli(["personal", "session", "status", "--json"], { keyring })
|
|
189
|
+
expect(ended.json().item).toMatchObject({ state: "missing" })
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it("session start --credential-storage file keeps the token in a private file instead of the keyring", async () => {
|
|
193
|
+
const keyring = memoryKeyring()
|
|
194
|
+
const started = await runCli(["personal", "session", "start", "--credential-storage", "file", "--json"], {
|
|
195
|
+
keyring,
|
|
196
|
+
stdin: FAKE_TOKEN,
|
|
197
|
+
})
|
|
198
|
+
expect(started.json().item).toMatchObject({ stored_in: "file" })
|
|
199
|
+
const status = await runCli(["personal", "session", "status", "--json"], { keyring })
|
|
200
|
+
expect(status.json().item).toMatchObject({ token_from: "file" })
|
|
201
|
+
expect((await runCli(["personal", "session", "end", "--json"], { keyring })).json().item).toMatchObject({
|
|
202
|
+
ended: expect.arrayContaining(["file"]),
|
|
203
|
+
})
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it("session start without a working keyring keeps the token in a private file, and says so on stderr", async () => {
|
|
207
|
+
const keyring = brokenKeyring()
|
|
208
|
+
const started = await runCli(["personal", "session", "start", "--json"], { keyring, stdin: FAKE_TOKEN })
|
|
209
|
+
expect(started.json().item).toMatchObject({ stored_in: "file" })
|
|
210
|
+
expect(started.err).toContain("keyring")
|
|
211
|
+
expect(holdsToken(process.env, "personal", keyring)).toBe(true)
|
|
212
|
+
expect((await runCli(["personal", "session", "end", "--json"], { keyring })).code).toBe(0)
|
|
213
|
+
expect(holdsToken(process.env, "personal", keyring)).toBe(false)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it("session start refuses a storage it does not know, before asking for the token", async () => {
|
|
217
|
+
const { code, err } = await runCli(["personal", "session", "start", "--credential-storage", "vault", "--json"])
|
|
218
|
+
expect(code).toBe(2)
|
|
219
|
+
expect(err).toContain("--credential-storage takes keyring or file")
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it("session start keeps nothing when the service refuses the token", async () => {
|
|
223
|
+
const keyring = memoryKeyring()
|
|
224
|
+
const refused = await runCli(["personal", "session", "start", "--json"], { keyring, stdin: "wrong0token" })
|
|
225
|
+
expect(refused.json()).toMatchObject({ error: { code: "authentication_error" } })
|
|
226
|
+
const status = await runCli(["personal", "session", "status", "--json"], { keyring })
|
|
227
|
+
expect(status.json().item).toMatchObject({ state: "missing" })
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it("the token reaches no address, output, run record or journal, whatever the outcome", async () => {
|
|
231
|
+
const service = fakeComments({ loseNextWriteAnswer: true })
|
|
232
|
+
const runs = [
|
|
233
|
+
await runCli(["personal", "session", "start", "--json"], { keyring: memoryKeyring(), stdin: FAKE_TOKEN }),
|
|
234
|
+
await runCli(["--record", "personal", "comments", "list", "--json"], { service }),
|
|
235
|
+
await runCli(["personal", "comments", "create", "--text", "Plan", "--json"], { service }),
|
|
236
|
+
await runCli(["personal", "comments", "show", "c9", "--json"], { service }),
|
|
237
|
+
await runCli(["personal", "comments", "list", "--json"], { service: fakeComments({ failWith: 401 }) }),
|
|
238
|
+
]
|
|
239
|
+
for (const ran of runs) expect(ran.out + ran.err).not.toContain(FAKE_TOKEN)
|
|
240
|
+
for (const call of service.calls) expect(call.url).not.toContain(FAKE_TOKEN)
|
|
241
|
+
const folders = [...new Set([process.env.HOME ?? "", tmpdir()])]
|
|
242
|
+
const written = folders.flatMap(filesUnder).map((path) => readFileSync(path, "utf8"))
|
|
243
|
+
expect(written.some((text) => text.includes("GET /comments"))).toBe(true)
|
|
244
|
+
expect(written.some((text) => text.includes("comments.create"))).toBe(true)
|
|
245
|
+
expect(written.filter((text) => text.includes(FAKE_TOKEN))).toEqual([])
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it("mcp serve runs a read for an agent with the run's keyring", async () => {
|
|
249
|
+
const service = fakeComments()
|
|
250
|
+
const options: RunOptions & ServiceEnvironment = { tty: false, keyring: signedIn(), fetch: service.fetch }
|
|
251
|
+
const mcp = await mcpOverStdio((streams) => run(["personal", "mcp", "serve"], { ...options, ...streams }))
|
|
252
|
+
try {
|
|
253
|
+
const listed = await mcp.call("clidev_read", { command: "comments list", arguments: { query: "author:u2" } })
|
|
254
|
+
expect(listed.body).toMatchObject({ items: [{ id: "c1" }] })
|
|
255
|
+
} finally {
|
|
256
|
+
expect(await mcp.close()).toBe(0)
|
|
257
|
+
}
|
|
258
|
+
})
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { CliError } from "@wirecat/cli-base"
|
|
2
|
+
import {
|
|
3
|
+
type BaseEnvironment,
|
|
4
|
+
baseContext,
|
|
5
|
+
type EventSink,
|
|
6
|
+
environmentOf,
|
|
7
|
+
type GlobalFlags,
|
|
8
|
+
} from "@wirecat/cli-core/cli"
|
|
9
|
+
import { Pacer, pacePathFor } from "@wirecat/cli-core/sends"
|
|
10
|
+
import type { Command } from "commander"
|
|
11
|
+
import { configuration } from "./config.js"
|
|
12
|
+
import { IDENTITY } from "./identity.js"
|
|
13
|
+
import { type CommentsService, commentsService } from "./sample/comments.js"
|
|
14
|
+
import { SERVICE, transport } from "./sample/transport.js"
|
|
15
|
+
import { tokenStore } from "./token.js"
|
|
16
|
+
|
|
17
|
+
/** What a test hands `run()` beside cli-core's own: the fake service in place of the network (WC-ARCH-10). */
|
|
18
|
+
export interface ServiceEnvironment extends BaseEnvironment {
|
|
19
|
+
fetch?: typeof fetch
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// SAMPLE: your service's documented limit; this one is made up.
|
|
23
|
+
const PACE = { perMinute: 120, burst: 20 }
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Settings, keyring and the run come from cli-core's `baseContext` and nowhere else: a command that resolves settings
|
|
27
|
+
* on its own skips `--timeout`, the run record and the profile's permissions (WC-LIFE-5, WC-LIFE-15).
|
|
28
|
+
*/
|
|
29
|
+
export const contextOf = (command: Command) => {
|
|
30
|
+
const base = baseContext(command, configuration.resolveSettings)
|
|
31
|
+
return {
|
|
32
|
+
...base,
|
|
33
|
+
environment: environmentOf<ServiceEnvironment>(command),
|
|
34
|
+
flags: command.optsWithGlobals<GlobalFlags>(),
|
|
35
|
+
profile: base.settings.profile,
|
|
36
|
+
tokens: tokenStore(base.env, base.keyring, base.renderer.warn),
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const signInWords = (profile: string) => `\`${IDENTITY.command} ${profile} session start\``
|
|
41
|
+
|
|
42
|
+
export const refuseOffline = (flags: GlobalFlags) => {
|
|
43
|
+
if (flags.offline)
|
|
44
|
+
throw new CliError("permission_error", `this needs ${SERVICE}; leave out --offline`, { reason: "offline.refused" })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The service for one run, with the token it was given; `events` comes from `run()`, so each request is recorded. */
|
|
48
|
+
export const serviceFor = (command: Command, token: string, events: EventSink) => {
|
|
49
|
+
const { environment, env, profile } = contextOf(command)
|
|
50
|
+
return commentsService(
|
|
51
|
+
transport({
|
|
52
|
+
token,
|
|
53
|
+
events,
|
|
54
|
+
signal: environment.signal,
|
|
55
|
+
...(environment.fetch ? { fetch: environment.fetch } : {}),
|
|
56
|
+
pacer: new Pacer(pacePathFor(IDENTITY, profile, env), PACE),
|
|
57
|
+
}),
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The profile's saved token, or an authentication error that says how to sign in. Read before any request. */
|
|
62
|
+
export const tokenOf = (command: Command): string => {
|
|
63
|
+
const { flags, profile, tokens } = contextOf(command)
|
|
64
|
+
refuseOffline(flags)
|
|
65
|
+
const saved = tokens.read(profile)
|
|
66
|
+
if (!saved)
|
|
67
|
+
throw new CliError("authentication_error", `profile ${profile} is not signed in — run ${signInWords(profile)}`)
|
|
68
|
+
return saved.secret
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Every remote call goes through here: inside cli-core's run, so `--timeout` bounds it, a failure or `--record` keeps
|
|
73
|
+
* a run named after the command, and the transport's requests land in that run (WC-LIFE-5, WC-LIFE-15).
|
|
74
|
+
*/
|
|
75
|
+
export const withService = <T>(command: Command, body: (service: CommentsService) => Promise<T>): Promise<T> => {
|
|
76
|
+
const token = tokenOf(command)
|
|
77
|
+
return contextOf(command).run((events) => body(serviceFor(command, token, events)))
|
|
78
|
+
}
|