deweyou-cli 0.2.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.
- package/CHANGELOG.md +18 -0
- package/README.md +221 -0
- package/dist/cache-CBEKVQeD.mjs +231 -0
- package/dist/context-BYyhbv5D.mjs +207 -0
- package/dist/deweyou.mjs +124 -0
- package/dist/doctor-Do7WDnrg.mjs +295 -0
- package/dist/init-ClloZBVB.mjs +353 -0
- package/dist/prompts-DVRcV560.mjs +100 -0
- package/package.json +40 -0
package/dist/deweyou.mjs
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { exit } from "node:process";
|
|
3
|
+
//#region src/cli/args.ts
|
|
4
|
+
const BOOLEAN_FLAGS = new Set([
|
|
5
|
+
"all",
|
|
6
|
+
"yes",
|
|
7
|
+
"dry-run",
|
|
8
|
+
"force"
|
|
9
|
+
]);
|
|
10
|
+
const VALUE_FLAGS = new Set([
|
|
11
|
+
"mode",
|
|
12
|
+
"skills",
|
|
13
|
+
"rules",
|
|
14
|
+
"format"
|
|
15
|
+
]);
|
|
16
|
+
const FLAGS_BY_COMMAND = {
|
|
17
|
+
init: new Set([
|
|
18
|
+
"all",
|
|
19
|
+
"skills",
|
|
20
|
+
"rules",
|
|
21
|
+
"mode",
|
|
22
|
+
"yes",
|
|
23
|
+
"dry-run",
|
|
24
|
+
"force"
|
|
25
|
+
]),
|
|
26
|
+
context: new Set(["format"]),
|
|
27
|
+
update: /* @__PURE__ */ new Set(),
|
|
28
|
+
doctor: /* @__PURE__ */ new Set()
|
|
29
|
+
};
|
|
30
|
+
function usageError(message, { silent = false } = {}) {
|
|
31
|
+
const error = new Error(message);
|
|
32
|
+
error.exitCode = 2;
|
|
33
|
+
error.silent = silent;
|
|
34
|
+
return error;
|
|
35
|
+
}
|
|
36
|
+
function parseArgs(argv) {
|
|
37
|
+
const [topic, command, ...rest] = argv;
|
|
38
|
+
const flags = {};
|
|
39
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
40
|
+
const token = rest[index];
|
|
41
|
+
if (!token.startsWith("--")) throw usageError(`Unexpected argument: ${token}`);
|
|
42
|
+
const name = token.slice(2);
|
|
43
|
+
if (!isKnownFlag(name)) throw usageError(`Unknown flag: --${name}`);
|
|
44
|
+
if (!isAllowedForCommand(command, name)) throw usageError(`Flag --${name} is not valid for agent ${command}`);
|
|
45
|
+
if (BOOLEAN_FLAGS.has(name)) {
|
|
46
|
+
flags[toCamel(name)] = true;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (VALUE_FLAGS.has(name)) {
|
|
50
|
+
const value = rest[index + 1];
|
|
51
|
+
if (!value || value.startsWith("--")) throw usageError(`Missing value for --${name}`);
|
|
52
|
+
flags[toCamel(name)] = parseValue(name, value);
|
|
53
|
+
index += 1;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (topic === "agent" && command === "context" && !flags.format) flags.format = "markdown";
|
|
58
|
+
return {
|
|
59
|
+
topic,
|
|
60
|
+
command,
|
|
61
|
+
flags
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function isKnownFlag(name) {
|
|
65
|
+
return BOOLEAN_FLAGS.has(name) || VALUE_FLAGS.has(name);
|
|
66
|
+
}
|
|
67
|
+
function isAllowedForCommand(command, name) {
|
|
68
|
+
if (!command) return false;
|
|
69
|
+
return FLAGS_BY_COMMAND[command]?.has(name) ?? false;
|
|
70
|
+
}
|
|
71
|
+
function parseValue(name, value) {
|
|
72
|
+
if (name === "skills" || name === "rules") return value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
function toCamel(value) {
|
|
76
|
+
return value.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
77
|
+
}
|
|
78
|
+
//#endregion
|
|
79
|
+
//#region src/cli/main.ts
|
|
80
|
+
async function main(argv) {
|
|
81
|
+
const parsed = parseArgs(argv);
|
|
82
|
+
if (parsed.topic !== "agent") printUsageAndThrow();
|
|
83
|
+
if (parsed.command === "init") {
|
|
84
|
+
const { runInit } = await import("./init-ClloZBVB.mjs");
|
|
85
|
+
await runInit(parsed.flags);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (parsed.command === "update") {
|
|
89
|
+
const { runUpdate } = await import("./cache-CBEKVQeD.mjs");
|
|
90
|
+
await runUpdate(parsed.flags);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (parsed.command === "context") {
|
|
94
|
+
const { runContext } = await import("./context-BYyhbv5D.mjs");
|
|
95
|
+
await runContext(parsed.flags);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (parsed.command === "doctor") {
|
|
99
|
+
const { runDoctor } = await import("./doctor-Do7WDnrg.mjs");
|
|
100
|
+
await runDoctor(parsed.flags);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
printUsageAndThrow();
|
|
104
|
+
}
|
|
105
|
+
function usage() {
|
|
106
|
+
return `Usage:
|
|
107
|
+
deweyou-cli agent init [--all] [--skills a,b] [--rules a,b] [--mode link|copy|pointer] [--yes] [--dry-run] [--force]
|
|
108
|
+
deweyou-cli agent update
|
|
109
|
+
deweyou-cli agent context [--format markdown|json]
|
|
110
|
+
deweyou-cli agent doctor`;
|
|
111
|
+
}
|
|
112
|
+
function printUsageAndThrow() {
|
|
113
|
+
console.log(usage());
|
|
114
|
+
throw usageError("", { silent: true });
|
|
115
|
+
}
|
|
116
|
+
//#endregion
|
|
117
|
+
//#region src/bin/deweyou.ts
|
|
118
|
+
main(process.argv.slice(2)).catch((error) => {
|
|
119
|
+
const cliError = error;
|
|
120
|
+
if (!cliError.silent && cliError.message) console.error(cliError.message);
|
|
121
|
+
exit(cliError.exitCode ?? 1);
|
|
122
|
+
});
|
|
123
|
+
//#endregion
|
|
124
|
+
export { usageError as t };
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { cachePaths, t as readJson } from "./cache-CBEKVQeD.mjs";
|
|
2
|
+
import { lstat, stat } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
//#region src/cli/doctor.ts
|
|
6
|
+
async function checkDoctor({ repoRoot = process.cwd(), homeDir = homedir() } = {}) {
|
|
7
|
+
const checks = [];
|
|
8
|
+
const registryPath = join(cachePaths({ homeDir }).assetsRoot, "registry.json");
|
|
9
|
+
const manifestPath = join(repoRoot, ".agents", "manifest.json");
|
|
10
|
+
const agentsMdPath = join(repoRoot, "AGENTS.md");
|
|
11
|
+
const registry = await readJsonCheck({
|
|
12
|
+
path: registryPath,
|
|
13
|
+
checks,
|
|
14
|
+
passMessage: "local asset cache registry exists",
|
|
15
|
+
missingMessage: "Dewey asset cache is missing. Run `deweyou-cli agent update`.",
|
|
16
|
+
invalidMessage: "Dewey asset cache registry is invalid JSON."
|
|
17
|
+
});
|
|
18
|
+
const manifest = await readJsonCheck({
|
|
19
|
+
path: manifestPath,
|
|
20
|
+
checks,
|
|
21
|
+
passMessage: "repository manifest exists",
|
|
22
|
+
missingMessage: "repository manifest is missing. Run `deweyou-cli agent init`.",
|
|
23
|
+
invalidMessage: "repository manifest is invalid JSON."
|
|
24
|
+
});
|
|
25
|
+
const registryValid = registry !== void 0 && validateRegistry(registry, checks);
|
|
26
|
+
const manifestValid = manifest !== void 0 && validateManifest(manifest, checks);
|
|
27
|
+
await statCheck({
|
|
28
|
+
path: agentsMdPath,
|
|
29
|
+
checks,
|
|
30
|
+
passMessage: "AGENTS.md exists",
|
|
31
|
+
missingMessage: "AGENTS.md is missing. Run `deweyou-cli agent init`."
|
|
32
|
+
});
|
|
33
|
+
if (manifestValid && registryValid) {
|
|
34
|
+
const validManifest = manifest;
|
|
35
|
+
const validRegistry = registry;
|
|
36
|
+
await checkSelectedAssets({
|
|
37
|
+
repoRoot,
|
|
38
|
+
cacheRoot: validManifest.cacheRoot,
|
|
39
|
+
manifest: validManifest,
|
|
40
|
+
registry: validRegistry,
|
|
41
|
+
checks
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
ok: checks.every((check) => check.status === "pass"),
|
|
46
|
+
checks
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
async function runDoctor(flags = {}) {
|
|
50
|
+
const result = await checkDoctor({
|
|
51
|
+
repoRoot: flags.repoRoot ?? process.cwd(),
|
|
52
|
+
homeDir: flags.homeDir ?? homedir()
|
|
53
|
+
});
|
|
54
|
+
for (const check of result.checks) console.log(`${check.status.toUpperCase()} ${check.message}`);
|
|
55
|
+
if (!result.ok) process.exitCode = 1;
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
async function readJsonCheck({ path, checks, passMessage, missingMessage, invalidMessage }) {
|
|
59
|
+
try {
|
|
60
|
+
const value = await readJson(path);
|
|
61
|
+
checks.push(pass(passMessage));
|
|
62
|
+
return value;
|
|
63
|
+
} catch (error) {
|
|
64
|
+
/* v8 ignore next -- defensive guard for non-Error throws */
|
|
65
|
+
if (!(error instanceof Error)) throw error;
|
|
66
|
+
if (!("code" in error)) {
|
|
67
|
+
if (error instanceof SyntaxError) {
|
|
68
|
+
checks.push(fail(invalidMessage));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
/* v8 ignore next -- unexpected JSON helper errors should bubble */
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
if (error.code === "ENOENT") {
|
|
75
|
+
checks.push(fail(missingMessage));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
/* v8 ignore next -- retained for JSON parsers that attach code fields */
|
|
79
|
+
if (error instanceof SyntaxError) {
|
|
80
|
+
checks.push(fail(invalidMessage));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
/* v8 ignore next -- non-missing read errors should bubble to the caller */
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async function statCheck({ path, checks, passMessage, missingMessage }) {
|
|
88
|
+
try {
|
|
89
|
+
await stat(path);
|
|
90
|
+
checks.push(pass(passMessage));
|
|
91
|
+
return true;
|
|
92
|
+
} catch (error) {
|
|
93
|
+
/* v8 ignore next -- defensive guard for non-Node filesystem errors */
|
|
94
|
+
if (!(error instanceof Error) || !("code" in error)) throw error;
|
|
95
|
+
if (error.code === "ENOENT") {
|
|
96
|
+
checks.push(fail(missingMessage));
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
/* v8 ignore next -- non-missing stat errors should bubble to the caller */
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function validateRegistry(registry, checks) {
|
|
104
|
+
const before = checks.length;
|
|
105
|
+
if (!isPlainObject(registry)) {
|
|
106
|
+
checks.push(fail("registry must be an object"));
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
if (!isPlainObject(registry.assets)) checks.push(fail("registry assets must be an object"));
|
|
110
|
+
const assets = isPlainObject(registry.assets) ? registry.assets : {};
|
|
111
|
+
if (!isPlainObject(assets.skills)) checks.push(fail("registry assets.skills must be an object"));
|
|
112
|
+
if (!isPlainObject(assets.rules)) checks.push(fail("registry assets.rules must be an object"));
|
|
113
|
+
if (isPlainObject(assets.skills)) validateRegistryAssetGroup(assets.skills, "skill", checks);
|
|
114
|
+
if (isPlainObject(assets.rules)) validateRegistryAssetGroup(assets.rules, "rule", checks);
|
|
115
|
+
return checks.length === before;
|
|
116
|
+
}
|
|
117
|
+
function validateManifest(manifest, checks) {
|
|
118
|
+
const before = checks.length;
|
|
119
|
+
if (!isPlainObject(manifest)) {
|
|
120
|
+
checks.push(fail("manifest must be an object"));
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
if (typeof manifest.mode !== "string" || ![
|
|
124
|
+
"link",
|
|
125
|
+
"copy",
|
|
126
|
+
"pointer"
|
|
127
|
+
].includes(manifest.mode)) checks.push(fail("manifest mode must be one of link, copy, or pointer"));
|
|
128
|
+
if (!isPlainObject(manifest.source)) checks.push(fail("manifest source must be an object"));
|
|
129
|
+
else {
|
|
130
|
+
if (typeof manifest.source.root !== "string") checks.push(fail("manifest source.root must be a string"));
|
|
131
|
+
if (manifest.source.commit !== null && typeof manifest.source.commit !== "string") checks.push(fail("manifest source.commit must be a string or null"));
|
|
132
|
+
}
|
|
133
|
+
if (typeof manifest.cacheRoot !== "string") checks.push(fail("manifest cacheRoot must be a string"));
|
|
134
|
+
if (!isPlainObject(manifest.assets)) checks.push(fail("manifest assets must be an object"));
|
|
135
|
+
const assets = isPlainObject(manifest.assets) ? manifest.assets : {};
|
|
136
|
+
if (!Array.isArray(assets.skills)) checks.push(fail("manifest assets.skills must be an array"));
|
|
137
|
+
if (!Array.isArray(assets.rules)) checks.push(fail("manifest assets.rules must be an array"));
|
|
138
|
+
return checks.length === before;
|
|
139
|
+
}
|
|
140
|
+
async function checkSelectedAssets({ repoRoot, cacheRoot, manifest, registry, checks }) {
|
|
141
|
+
const registryAssets = {
|
|
142
|
+
skills: registry.assets?.skills ?? {},
|
|
143
|
+
rules: registry.assets?.rules ?? {}
|
|
144
|
+
};
|
|
145
|
+
const selected = {
|
|
146
|
+
skills: manifest.assets?.skills ?? [],
|
|
147
|
+
rules: manifest.assets?.rules ?? []
|
|
148
|
+
};
|
|
149
|
+
const snapshot = {
|
|
150
|
+
skills: manifest.assetSnapshot?.skills ?? {},
|
|
151
|
+
rules: manifest.assetSnapshot?.rules ?? {}
|
|
152
|
+
};
|
|
153
|
+
for (const name of selected.skills) {
|
|
154
|
+
const asset = registryAssets.skills[name];
|
|
155
|
+
if (!asset) {
|
|
156
|
+
checks.push(fail(`selected skill ${name} is missing from the registry`));
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (!isValidRegistryPath(asset.path)) {
|
|
160
|
+
checks.push(fail(`selected skill ${name} has invalid registry path`));
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
checkAssetHashSnapshot({
|
|
164
|
+
kind: "skill",
|
|
165
|
+
name,
|
|
166
|
+
currentHash: asset.hash,
|
|
167
|
+
snapshotHash: snapshot.skills[name]?.hash,
|
|
168
|
+
checks
|
|
169
|
+
});
|
|
170
|
+
await checkAssetPath({
|
|
171
|
+
kind: "skill",
|
|
172
|
+
name,
|
|
173
|
+
filePath: manifest.mode === "pointer" ? join(cacheRoot, asset.path, "SKILL.md") : join(repoRoot, ".agents", "skills", name, "SKILL.md"),
|
|
174
|
+
linkPath: manifest.mode === "pointer" ? null : join(repoRoot, ".agents", "skills", name),
|
|
175
|
+
checks
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
for (const name of selected.rules) {
|
|
179
|
+
const asset = registryAssets.rules[name];
|
|
180
|
+
if (!asset) {
|
|
181
|
+
checks.push(fail(`selected rule ${name} is missing from the registry`));
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (!isValidRegistryPath(asset.path)) {
|
|
185
|
+
checks.push(fail(`selected rule ${name} has invalid registry path`));
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
checkAssetHashSnapshot({
|
|
189
|
+
kind: "rule",
|
|
190
|
+
name,
|
|
191
|
+
currentHash: asset.hash,
|
|
192
|
+
snapshotHash: snapshot.rules[name]?.hash,
|
|
193
|
+
checks
|
|
194
|
+
});
|
|
195
|
+
const filePath = manifest.mode === "pointer" ? join(cacheRoot, asset.path) : join(repoRoot, ".agents", "rules", `${name}.md`);
|
|
196
|
+
await checkAssetPath({
|
|
197
|
+
kind: "rule",
|
|
198
|
+
name,
|
|
199
|
+
filePath,
|
|
200
|
+
linkPath: manifest.mode === "pointer" ? null : filePath,
|
|
201
|
+
checks
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
function validateRegistryAssetGroup(assets, kind, checks) {
|
|
206
|
+
for (const [name, asset] of Object.entries(assets)) {
|
|
207
|
+
if (!isPlainObject(asset)) {
|
|
208
|
+
checks.push(fail(`registry ${kind} ${name} must be an object`));
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (typeof asset.path !== "string") checks.push(fail(`registry ${kind} ${name} path must be a string`));
|
|
212
|
+
if (typeof asset.description !== "string") checks.push(fail(`registry ${kind} ${name} description must be a string`));
|
|
213
|
+
if (typeof asset.hash !== "string" || !asset.hash.startsWith("sha256:")) checks.push(fail(`registry ${kind} ${name} hash must be a sha256 content hash`));
|
|
214
|
+
if (!Array.isArray(asset.tags)) checks.push(fail(`registry ${kind} ${name} tags must be an array`));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
function checkAssetHashSnapshot({ kind, name, currentHash, snapshotHash, checks }) {
|
|
218
|
+
if (!snapshotHash) {
|
|
219
|
+
checks.push(fail(`selected ${kind} ${name} is missing an initialized hash snapshot`));
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (currentHash !== snapshotHash) checks.push(fail(`selected ${kind} ${name} has changed in the local asset cache`));
|
|
223
|
+
}
|
|
224
|
+
async function checkAssetPath({ kind, name, filePath, linkPath, checks }) {
|
|
225
|
+
if (linkPath) {
|
|
226
|
+
if (!await checkLinkTarget({
|
|
227
|
+
kind,
|
|
228
|
+
name,
|
|
229
|
+
linkPath,
|
|
230
|
+
checks
|
|
231
|
+
})) return;
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
await stat(filePath);
|
|
235
|
+
checks.push(pass(`selected ${kind} ${name} path exists`));
|
|
236
|
+
} catch (error) {
|
|
237
|
+
/* v8 ignore next -- defensive guard for non-Node filesystem errors */
|
|
238
|
+
if (!(error instanceof Error) || !("code" in error)) throw error;
|
|
239
|
+
if (error.code === "ENOENT") {
|
|
240
|
+
checks.push(fail(`selected ${kind} ${name} path is missing: ${filePath}`));
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
/* v8 ignore next -- non-missing stat errors should bubble to the caller */
|
|
244
|
+
throw error;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
async function checkLinkTarget({ kind, name, linkPath, checks }) {
|
|
248
|
+
let entry;
|
|
249
|
+
try {
|
|
250
|
+
entry = await lstat(linkPath);
|
|
251
|
+
} catch (error) {
|
|
252
|
+
/* v8 ignore next -- defensive guard for non-Node filesystem errors */
|
|
253
|
+
if (!(error instanceof Error) || !("code" in error)) throw error;
|
|
254
|
+
if (error.code === "ENOENT") {
|
|
255
|
+
checks.push(fail(`selected ${kind} ${name} path is missing: ${linkPath}`));
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
/* v8 ignore next -- non-missing lstat errors should bubble to the caller */
|
|
259
|
+
throw error;
|
|
260
|
+
}
|
|
261
|
+
if (!entry.isSymbolicLink()) return true;
|
|
262
|
+
try {
|
|
263
|
+
await stat(linkPath);
|
|
264
|
+
return true;
|
|
265
|
+
} catch (error) {
|
|
266
|
+
/* v8 ignore next -- defensive guard for non-Node filesystem errors */
|
|
267
|
+
if (!(error instanceof Error) || !("code" in error)) throw error;
|
|
268
|
+
if (error.code === "ENOENT") {
|
|
269
|
+
checks.push(fail(`selected ${kind} ${name} symlink is broken: ${linkPath}`));
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
/* v8 ignore next -- non-missing stat errors should bubble to the caller */
|
|
273
|
+
throw error;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
function pass(message) {
|
|
277
|
+
return {
|
|
278
|
+
status: "pass",
|
|
279
|
+
message
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
function fail(message) {
|
|
283
|
+
return {
|
|
284
|
+
status: "fail",
|
|
285
|
+
message
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
function isValidRegistryPath(path) {
|
|
289
|
+
return typeof path === "string" && path.length > 0;
|
|
290
|
+
}
|
|
291
|
+
function isPlainObject(value) {
|
|
292
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
293
|
+
}
|
|
294
|
+
//#endregion
|
|
295
|
+
export { runDoctor };
|