askmri 0.0.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/README.md ADDED
@@ -0,0 +1,10 @@
1
+ # AskMRI
2
+
3
+ The open AI workbench for MRI research.
4
+
5
+ ```bash
6
+ npx askmri doctor
7
+ npx askmri setup
8
+ ```
9
+
10
+ [askmri.com](https://askmri.com) · [GitHub](https://github.com/askmri)
@@ -0,0 +1,45 @@
1
+ type EnvGroup = "base" | "ants" | "totalseg" | "brats";
2
+ /**
3
+ * Bootstrap pipeline:
4
+ * 1. ensure `uv` is on PATH (install if missing)
5
+ * 2. ensure ASKMRI_HOME directories exist
6
+ * 3. ensure a venv with `askmri[full-light]` installed
7
+ *
8
+ * Per-tool-group venvs are created lazily via `ensureGroupVenv()` when
9
+ * installing models that require isolated environments (ants, totalseg, brats).
10
+ *
11
+ * The heavy ML extras stay opt-in via `askmri setup --with ants,total`.
12
+ */
13
+ interface CommandCheck {
14
+ ok: boolean;
15
+ version?: string;
16
+ }
17
+ export declare function commandExists(name: string): Promise<CommandCheck>;
18
+ export declare function ensureUv(): Promise<void>;
19
+ export declare function ensureDirs(): void;
20
+ export interface InstallOptions {
21
+ extras?: string[];
22
+ force?: boolean;
23
+ }
24
+ export declare function ensureVenv(opts?: InstallOptions): Promise<void>;
25
+ export interface GroupVenvOptions {
26
+ /** Force reinstall even if marker exists */
27
+ force?: boolean;
28
+ }
29
+ /**
30
+ * Ensure a per-tool-group venv exists and has its dependencies installed.
31
+ * Used for isolated environments (ants, totalseg, brats) that have conflicting
32
+ * numpy requirements.
33
+ *
34
+ * - "base" is a no-op (always uses the main venv)
35
+ * - Other groups create ~/.askmri/venvs/{group}/ with Python 3.11
36
+ *
37
+ * Returns the venv path.
38
+ */
39
+ export declare function ensureGroupVenv(group: EnvGroup, opts?: GroupVenvOptions): Promise<string>;
40
+ /**
41
+ * Check if a group's venv is installed (marker file exists).
42
+ */
43
+ export declare function isGroupVenvInstalled(group: EnvGroup): boolean;
44
+ export {};
45
+ //# sourceMappingURL=uv.d.ts.map
@@ -0,0 +1,138 @@
1
+ import { mkdirSync, existsSync, writeFileSync } from "node:fs";
2
+ import { x } from "tinyexec";
3
+ import { log } from "../log.js";
4
+ import { paths, venvFor, venvMarkerFor } from "../paths.js";
5
+ const ENV_GROUP_LABELS = {
6
+ base: "Base (builtin)",
7
+ ants: "ANTsPy / ANTsPyNet",
8
+ totalseg: "TotalSegmentator",
9
+ brats: "BraTS Toolkit",
10
+ };
11
+ const ENV_GROUP_INSTALL_CMDS = {
12
+ base: null,
13
+ ants: "pip install antspynet",
14
+ totalseg: "pip install TotalSegmentator",
15
+ brats: "pip install brats-toolkit",
16
+ };
17
+ export async function commandExists(name) {
18
+ try {
19
+ const result = await x(name, ["--version"], { throwOnError: false });
20
+ if (result.exitCode === 0) {
21
+ const firstLine = result.stdout.trim().split("\n")[0];
22
+ return { ok: true, version: firstLine ?? result.stdout.trim() };
23
+ }
24
+ return { ok: false };
25
+ }
26
+ catch {
27
+ return { ok: false };
28
+ }
29
+ }
30
+ export async function ensureUv() {
31
+ const uv = await commandExists("uv");
32
+ if (uv.ok) {
33
+ log.ok(`uv detected (${uv.version})`);
34
+ return;
35
+ }
36
+ log.warn("uv not found — installing it locally");
37
+ // Use the official installer; it places `uv` at ~/.local/bin/uv (Unix) or %USERPROFILE%\.local\bin (Win).
38
+ if (process.platform === "win32") {
39
+ await x("powershell", [
40
+ "-ExecutionPolicy",
41
+ "ByPass",
42
+ "-c",
43
+ "irm https://astral.sh/uv/install.ps1 | iex",
44
+ ]);
45
+ }
46
+ else {
47
+ await x("sh", ["-c", "curl -LsSf https://astral.sh/uv/install.sh | sh"]);
48
+ }
49
+ const recheck = await commandExists("uv");
50
+ if (!recheck.ok) {
51
+ throw new Error("uv installation finished but `uv` is still not on PATH. Add ~/.local/bin to PATH and re-run.");
52
+ }
53
+ log.ok(`uv installed (${recheck.version})`);
54
+ }
55
+ export function ensureDirs() {
56
+ for (const dir of [paths.home, paths.bin, paths.venvs, paths.cache, paths.data, paths.logs]) {
57
+ if (!existsSync(dir))
58
+ mkdirSync(dir, { recursive: true });
59
+ }
60
+ }
61
+ export async function ensureVenv(opts = {}) {
62
+ ensureDirs();
63
+ const venvExists = existsSync(paths.venv);
64
+ if (venvExists && existsSync(paths.marker) && !opts.force) {
65
+ log.ok("Python environment already installed");
66
+ return;
67
+ }
68
+ if (!venvExists) {
69
+ log.step(`Creating Python venv at ${paths.venv}`);
70
+ await x("uv", ["venv", "--python", "3.11", paths.venv]);
71
+ }
72
+ const pkg = opts.extras?.length ? `askmri[${opts.extras.join(",")}]` : "askmri";
73
+ log.step(`Installing ${pkg} into the venv (this may take a minute)`);
74
+ await x("uv", ["pip", "install", "--python", paths.venv, pkg], {
75
+ nodeOptions: { stdio: "inherit" },
76
+ });
77
+ // Drop a marker file so subsequent invocations skip the install step.
78
+ writeFileSync(paths.marker, new Date().toISOString());
79
+ log.ok("Python environment ready");
80
+ }
81
+ /**
82
+ * Ensure a per-tool-group venv exists and has its dependencies installed.
83
+ * Used for isolated environments (ants, totalseg, brats) that have conflicting
84
+ * numpy requirements.
85
+ *
86
+ * - "base" is a no-op (always uses the main venv)
87
+ * - Other groups create ~/.askmri/venvs/{group}/ with Python 3.11
88
+ *
89
+ * Returns the venv path.
90
+ */
91
+ export async function ensureGroupVenv(group, opts = {}) {
92
+ // "base" group uses the main venv — nothing to do
93
+ if (group === "base") {
94
+ return paths.venv;
95
+ }
96
+ ensureDirs();
97
+ const venvPath = venvFor(group);
98
+ const markerPath = venvMarkerFor(group);
99
+ const venvExists = existsSync(venvPath);
100
+ const markerExists = existsSync(markerPath);
101
+ // If already installed and not forcing, skip
102
+ if (venvExists && markerExists && !opts.force) {
103
+ log.ok(`${ENV_GROUP_LABELS[group]} environment already installed`);
104
+ return venvPath;
105
+ }
106
+ // Create the venv if it doesn't exist
107
+ if (!venvExists) {
108
+ log.step(`Creating isolated venv for ${ENV_GROUP_LABELS[group]} at ${venvPath}`);
109
+ await x("uv", ["venv", "--python", "3.11", venvPath]);
110
+ }
111
+ // Install group dependencies
112
+ const installCmd = ENV_GROUP_INSTALL_CMDS[group];
113
+ if (installCmd) {
114
+ // Extract package spec from "pip install <package>"
115
+ const packageSpec = installCmd.replace(/^pip install\s+/, "").replace(/^['"]|['"]$/g, "");
116
+ log.step(`Installing ${packageSpec} into ${group} venv`);
117
+ const result = await x("uv", ["pip", "install", "--python", venvPath, packageSpec], {
118
+ nodeOptions: { stdio: "inherit" },
119
+ throwOnError: false,
120
+ });
121
+ if (result.exitCode !== 0) {
122
+ throw new Error(`Failed to install ${packageSpec} (exit code ${result.exitCode})`);
123
+ }
124
+ }
125
+ // Drop marker only after successful install
126
+ writeFileSync(markerPath, new Date().toISOString());
127
+ log.ok(`${ENV_GROUP_LABELS[group]} environment ready`);
128
+ return venvPath;
129
+ }
130
+ /**
131
+ * Check if a group's venv is installed (marker file exists).
132
+ */
133
+ export function isGroupVenvInstalled(group) {
134
+ if (group === "base") {
135
+ return existsSync(paths.marker);
136
+ }
137
+ return existsSync(venvMarkerFor(group));
138
+ }
@@ -0,0 +1,2 @@
1
+ export declare function doctor(): Promise<number>;
2
+ //# sourceMappingURL=doctor.d.ts.map
@@ -0,0 +1,213 @@
1
+ import { existsSync, readdirSync, statSync } from "node:fs";
2
+ import { totalmem } from "node:os";
3
+ import { join } from "node:path";
4
+ import { x } from "tinyexec";
5
+ import { commandExists } from "../bootstrap/uv.js";
6
+ import { log } from "../log.js";
7
+ import { monaiBundlesDir, paths, pythonBin, pythonCliBin } from "../paths.js";
8
+ /**
9
+ * Probe a Python import inside the managed venv. Returns the version
10
+ * string emitted by `<pkg>.__version__` when available, otherwise "ok".
11
+ */
12
+ async function probePythonImport(pkg, attr = "__version__") {
13
+ if (!existsSync(pythonBin())) {
14
+ return { ok: false, detail: "venv missing" };
15
+ }
16
+ const script = `import ${pkg}; v=getattr(${pkg}, '${attr}', 'ok'); print(v)`;
17
+ try {
18
+ const r = await x(pythonBin(), ["-c", script], { throwOnError: false });
19
+ if (r.exitCode === 0) {
20
+ return { ok: true, detail: (r.stdout ?? "").trim() || "ok" };
21
+ }
22
+ return { ok: false, detail: "not installed" };
23
+ }
24
+ catch {
25
+ return { ok: false, detail: "probe failed" };
26
+ }
27
+ }
28
+ async function probeGpu() {
29
+ if (!existsSync(pythonBin())) {
30
+ return { ok: false, detail: "venv missing" };
31
+ }
32
+ const script = "import torch; "
33
+ + "cuda=torch.cuda.is_available(); "
34
+ + "mps=getattr(torch.backends,'mps',None) and torch.backends.mps.is_available(); "
35
+ + "print('cuda' if cuda else ('mps' if mps else 'cpu'))";
36
+ try {
37
+ const r = await x(pythonBin(), ["-c", script], { throwOnError: false });
38
+ if (r.exitCode !== 0)
39
+ return { ok: false, detail: "torch not installed" };
40
+ const dev = (r.stdout ?? "").trim();
41
+ return { ok: dev !== "cpu", detail: dev || "cpu" };
42
+ }
43
+ catch {
44
+ return { ok: false, detail: "probe failed" };
45
+ }
46
+ }
47
+ function bundleCacheSummary() {
48
+ if (!existsSync(monaiBundlesDir))
49
+ return "empty";
50
+ try {
51
+ const entries = readdirSync(monaiBundlesDir).filter((e) => statSync(join(monaiBundlesDir, e)).isDirectory());
52
+ return entries.length === 0
53
+ ? "empty"
54
+ : `${entries.length} bundle(s): ${entries.slice(0, 3).join(", ")}${entries.length > 3 ? "…" : ""}`;
55
+ }
56
+ catch {
57
+ return "unreadable";
58
+ }
59
+ }
60
+ async function checks() {
61
+ const results = [];
62
+ // ---------- Core ----------
63
+ const node = process.versions.node;
64
+ const nodeMajor = node.split(".")[0];
65
+ results.push({
66
+ name: "Node.js",
67
+ required: true,
68
+ ok: nodeMajor !== undefined && parseInt(nodeMajor, 10) >= 18,
69
+ detail: `v${node}`,
70
+ fix: "Upgrade to Node 18.18+ from https://nodejs.org",
71
+ });
72
+ const uv = await commandExists("uv");
73
+ results.push({
74
+ name: "uv (Python installer)",
75
+ required: true,
76
+ ok: uv.ok,
77
+ detail: uv.version ?? "not found",
78
+ fix: "Run `askmri setup` to install uv automatically.",
79
+ });
80
+ const venvOk = existsSync(pythonCliBin());
81
+ results.push({
82
+ name: "Python venv + askmri-server",
83
+ required: true,
84
+ ok: venvOk,
85
+ detail: venvOk ? paths.venv : "not installed",
86
+ fix: "Run `askmri setup`",
87
+ });
88
+ // ---------- Optional binaries on PATH ----------
89
+ const dcm2niix = await commandExists("dcm2niix");
90
+ results.push({
91
+ name: "dcm2niix (DICOM → NIfTI)",
92
+ required: false,
93
+ ok: dcm2niix.ok,
94
+ detail: dcm2niix.version ?? "not found",
95
+ fix: "macOS: `brew install dcm2niix` · Ubuntu: `apt install dcm2niix`",
96
+ });
97
+ const synthstrip = await commandExists("mri_synthstrip");
98
+ results.push({
99
+ name: "mri_synthstrip (alt skull-strip)",
100
+ required: false,
101
+ ok: synthstrip.ok,
102
+ detail: synthstrip.version ?? "not found",
103
+ fix: "FreeSurfer 7.3+ or https://surfer.nmr.mgh.harvard.edu/docs/synthstrip",
104
+ });
105
+ const npx = await commandExists("npx");
106
+ results.push({
107
+ name: "npx (for bids-validator)",
108
+ required: false,
109
+ ok: npx.ok,
110
+ detail: npx.version ?? "not found",
111
+ fix: "Bundled with Node.js 18+",
112
+ });
113
+ // ---------- Optional Python packages ----------
114
+ if (venvOk) {
115
+ const optional = [
116
+ ["pyradiomics", "radiomics", "pip install 'askmri-core[radiomics]'"],
117
+ ["highdicom", "highdicom", "pip install 'askmri-core[interop]'"],
118
+ ["nilearn", "nilearn", "pip install 'askmri-core[reports]'"],
119
+ ["monai", "monai", "pip install 'askmri-core[monai]'"],
120
+ ["dicomanonymizer", "dicomanonymizer", "pip install 'askmri-core[anonymize]'"],
121
+ ["torch", "torch", "pip install 'askmri-core[torch]'"],
122
+ ["antspynet", "antspynet", "pip install 'askmri-core[ants]'"],
123
+ ["HD-BET", "HD_BET", "pip install 'askmri-core[hdbet]'"],
124
+ ["TotalSegmentator", "totalsegmentator", "pip install TotalSegmentator"],
125
+ ];
126
+ for (const [label, importName, fix] of optional) {
127
+ const r = await probePythonImport(importName);
128
+ results.push({
129
+ name: label,
130
+ required: false,
131
+ ok: r.ok,
132
+ detail: r.detail,
133
+ fix,
134
+ });
135
+ }
136
+ }
137
+ // ---------- GPU ----------
138
+ if (venvOk) {
139
+ const gpu = await probeGpu();
140
+ results.push({
141
+ name: "Accelerator",
142
+ required: false,
143
+ ok: gpu.ok,
144
+ detail: gpu.detail,
145
+ fix: "Install torch with CUDA / MPS support for GPU acceleration.",
146
+ });
147
+ }
148
+ // ---------- MONAI bundle cache ----------
149
+ results.push({
150
+ name: "MONAI bundle cache",
151
+ required: false,
152
+ ok: existsSync(monaiBundlesDir),
153
+ detail: bundleCacheSummary(),
154
+ fix: "First `askmri-server monai-bundle download <name>` will create it.",
155
+ });
156
+ // ---------- Host resources ----------
157
+ const ram = totalmem() / 1024 / 1024 / 1024;
158
+ results.push({
159
+ name: "System RAM",
160
+ required: false,
161
+ ok: ram >= 8,
162
+ detail: `${ram.toFixed(1)} GiB`,
163
+ fix: "8 GiB minimum recommended; 16 GiB comfortable for TotalSegmentator.",
164
+ });
165
+ const dataDirExists = existsSync(paths.data);
166
+ results.push({
167
+ name: "Data directory",
168
+ required: false,
169
+ ok: dataDirExists,
170
+ detail: dataDirExists ? `${paths.data} (${dirSize(paths.data)})` : "missing",
171
+ fix: "`askmri setup` will create it.",
172
+ });
173
+ return results;
174
+ }
175
+ function dirSize(_dir) {
176
+ try {
177
+ const s = statSync(_dir);
178
+ return s.isDirectory() ? "ready" : "not a directory";
179
+ }
180
+ catch {
181
+ return "unknown";
182
+ }
183
+ }
184
+ export async function doctor() {
185
+ log.header("AskMRI compatibility check");
186
+ const all = await checks();
187
+ let hasFailure = false;
188
+ for (const c of all) {
189
+ const label = `${c.name.padEnd(34)} ${c.detail}`;
190
+ if (c.ok) {
191
+ log.ok(label);
192
+ }
193
+ else if (c.required) {
194
+ log.error(label);
195
+ if (c.fix)
196
+ log.step(`fix: ${c.fix}`);
197
+ hasFailure = true;
198
+ }
199
+ else {
200
+ log.warn(label);
201
+ if (c.fix)
202
+ log.step(`fix: ${c.fix}`);
203
+ }
204
+ }
205
+ console.error();
206
+ if (hasFailure) {
207
+ log.error("Some required dependencies are missing. Run `askmri setup`.");
208
+ return 1;
209
+ }
210
+ log.ok("Ready for MRI research.");
211
+ log.step("Install optional extras: `askmri setup --with radiomics,reports,interop,monai,anonymize,full`");
212
+ return 0;
213
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Forward every argument unchanged to the bundled `askmri-server` Python
3
+ * binary. If the venv is missing, install it first.
4
+ *
5
+ * Examples:
6
+ * askmri info ./dicom → askmri-server info ./dicom
7
+ * askmri run ./dicom -m totalseg → askmri-server run ./dicom -m totalseg
8
+ */
9
+ export declare function passthrough(args: string[]): Promise<number>;
10
+ //# sourceMappingURL=passthrough.d.ts.map
@@ -0,0 +1,25 @@
1
+ import { existsSync } from "node:fs";
2
+ import { x } from "tinyexec";
3
+ import { ensureUv, ensureVenv } from "../bootstrap/uv.js";
4
+ import { log } from "../log.js";
5
+ import { pythonCliBin } from "../paths.js";
6
+ /**
7
+ * Forward every argument unchanged to the bundled `askmri-server` Python
8
+ * binary. If the venv is missing, install it first.
9
+ *
10
+ * Examples:
11
+ * askmri info ./dicom → askmri-server info ./dicom
12
+ * askmri run ./dicom -m totalseg → askmri-server run ./dicom -m totalseg
13
+ */
14
+ export async function passthrough(args) {
15
+ if (!existsSync(pythonCliBin())) {
16
+ log.info("First run — installing the Python pipeline…");
17
+ await ensureUv();
18
+ await ensureVenv();
19
+ }
20
+ const result = await x(pythonCliBin(), args, {
21
+ nodeOptions: { stdio: "inherit" },
22
+ throwOnError: false,
23
+ });
24
+ return result.exitCode ?? 1;
25
+ }
@@ -0,0 +1,6 @@
1
+ import { type InstallOptions } from "../bootstrap/uv.js";
2
+ export interface SetupOptions extends InstallOptions {
3
+ }
4
+ export declare function setup(opts?: SetupOptions): Promise<number>;
5
+ export declare function parseSetupArgs(argv: string[]): SetupOptions;
6
+ //# sourceMappingURL=setup.d.ts.map
@@ -0,0 +1,40 @@
1
+ import { ensureUv, ensureVenv } from "../bootstrap/uv.js";
2
+ import { log } from "../log.js";
3
+ import { paths } from "../paths.js";
4
+ export async function setup(opts = {}) {
5
+ log.header("Setup");
6
+ log.info(`Home: ${paths.home}`);
7
+ if (opts.extras?.length)
8
+ log.info(`Extras: ${opts.extras.join(", ")}`);
9
+ if (opts.force)
10
+ log.info("Force reinstall: yes");
11
+ try {
12
+ await ensureUv();
13
+ await ensureVenv(opts);
14
+ }
15
+ catch (err) {
16
+ log.error(err instanceof Error ? err.message : String(err));
17
+ log.step("If this is a network issue, retry. Otherwise report at https://github.com/iplanwebsites/askmri/issues");
18
+ return 1;
19
+ }
20
+ console.error();
21
+ log.ok("Setup complete.");
22
+ log.step("Next: `askmri doctor` to verify, or `askmri serve` to launch the viewer.");
23
+ return 0;
24
+ }
25
+ export function parseSetupArgs(argv) {
26
+ const opts = {};
27
+ for (let i = 0; i < argv.length; i++) {
28
+ const a = argv[i];
29
+ if (a === "--with" || a === "-w") {
30
+ const next = argv[++i];
31
+ if (!next)
32
+ continue;
33
+ opts.extras = next.split(",").map((s) => s.trim()).filter(Boolean);
34
+ }
35
+ else if (a === "--force" || a === "-f") {
36
+ opts.force = true;
37
+ }
38
+ }
39
+ return opts;
40
+ }
@@ -0,0 +1,2 @@
1
+ export declare function versionCommand(args: string[]): Promise<number>;
2
+ //# sourceMappingURL=version.d.ts.map
@@ -0,0 +1,62 @@
1
+ import { existsSync } from "node:fs";
2
+ import { x } from "tinyexec";
3
+ import kleur from "kleur";
4
+ import { VERSION } from "../version.js";
5
+ import { pythonCliBin } from "../paths.js";
6
+ import { log } from "../log.js";
7
+ async function readPythonVersions() {
8
+ if (!existsSync(pythonCliBin()))
9
+ return null;
10
+ const res = await x(pythonCliBin(), ["version-json"], { throwOnError: false });
11
+ if (res.exitCode !== 0)
12
+ return null;
13
+ try {
14
+ return JSON.parse(res.stdout);
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
20
+ export async function versionCommand(args) {
21
+ const check = args.includes("--check");
22
+ const json = args.includes("--json");
23
+ const py = await readPythonVersions();
24
+ if (json) {
25
+ console.log(JSON.stringify({
26
+ launcher: VERSION,
27
+ python: py,
28
+ }, null, 2));
29
+ return 0;
30
+ }
31
+ if (!check) {
32
+ console.log(VERSION);
33
+ return 0;
34
+ }
35
+ // --check mode: print a comparison table and fail on drift.
36
+ console.error(kleur.bold("Version alignment\n"));
37
+ const row = (label, value, expected) => {
38
+ const v = value ?? kleur.dim("(not installed)");
39
+ const ok = !expected || value === expected;
40
+ const mark = ok ? kleur.green("✓") : kleur.red("✗");
41
+ console.error(` ${mark} ${label.padEnd(20)} ${v}`);
42
+ return ok;
43
+ };
44
+ let allAligned = row("launcher (npm)", VERSION);
45
+ if (!py) {
46
+ console.error(kleur.yellow("\n Python pipeline is not installed yet."));
47
+ console.error(kleur.dim(" Run `askmri setup` to install it."));
48
+ return check ? 1 : 0;
49
+ }
50
+ allAligned = row("askmri-cli", py["askmri-cli"], VERSION) && allAligned;
51
+ allAligned = row("askmri-core", py["askmri-core"], VERSION) && allAligned;
52
+ allAligned = row("askmri-api", py["askmri-api"], VERSION) && allAligned;
53
+ allAligned = row("askmri (meta)", py.askmri, VERSION) && allAligned;
54
+ console.error();
55
+ if (!allAligned) {
56
+ log.error("Version drift between the npm launcher and the installed Python pipeline.");
57
+ log.step("Run `askmri setup --force` to reinstall and align.");
58
+ return 1;
59
+ }
60
+ log.ok("All versions aligned.");
61
+ return 0;
62
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env node
2
+ import kleur from "kleur";
3
+ import { doctor } from "./commands/doctor.js";
4
+ import { setup, parseSetupArgs } from "./commands/setup.js";
5
+ import { passthrough } from "./commands/passthrough.js";
6
+ import { versionCommand } from "./commands/version.js";
7
+ import { paths } from "./paths.js";
8
+ import { VERSION } from "./version.js";
9
+ import { log, initFileLogging } from "./log.js";
10
+ // ============================================================================
11
+ // MINIMAL_MODE: Toggle full CLI features for npm publish.
12
+ // To enable full mode: set MINIMAL_MODE = false and uncomment the loaders.
13
+ // ============================================================================
14
+ const MINIMAL_MODE = true;
15
+ // const MINIMAL_MODE = false
16
+ // const loadServe = () => import("./commands/serve.js")
17
+ // const loadOpen = () => import("./commands/open.js")
18
+ // const loadConfig = () => import("./commands/config.js")
19
+ // const loadDatasets = () => import("./commands/datasets.js")
20
+ const HELP_MINIMAL = `${kleur.bold("askmri")} v${VERSION} — MRI research toolkit
21
+
22
+ ${kleur.dim("Usage:")} askmri <command> [options]
23
+
24
+ ${kleur.bold("Commands:")}
25
+ ${kleur.cyan("doctor")} Check Python, uv, environment
26
+ ${kleur.cyan("setup")} [--with X] Install the Python pipeline
27
+ ${kleur.cyan("version")} [--check] Print version
28
+ ${kleur.cyan("home")} Print the AskMRI home directory
29
+ ${kleur.cyan("help")} Show this message
30
+
31
+ ${kleur.bold("Examples:")}
32
+ ${kleur.dim("$")} npx askmri doctor
33
+ ${kleur.dim("$")} npx askmri setup
34
+
35
+ ${kleur.bold("Environment:")}
36
+ ASKMRI_HOME Override the install directory (default: ~/.askmri)
37
+
38
+ More at https://askmri.com
39
+ `;
40
+ const HELP_FULL = `${kleur.bold("askmri")} v${VERSION} — MRI research toolkit (research preview, not for clinical use)
41
+
42
+ ${kleur.dim("Usage:")} askmri <command> [options]
43
+
44
+ ${kleur.bold("Launcher commands:")}
45
+ ${kleur.cyan("open")} Open the viewer in a desktop app window (chromeless)
46
+ ${kleur.cyan("serve")} [--port P] Start the web viewer + API (headless, for dev/scripting)
47
+ ${kleur.cyan("datasets")} <cmd> Browse, download, and process public datasets
48
+ ${kleur.cyan("doctor")} Check uv, venv, optional Python deps, GPU, MONAI cache
49
+ ${kleur.cyan("setup")} [--with X] Install the Python pipeline (extras: radiomics,reports,interop,monai,anonymize,torch,ants,hdbet,full)
50
+ ${kleur.cyan("config")} <cmd> Manage API keys (set/get/list/delete/sync)
51
+ ${kleur.cyan("version")} [--check] Print launcher version; --check compares against the Python pipeline
52
+ ${kleur.cyan("home")} Print the AskMRI home directory
53
+ ${kleur.cyan("help")} Show this message
54
+
55
+ ${kleur.bold("Passthrough to the Python pipeline (askmri-server):")}
56
+ ${kleur.cyan("info")} <folder> DICOM metadata
57
+ ${kleur.cyan("run")} <folder> -m <method> Full pipeline
58
+ ${kleur.cyan("preprocess")} <folder> DICOM → NIfTI (auto-detects body region)
59
+ ${kleur.cyan("analyze")} <file> -m <method> Run a single method
60
+ ${kleur.cyan("stats")} <file> Volume statistics
61
+ ${kleur.cyan("analyze-all")} <file> Run multiple methods on one input
62
+ ${kleur.cyan("radiomics")} <image> <mask> PyRadiomics features per label
63
+ ${kleur.cyan("export-dicom-seg")} <mask> <reference> Mask → DICOM-SEG via highdicom
64
+ ${kleur.cyan("anonymize")} <path> PHI scrubbing on DICOM file/dir
65
+ ${kleur.cyan("html-report")} <scan_dir> Printable HTML report (nilearn)
66
+ ${kleur.cyan("monai-bundle")} {list|info|download|run} MONAI model-zoo loader
67
+ ${kleur.cyan("validate-bids")} <path> Validate a BIDS dataset (npx)
68
+
69
+ ${kleur.bold("Examples:")}
70
+ ${kleur.dim("$")} npx askmri open # Launch viewer in app window
71
+ ${kleur.dim("$")} npx askmri serve --port 5173 # Headless server for dev
72
+ ${kleur.dim("$")} npx askmri doctor
73
+ ${kleur.dim("$")} npx askmri setup
74
+ ${kleur.dim("$")} npx askmri run ./dicom -m totalseg
75
+
76
+ ${kleur.bold("Environment:")}
77
+ ASKMRI_HOME Override the install directory (default: ~/.askmri)
78
+ ASKMRI_QUIET Set to "1" to suppress non-error console output
79
+ ASKMRI_NO_LOG Set to "1" to disable file logging
80
+ `;
81
+ const HELP = MINIMAL_MODE ? HELP_MINIMAL : HELP_FULL;
82
+ // Commands that should NOT auto-print the launcher banner (they need clean
83
+ // stdout for piping, or print their own banner).
84
+ const QUIET_COMMANDS = new Set([
85
+ "version",
86
+ "home",
87
+ "help",
88
+ "-h",
89
+ "--help",
90
+ "-v",
91
+ "--version",
92
+ ]);
93
+ async function main(argv) {
94
+ const [cmd, ...rest] = argv;
95
+ if (cmd && !QUIET_COMMANDS.has(cmd)) {
96
+ initFileLogging();
97
+ log.banner(VERSION, paths.home);
98
+ }
99
+ switch (cmd) {
100
+ case undefined:
101
+ case "-h":
102
+ case "--help":
103
+ case "help":
104
+ console.error(HELP);
105
+ return 0;
106
+ case "-v":
107
+ case "--version":
108
+ case "version":
109
+ return versionCommand(rest);
110
+ case "home":
111
+ console.log(paths.home);
112
+ return 0;
113
+ case "doctor":
114
+ return doctor();
115
+ case "setup":
116
+ if (rest.length === 0 || rest.every((a) => a.startsWith("-"))) {
117
+ return setup(parseSetupArgs(rest));
118
+ }
119
+ if (MINIMAL_MODE) {
120
+ console.error(`Unknown command: ${cmd}`);
121
+ return 1;
122
+ }
123
+ return passthrough([cmd, ...rest]);
124
+ // [FULL] case "serve": {
125
+ // [FULL] const { serve, parseServeArgs } = await loadServe()
126
+ // [FULL] return serve(parseServeArgs(rest))
127
+ // [FULL] }
128
+ // [FULL] case "open": {
129
+ // [FULL] const { open, parseOpenArgs } = await loadOpen()
130
+ // [FULL] return open(parseOpenArgs(rest))
131
+ // [FULL] }
132
+ // [FULL] case "config": {
133
+ // [FULL] const { config } = await loadConfig()
134
+ // [FULL] return config(rest)
135
+ // [FULL] }
136
+ // [FULL] case "datasets": {
137
+ // [FULL] const { datasets } = await loadDatasets()
138
+ // [FULL] return datasets(rest)
139
+ // [FULL] }
140
+ // [FULL] default:
141
+ // [FULL] return passthrough([cmd, ...rest])
142
+ default:
143
+ console.error(`Unknown command: ${cmd}`);
144
+ return 1;
145
+ }
146
+ }
147
+ main(process.argv.slice(2))
148
+ .then((code) => process.exit(code))
149
+ .catch((err) => {
150
+ log.error(err instanceof Error ? err.message : String(err));
151
+ process.exit(1);
152
+ });
package/dist/log.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ export declare function initFileLogging(): string | null;
2
+ export declare const log: {
3
+ info(msg: string): void;
4
+ ok(msg: string): void;
5
+ warn(msg: string): void;
6
+ error(msg: string): void;
7
+ step(msg: string): void;
8
+ header(msg: string): void;
9
+ /** Print the launcher's identity banner on every non-quiet invocation. */
10
+ banner(version: string, home: string): void;
11
+ /** Used by the Python subprocess proxy in serve.ts. */
12
+ python(line: string): void;
13
+ /** Filesystem path of the current log file, if file logging is active. */
14
+ readonly file: string | null;
15
+ };
16
+ //# sourceMappingURL=log.d.ts.map
package/dist/log.js ADDED
@@ -0,0 +1,85 @@
1
+ import kleur from "kleur";
2
+ import { existsSync, mkdirSync, appendFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { platform, arch, totalmem, release } from "node:os";
5
+ import { paths } from "./paths.js";
6
+ const isQuiet = process.env.ASKMRI_QUIET === "1";
7
+ const fileLoggingDisabled = process.env.ASKMRI_NO_LOG === "1";
8
+ let logFile = null;
9
+ export function initFileLogging() {
10
+ if (fileLoggingDisabled)
11
+ return null;
12
+ try {
13
+ if (!existsSync(paths.logs))
14
+ mkdirSync(paths.logs, { recursive: true });
15
+ const today = new Date().toISOString().slice(0, 10);
16
+ logFile = join(paths.logs, `launcher-${today}.log`);
17
+ appendFileSync(logFile, `\n${new Date().toISOString()} ──── new invocation ${process.argv.slice(2).join(" ")}\n`);
18
+ return logFile;
19
+ }
20
+ catch {
21
+ // Disk full / permission denied — fall back to console-only.
22
+ return null;
23
+ }
24
+ }
25
+ function writeToFile(level, msg) {
26
+ if (!logFile)
27
+ return;
28
+ try {
29
+ appendFileSync(logFile, `${new Date().toISOString()} ${level.padEnd(5)} ${msg}\n`);
30
+ }
31
+ catch {
32
+ // ignore — best effort
33
+ }
34
+ }
35
+ export const log = {
36
+ info(msg) {
37
+ if (!isQuiet)
38
+ console.error(kleur.cyan("·"), msg);
39
+ writeToFile("info", msg);
40
+ },
41
+ ok(msg) {
42
+ if (!isQuiet)
43
+ console.error(kleur.green("✓"), msg);
44
+ writeToFile("ok", msg);
45
+ },
46
+ warn(msg) {
47
+ console.error(kleur.yellow("!"), msg);
48
+ writeToFile("warn", msg);
49
+ },
50
+ error(msg) {
51
+ console.error(kleur.red("✗"), msg);
52
+ writeToFile("error", msg);
53
+ },
54
+ step(msg) {
55
+ if (!isQuiet)
56
+ console.error(kleur.dim("→"), kleur.dim(msg));
57
+ writeToFile("step", msg);
58
+ },
59
+ header(msg) {
60
+ if (!isQuiet)
61
+ console.error("\n" + kleur.bold(msg));
62
+ writeToFile("info", msg);
63
+ },
64
+ /** Print the launcher's identity banner on every non-quiet invocation. */
65
+ banner(version, home) {
66
+ const platformInfo = `${platform()} ${arch()} · node ${process.versions.node} · ${(totalmem() / 1024 / 1024 / 1024).toFixed(1)} GiB`;
67
+ const banner = `askmri v${version} ${kleur.dim(`(${platformInfo})`)}`;
68
+ if (!isQuiet) {
69
+ console.error(kleur.bold().magenta(banner));
70
+ console.error(kleur.dim(`home: ${home}` + (logFile ? ` · log: ${logFile}` : "")));
71
+ }
72
+ writeToFile("info", `banner: askmri v${version} ${platformInfo}`);
73
+ writeToFile("info", `home: ${home}, kernel: ${release()}`);
74
+ },
75
+ /** Used by the Python subprocess proxy in serve.ts. */
76
+ python(line) {
77
+ if (!isQuiet)
78
+ process.stderr.write(kleur.dim("[py] ") + line);
79
+ writeToFile("py", line.trimEnd());
80
+ },
81
+ /** Filesystem path of the current log file, if file logging is active. */
82
+ get file() {
83
+ return logFile;
84
+ },
85
+ };
@@ -0,0 +1,48 @@
1
+ import type { EnvGroup } from "@askmri/content";
2
+ /**
3
+ * Resolve the AskMRI home directory. Honours $ASKMRI_HOME, then falls back
4
+ * to ~/.askmri. The directory holds:
5
+ *
6
+ * bin/ — installed askmri-server binary (uv-managed)
7
+ * venv/ — Python virtual environment (base/server)
8
+ * venvs/ — per-tool-group isolated venvs (ants, totalseg, brats)
9
+ * cache/ — preprocessing + model weight cache
10
+ * data/ — default scan and output storage
11
+ * logs/ — launcher + server logs
12
+ */
13
+ export declare function askmriHome(): string;
14
+ export declare const paths: {
15
+ home: string;
16
+ bin: string;
17
+ venv: string;
18
+ venvs: string;
19
+ cache: string;
20
+ data: string;
21
+ logs: string;
22
+ marker: string;
23
+ };
24
+ /**
25
+ * Get the venv path for a specific environment group.
26
+ * - "base" uses the main venv at ~/.askmri/venv/ (where the server runs)
27
+ * - Other groups use isolated venvs at ~/.askmri/venvs/{group}/
28
+ */
29
+ export declare function venvFor(group: EnvGroup): string;
30
+ /**
31
+ * Get the Python binary path for a specific environment group's venv.
32
+ */
33
+ export declare function pythonBinFor(group: EnvGroup): string;
34
+ /**
35
+ * Get the pip binary path for a specific environment group's venv.
36
+ */
37
+ export declare function pipBinFor(group: EnvGroup): string;
38
+ /**
39
+ * Marker file path indicating a group's venv is installed.
40
+ */
41
+ export declare function venvMarkerFor(group: EnvGroup): string;
42
+ /** Path to the `askmri-server` binary inside the managed venv. */
43
+ export declare function pythonCliBin(): string;
44
+ /** Path to the `python` binary inside the managed venv (base). */
45
+ export declare function pythonBin(): string;
46
+ /** Cache directory for MONAI bundles (shared with the Python side). */
47
+ export declare const monaiBundlesDir: string;
48
+ //# sourceMappingURL=paths.d.ts.map
package/dist/paths.js ADDED
@@ -0,0 +1,78 @@
1
+ import { homedir, platform } from "node:os";
2
+ import { join, resolve } from "node:path";
3
+ import { env } from "node:process";
4
+ /**
5
+ * Resolve the AskMRI home directory. Honours $ASKMRI_HOME, then falls back
6
+ * to ~/.askmri. The directory holds:
7
+ *
8
+ * bin/ — installed askmri-server binary (uv-managed)
9
+ * venv/ — Python virtual environment (base/server)
10
+ * venvs/ — per-tool-group isolated venvs (ants, totalseg, brats)
11
+ * cache/ — preprocessing + model weight cache
12
+ * data/ — default scan and output storage
13
+ * logs/ — launcher + server logs
14
+ */
15
+ export function askmriHome() {
16
+ if (env.ASKMRI_HOME)
17
+ return resolve(env.ASKMRI_HOME);
18
+ return join(homedir(), ".askmri");
19
+ }
20
+ export const paths = {
21
+ home: askmriHome(),
22
+ bin: join(askmriHome(), "bin"),
23
+ venv: join(askmriHome(), "venv"),
24
+ venvs: join(askmriHome(), "venvs"),
25
+ cache: join(askmriHome(), "cache"),
26
+ data: join(askmriHome(), "data"),
27
+ logs: join(askmriHome(), "logs"),
28
+ marker: join(askmriHome(), "INSTALLED"),
29
+ };
30
+ /**
31
+ * Get the venv path for a specific environment group.
32
+ * - "base" uses the main venv at ~/.askmri/venv/ (where the server runs)
33
+ * - Other groups use isolated venvs at ~/.askmri/venvs/{group}/
34
+ */
35
+ export function venvFor(group) {
36
+ if (group === "base")
37
+ return paths.venv;
38
+ return join(paths.venvs, group);
39
+ }
40
+ /**
41
+ * Get the Python binary path for a specific environment group's venv.
42
+ */
43
+ export function pythonBinFor(group) {
44
+ const venv = venvFor(group);
45
+ const ext = platform() === "win32" ? ".exe" : "";
46
+ const dir = platform() === "win32" ? "Scripts" : "bin";
47
+ return join(venv, dir, `python${ext}`);
48
+ }
49
+ /**
50
+ * Get the pip binary path for a specific environment group's venv.
51
+ */
52
+ export function pipBinFor(group) {
53
+ const venv = venvFor(group);
54
+ const ext = platform() === "win32" ? ".exe" : "";
55
+ const dir = platform() === "win32" ? "Scripts" : "bin";
56
+ // uv creates pip3, not pip
57
+ return join(venv, dir, `pip3${ext}`);
58
+ }
59
+ /**
60
+ * Marker file path indicating a group's venv is installed.
61
+ */
62
+ export function venvMarkerFor(group) {
63
+ if (group === "base")
64
+ return paths.marker;
65
+ return join(paths.venvs, group, "INSTALLED");
66
+ }
67
+ /** Path to the `askmri-server` binary inside the managed venv. */
68
+ export function pythonCliBin() {
69
+ const ext = platform() === "win32" ? ".exe" : "";
70
+ const dir = platform() === "win32" ? "Scripts" : "bin";
71
+ return join(paths.venv, dir, `askmri-server${ext}`);
72
+ }
73
+ /** Path to the `python` binary inside the managed venv (base). */
74
+ export function pythonBin() {
75
+ return pythonBinFor("base");
76
+ }
77
+ /** Cache directory for MONAI bundles (shared with the Python side). */
78
+ export const monaiBundlesDir = join(askmriHome(), "monai-bundles");
@@ -0,0 +1,2 @@
1
+ export declare const VERSION = "0.1.0";
2
+ //# sourceMappingURL=version.d.ts.map
@@ -0,0 +1,3 @@
1
+ // Auto-generated by scripts/sync-versions.mjs from /VERSION.
2
+ // Do not edit by hand.
3
+ export const VERSION = "0.1.0";
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "askmri",
3
+ "version": "0.0.1",
4
+ "description": "The open AI workbench for MRI research.",
5
+ "keywords": [
6
+ "mri",
7
+ "nifti",
8
+ "research",
9
+ "viewer"
10
+ ],
11
+ "homepage": "https://askmri.com",
12
+ "bugs": "https://github.com/askmri/askmri/issues",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "https://github.com/askmri/askmri.git"
16
+ },
17
+ "license": "Apache-2.0",
18
+ "type": "module",
19
+ "bin": {
20
+ "askmri": "./dist/index.js"
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "!dist/__tests__",
25
+ "!dist/**/*.test.*",
26
+ "!dist/**/*.e2e.test.*",
27
+ "!dist/**/*.map",
28
+ "README.md"
29
+ ],
30
+ "engines": {
31
+ "node": ">=18.18"
32
+ },
33
+ "scripts": {
34
+ "build": "tsc -p tsconfig.minimal.json && node scripts/post-build.mjs",
35
+ "build:full": "tsc -p tsconfig.json && node scripts/post-build.mjs",
36
+ "typecheck": "tsc -p tsconfig.json --noEmit",
37
+ "dev": "tsx src/index.ts",
38
+ "clean": "rm -rf dist",
39
+ "test:chat": "tsx src/trpc/procedures/chat.e2e.test.ts"
40
+ },
41
+ "dependencies": {
42
+ "@ai-sdk/anthropic": "^1",
43
+ "@ai-sdk/openai": "^1",
44
+ "@askmri/agent": "workspace:*",
45
+ "@askmri/api": "workspace:*",
46
+ "@askmri/content": "workspace:*",
47
+ "@askmri/db": "workspace:*",
48
+ "@askmri/shared": "workspace:*",
49
+ "@trpc/server": "^11.7.1",
50
+ "ai": "^4",
51
+ "kleur": "^4.1.5",
52
+ "nanoid": "^5",
53
+ "tinyexec": "^1.0.1",
54
+ "zod": "^3.25.34"
55
+ },
56
+ "devDependencies": {
57
+ "@types/node": "^24.12.3",
58
+ "tsx": "^4.20.0",
59
+ "typescript": "~6.0.2"
60
+ }
61
+ }