@danwo/cli 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,39 @@
1
+ /** The CLI's commands described from their commander definitions, for `danwo spec`. */
2
+ import type { CliCommand } from "@danwo/skill"
3
+ import type { Argument, Command, Option } from "commander"
4
+
5
+ /** A default as text, or undefined when there is none. */
6
+ function defaultText(value: unknown): string | undefined {
7
+ if (value === undefined || value === false) return undefined
8
+
9
+ return typeof value === "string" ? value : JSON.stringify(value)
10
+ }
11
+
12
+ function argumentOf(argument: Argument) {
13
+ return {
14
+ name: argument.name(),
15
+ required: argument.required,
16
+ variadic: argument.variadic,
17
+ description: argument.description,
18
+ choices: argument.argChoices ?? [],
19
+ }
20
+ }
21
+
22
+ function optionOf(option: Option) {
23
+ return {
24
+ flags: option.flags,
25
+ description: option.description,
26
+ choices: option.argChoices ?? [],
27
+ default: defaultText(option.defaultValue),
28
+ }
29
+ }
30
+
31
+ /** Every subcommand of `program` with its arguments and options. */
32
+ export function cliCommands(program: Command): CliCommand[] {
33
+ return program.commands.map((command) => ({
34
+ name: command.name(),
35
+ description: command.description(),
36
+ arguments: command.registeredArguments.map(argumentOf),
37
+ options: command.options.map(optionOf),
38
+ }))
39
+ }
package/src/doctor.ts ADDED
@@ -0,0 +1,254 @@
1
+ /**
2
+ * `danwo doctor`: what stops a first run, checked before one. Node, danwo's own Chromium (and
3
+ * the config's other browsers, when `browser.name` names Firefox or WebKit), the config (loading, and keys danwo does not know), the model's key (or, for a model on a coding CLI, the CLI's install and sign-in), the installed Playwright Test
4
+ * against `@danwo/playwright`'s range, and whether the coding agents' copy of the skill is this
5
+ * danwo's. Android has its own: `danwo device doctor`.
6
+ */
7
+ import { existsSync, readFileSync } from "node:fs"
8
+ import { createRequire } from "node:module"
9
+ import { join } from "node:path"
10
+
11
+ import { DANWO_VERSION } from "@danwo/init/catalog"
12
+ import {
13
+ type BrowserName,
14
+ browserNames,
15
+ browserTitle,
16
+ cliStatusOf,
17
+ type DanwoConfig,
18
+ loadConfig,
19
+ } from "@danwo/runner/runner"
20
+ import { SKILL_DIRS, SKILL_FILE } from "@danwo/skill"
21
+
22
+ export interface DoctorCheck {
23
+ readonly name: string
24
+ readonly status: "ok" | "warn" | "fail"
25
+ readonly detail: string
26
+ }
27
+
28
+ /** What the checks read from the machine; replaced in tests. */
29
+ export interface DoctorProbes {
30
+ readonly nodeVersion: string
31
+ readonly env: NodeJS.ProcessEnv
32
+ /** Starts danwo's build of `engine` headless; its version or why it did not start. */
33
+ readonly browser: (
34
+ engine: BrowserName,
35
+ ) => Promise<{ readonly ok: boolean; readonly detail: string }>
36
+ }
37
+
38
+ /** The oldest Playwright Test `@danwo/playwright` takes. */
39
+ const PLAYWRIGHT_FLOOR = [1, 60] as const
40
+
41
+ /** The variable each model provider's AI SDK package reads its key from, by provider prefix. */
42
+ const PROVIDER_KEYS: readonly (readonly [string, string])[] = [
43
+ ["anthropic", "ANTHROPIC_API_KEY"],
44
+ ["openai", "OPENAI_API_KEY"],
45
+ ["google", "GOOGLE_GENERATIVE_AI_API_KEY"],
46
+ ["gateway", "AI_GATEWAY_API_KEY"],
47
+ ]
48
+
49
+ function nodeCheck(version: string): DoctorCheck {
50
+ const major = Number(/^v?(\d+)/.exec(version)?.[1] ?? 0)
51
+
52
+ return major >= 24
53
+ ? { name: "Node.js", status: "ok", detail: version }
54
+ : { name: "Node.js", status: "fail", detail: `${version}; danwo needs Node.js 24 or newer` }
55
+ }
56
+
57
+ async function browserCheck(probes: DoctorProbes, engine: BrowserName): Promise<DoctorCheck> {
58
+ const found = await probes.browser(engine)
59
+ const name = browserTitle(engine)
60
+
61
+ return found.ok
62
+ ? { name, status: "ok", detail: found.detail }
63
+ : { name, status: "fail", detail: found.detail.replace(/^browserType\.launch: /, "") }
64
+ }
65
+
66
+ /** The engines a project needs: Chromium always (`danwo record` and the MCP server run on it), then the config's others. */
67
+ function enginesOf(config: DanwoConfig | undefined): BrowserName[] {
68
+ const named = config?.browser?.name
69
+
70
+ return [
71
+ ...new Set<BrowserName>([
72
+ "chromium",
73
+ ...(named === undefined
74
+ ? []
75
+ : browserNames(typeof named === "string" ? [named] : named, "browser.name")),
76
+ ]),
77
+ ]
78
+ }
79
+
80
+ /** The key variable a model needs, by its provider, when danwo can tell. */
81
+ function modelKeyOf(model: DanwoConfig["model"]): string | undefined {
82
+ if (typeof model === "string") return "AI_GATEWAY_API_KEY"
83
+
84
+ const named: unknown = typeof model === "object" ? Reflect.get(model, "provider") : undefined
85
+ const provider = typeof named === "string" ? named : ""
86
+
87
+ return PROVIDER_KEYS.find(([prefix]) => provider.startsWith(prefix))?.[1]
88
+ }
89
+
90
+ /**
91
+ * A model that runs on a coding CLI: the CLI is installed, its version, and whether its own status
92
+ * command says it is signed in. Nothing here reads the CLI's credentials.
93
+ */
94
+ async function cliChecks(model: DanwoConfig["model"]): Promise<DoctorCheck[] | undefined> {
95
+ const pending = cliStatusOf(model)
96
+
97
+ if (pending === undefined) return undefined
98
+
99
+ const cli = await pending
100
+ const runsOn: DoctorCheck = {
101
+ name: "model",
102
+ status: "ok",
103
+ detail: `runs on the ${cli.title}, signed in with a plan; no API key`,
104
+ }
105
+
106
+ if (!cli.installed) return [runsOn, { name: cli.title, status: "fail", detail: cli.detail }]
107
+
108
+ return [
109
+ runsOn,
110
+ { name: cli.title, status: "ok", detail: cli.version ?? "installed" },
111
+ cli.signedIn === true
112
+ ? { name: `${cli.title} sign-in`, status: "ok", detail: cli.detail }
113
+ : {
114
+ name: `${cli.title} sign-in`,
115
+ status: "fail",
116
+ detail: `not signed in; run \`${cli.login}\` once in a terminal`,
117
+ },
118
+ ]
119
+ }
120
+
121
+ function modelCheck(config: DanwoConfig, env: NodeJS.ProcessEnv): DoctorCheck {
122
+ if (config.model === undefined)
123
+ return { name: "model", status: "ok", detail: "none set: agent steps are off" }
124
+
125
+ const key = modelKeyOf(config.model)
126
+
127
+ if (key === undefined)
128
+ return { name: "model", status: "ok", detail: "set; its key is not checked here" }
129
+ if ((env[key] ?? "") !== "") return { name: "model", status: "ok", detail: `${key} is set` }
130
+
131
+ return {
132
+ name: "model",
133
+ status: "warn",
134
+ detail: `${key} is not set in this shell; agent steps fail without it unless the config passes the key itself`,
135
+ }
136
+ }
137
+
138
+ /** The config's checks: it loads, its keys, and the model's key. */
139
+ async function configChecks(
140
+ cwd: string,
141
+ explicit: string | undefined,
142
+ env: NodeJS.ProcessEnv,
143
+ ): Promise<{ checks: DoctorCheck[]; root: string; config?: DanwoConfig }> {
144
+ try {
145
+ const loaded = await loadConfig(cwd, explicit)
146
+
147
+ if (loaded.path === undefined)
148
+ return {
149
+ root: cwd,
150
+ checks: [
151
+ {
152
+ name: "config",
153
+ status: "warn",
154
+ detail: "no danwo.config.ts here or above; npx @danwo/cli init writes one",
155
+ },
156
+ ],
157
+ }
158
+
159
+ const keys = (loaded.warnings ?? []).map((warning): DoctorCheck => ({
160
+ name: "config",
161
+ status: "warn",
162
+ detail: warning,
163
+ }))
164
+
165
+ return {
166
+ root: loaded.root,
167
+ config: loaded.config,
168
+ checks: [
169
+ { name: "config", status: "ok", detail: loaded.path },
170
+ ...keys,
171
+ ...((await cliChecks(loaded.config.model)) ?? [modelCheck(loaded.config, env)]),
172
+ ],
173
+ }
174
+ } catch (error) {
175
+ const detail = error instanceof Error ? error.message : String(error)
176
+
177
+ return { root: cwd, checks: [{ name: "config", status: "fail", detail }] }
178
+ }
179
+ }
180
+
181
+ /** The Playwright Test the project installed, against the range `@danwo/playwright` takes. */
182
+ function playwrightCheck(root: string): DoctorCheck[] {
183
+ let version: string
184
+
185
+ try {
186
+ const require = createRequire(join(root, "package.json"))
187
+ const pkg: unknown = JSON.parse(
188
+ readFileSync(require.resolve("@playwright/test/package.json"), "utf8"),
189
+ )
190
+
191
+ version = String(Reflect.get(Object(pkg), "version"))
192
+ } catch {
193
+ return []
194
+ }
195
+
196
+ const [major = 0, minor = 0] = version.split(".").map(Number)
197
+ const fits = major === PLAYWRIGHT_FLOOR[0] && minor >= PLAYWRIGHT_FLOOR[1]
198
+
199
+ return [
200
+ fits
201
+ ? { name: "@playwright/test", status: "ok", detail: version }
202
+ : {
203
+ name: "@playwright/test",
204
+ status: "fail",
205
+ detail: `${version}; @danwo/playwright takes ${PLAYWRIGHT_FLOOR.join(".")}.0 to 1.x`,
206
+ },
207
+ ]
208
+ }
209
+
210
+ /** Whether each copy of danwo's skill in the project is this danwo's. */
211
+ function skillChecks(root: string): DoctorCheck[] {
212
+ const shipped = readFileSync(SKILL_FILE, "utf8")
213
+
214
+ return SKILL_DIRS.flatMap((dir): DoctorCheck[] => {
215
+ const file = join(root, dir, "SKILL.md")
216
+
217
+ if (!existsSync(file)) return []
218
+ if (readFileSync(file, "utf8") === shipped)
219
+ return [{ name: "skill", status: "ok", detail: `${dir} is danwo ${DANWO_VERSION}'s` }]
220
+
221
+ return [
222
+ {
223
+ name: "skill",
224
+ status: "warn",
225
+ detail: `${dir} differs from danwo ${DANWO_VERSION}'s (an older danwo wrote it, or it was edited); danwo init updates a copy nobody edited`,
226
+ },
227
+ ]
228
+ })
229
+ }
230
+
231
+ /** Every check, in the order a first run meets them. */
232
+ export async function projectDoctor(
233
+ options: { readonly cwd: string; readonly config?: string | undefined },
234
+ probes: DoctorProbes,
235
+ ): Promise<DoctorCheck[]> {
236
+ const config = await configChecks(options.cwd, options.config, probes.env)
237
+ const browsers: DoctorCheck[] = []
238
+
239
+ for (const engine of enginesOf(config.config)) browsers.push(await browserCheck(probes, engine))
240
+
241
+ return [
242
+ nodeCheck(probes.nodeVersion),
243
+ { name: "danwo", status: "ok", detail: DANWO_VERSION },
244
+ ...browsers,
245
+ ...config.checks,
246
+ ...playwrightCheck(config.root),
247
+ ...skillChecks(config.root),
248
+ ]
249
+ }
250
+
251
+ /** The checks as lines, one each, the way `danwo device doctor` prints them. */
252
+ export function doctorLines(checks: readonly DoctorCheck[]): string[] {
253
+ return checks.map((check) => `${check.status.padEnd(4)} ${check.name}: ${check.detail}`)
254
+ }