dsh-deeptutor 0.2.1 → 0.3.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/lib/tools.js ADDED
@@ -0,0 +1,304 @@
1
+ /**
2
+ * DeepTutor bridge: the agent-facing tools, defined once and host-neutral.
3
+ *
4
+ * Two hosts adapt these specs:
5
+ * - dsh (src/index.ts) registers them through `defineTool`;
6
+ * - MCP clients such as Claude Code (src/mcp.ts) list and call them over stdio.
7
+ *
8
+ * Parameter specs use the dsh-tools shape (JSON-Schema-like, `required: true`
9
+ * on the property) so dsh can infer argument types; `toJsonSchema` turns them
10
+ * into plain JSON Schema for MCP.
11
+ *
12
+ * The four tools split the work like this:
13
+ * deeptutor_run one learning turn (event folding, session reuse, HTML render)
14
+ * deeptutor_kb list / search / info knowledge bases (hit formatting)
15
+ * deeptutor_note archive Markdown to a notebook (find-or-create, file upload)
16
+ * deeptutor_cli any other CLI command, discovered from --help, behind the
17
+ * safety tiers in cli-policy.ts
18
+ */
19
+ import { cliNoteLocal, cliNoteRemote, runCli } from "./cli-exec.js";
20
+ import { classifyCliArgs } from "./cli-policy.js";
21
+ import { configGuide, configHint, SSH_HOST, truncate } from "./config.js";
22
+ import { describeCapabilities, discoverCapabilities } from "./discovery.js";
23
+ import { renderHtml } from "./html-render.js";
24
+ import { cliRunTurn, fmtTurnResult } from "./turn.js";
25
+ /** Plain JSON Schema (object) for one tool's parameters. Pure. */
26
+ export function toJsonSchema(params) {
27
+ const properties = {};
28
+ const required = [];
29
+ for (const [key, spec] of Object.entries(params)) {
30
+ const { required: isRequired, ...schema } = spec;
31
+ properties[key] = schema;
32
+ if (isRequired)
33
+ required.push(key);
34
+ }
35
+ return { type: "object", properties, required, additionalProperties: false };
36
+ }
37
+ /** Required-parameter and basic type check (MCP clients may skip schema validation). Pure. */
38
+ export function validateArgs(params, args) {
39
+ const problems = [];
40
+ for (const [key, spec] of Object.entries(params)) {
41
+ const v = args[key];
42
+ if (v === undefined || v === null) {
43
+ if (spec.required)
44
+ problems.push(`missing required parameter "${key}"`);
45
+ continue;
46
+ }
47
+ const ok = spec.type === "array"
48
+ ? Array.isArray(v) && v.every((x) => typeof x === "string")
49
+ : spec.type === "object"
50
+ ? typeof v === "object" && !Array.isArray(v)
51
+ : typeof v === spec.type;
52
+ if (!ok)
53
+ problems.push(`parameter "${key}" must be ${spec.type === "array" ? "an array of strings" : `a ${spec.type}`}`);
54
+ else if (spec.type === "string" && spec.enum && !spec.enum.includes(v))
55
+ problems.push(`parameter "${key}" must be one of: ${spec.enum.join(", ")}`);
56
+ }
57
+ return problems;
58
+ }
59
+ // ---------- deeptutor_run ----------
60
+ const runTool = {
61
+ name: "deeptutor_run",
62
+ description: "Execute one learning call on the DeepTutor service, e.g. deep_solve (in-depth explanation), deep_question (self-test questions), deep_research, chat, mastery_path (learning path), visualize. The capability set depends on the deployment and its installed modules: list it with deeptutor_cli args=[\"module\",\"list\"] (capabilities of modules that are ready). Can mount knowledge bases (kbs) and tools (rag, web_search, …; list them with deeptutor_cli args=[\"plugin\",\"list\"]). Returns the final answer plus session_id; pass session_id in later turns to continue the context.",
63
+ parameters: {
64
+ capability: {
65
+ type: "string",
66
+ required: true,
67
+ description: "Capability name, e.g. deep_solve / deep_question / deep_research / chat / mastery_path / visualize. Not a fixed list: whatever the deployment's ready modules provide.",
68
+ },
69
+ prompt: {
70
+ type: "string",
71
+ required: true,
72
+ description: "The learning question or instruction for this turn (e.g. explain the async/await state machine)",
73
+ },
74
+ kbs: {
75
+ type: "array",
76
+ items: { type: "string" },
77
+ description: "Knowledge base names (check deeptutor_kb action=list first; never guess names)",
78
+ },
79
+ tools: {
80
+ type: "array",
81
+ items: { type: "string" },
82
+ description: "Tools to enable, e.g. rag (KB retrieval), web_search, reason, brainstorm, code_execution",
83
+ },
84
+ session_id: {
85
+ type: "string",
86
+ description: "Session ID from a previous deeptutor_run call to continue that context",
87
+ },
88
+ config: {
89
+ type: "object",
90
+ additionalProperties: true,
91
+ description: "Capability config key-values, e.g. num_questions=5 for deep_question, mode=report / depth=standard for deep_research",
92
+ },
93
+ language: { type: "string", description: "Response language code, default en; use zh for Chinese" },
94
+ timeout: { type: "number", description: "Timeout in seconds, default 600; use 900+ for deep_research" },
95
+ max_chars: { type: "number", description: "Max characters of the answer returned, default 30000" },
96
+ html: {
97
+ type: "string",
98
+ description: "Optional output path for a self-contained local HTML render of the answer (also writes the companion .md next to it), e.g. data/study/abstract-class/knowledge.html. Relative paths resolve against the agent's working directory. Set it when the user wants browsable/printable study material.",
99
+ },
100
+ html_title: { type: "string", description: "HTML page title (default: derived from the html file name)" },
101
+ },
102
+ async execute(args, signal) {
103
+ const timeoutMs = (args.timeout ?? 600) * 1000;
104
+ const maxChars = args.max_chars ?? 30000;
105
+ const capability = String(args.capability).trim();
106
+ const index = await discoverCapabilities(signal).catch(() => undefined);
107
+ const off = index?.unavailable[capability];
108
+ if (off) {
109
+ throw new Error(`[deeptutor_run] capability "${capability}" is not ready on this deployment ` +
110
+ `(module ${off.module}: ${off.reason}).\n${describeCapabilities(index)}`);
111
+ }
112
+ try {
113
+ const r = await cliRunTurn({ ...args, capability }, timeoutMs, signal);
114
+ const htmlNote = await renderHtml(args, r.answer);
115
+ return {
116
+ mode: "cli",
117
+ sessionId: r.sessionId,
118
+ toolCalls: r.toolCalls,
119
+ errors: r.errors,
120
+ text: configHint() + fmtTurnResult(capability, r, maxChars) + htmlNote,
121
+ };
122
+ }
123
+ catch (err) {
124
+ if (signal.aborted)
125
+ throw new Error("[deeptutor_run cancelled]");
126
+ const known = index && !index.ready.includes(capability) ? `\n${describeCapabilities(index)}` : "";
127
+ throw new Error(configGuide() + `[deeptutor_run failed] ${err?.message ?? String(err)}${known}`);
128
+ }
129
+ },
130
+ };
131
+ // ---------- deeptutor_kb ----------
132
+ const kbTool = {
133
+ name: "deeptutor_kb",
134
+ description: "Search DeepTutor knowledge bases: list all bases, search inside a base (RAG hit snippets), info for details. Search the user's personal knowledge bases before generating learning content and combine the hits with authoritative sources. Other kb commands (create, add, sources, eval…) go through deeptutor_cli.",
135
+ parameters: {
136
+ action: {
137
+ type: "string",
138
+ enum: ["list", "search", "info"],
139
+ required: true,
140
+ description: "What to do with the knowledge bases",
141
+ },
142
+ kb: { type: "string", description: "Knowledge base name (required for search/info)" },
143
+ query: { type: "string", description: "Search question (required for search)" },
144
+ mode: { type: "string", enum: ["hybrid", "dense", "sparse"], description: "Retrieval mode, default hybrid" },
145
+ top_k: {
146
+ type: "number",
147
+ description: "Number of hits to return, default all (applied locally; the CLI has no --top-k flag)",
148
+ },
149
+ max_chars: { type: "number", description: "Max characters of the returned content, default 30000" },
150
+ },
151
+ async execute(args, signal) {
152
+ const maxChars = args.max_chars ?? 30000;
153
+ if ((args.action === "search" || args.action === "info") && !args.kb) {
154
+ throw new Error(`[deeptutor_kb] action=${args.action} needs kb`);
155
+ }
156
+ if (args.action === "search" && !args.query)
157
+ throw new Error("[deeptutor_kb] action=search needs query");
158
+ let argv;
159
+ if (args.action === "list")
160
+ argv = ["kb", "list", "--format", "json"];
161
+ else if (args.action === "info")
162
+ argv = ["kb", "info", args.kb];
163
+ else
164
+ argv = ["kb", "search", args.kb, args.query, "--format", "json", "--mode", args.mode ?? "hybrid"];
165
+ const { stdout, stderr, code, killed } = await runCli(argv, { signal, timeoutMs: 90_000 });
166
+ if (killed)
167
+ throw new Error(configGuide() + "[deeptutor_kb timed out]");
168
+ if (code !== 0) {
169
+ throw new Error(`[deeptutor_kb failed] exit=${code}\nstderr: ${truncate(stderr || "(none)", 2000)}` + configGuide());
170
+ }
171
+ let body = stdout.trim() || "(empty result)";
172
+ if (args.action === "search")
173
+ body = formatSearch(stdout, args.top_k) ?? body;
174
+ return { action: args.action, mode: "cli", kb: args.kb, query: args.query, text: truncate(body, maxChars) };
175
+ },
176
+ };
177
+ /** Format `kb search --format json` output; undefined when it is not JSON. Exported for tests. */
178
+ export function formatSearch(stdout, topK) {
179
+ let parsed;
180
+ try {
181
+ parsed = JSON.parse(stdout);
182
+ }
183
+ catch {
184
+ return undefined;
185
+ }
186
+ const fmtHit = (s, i) => {
187
+ const text = s.text ?? s.content ?? s.snippet ?? JSON.stringify(s);
188
+ const src = s.source ?? s.doc_name ?? s.file ?? "";
189
+ const title = s.title ? ` title: ${s.title}` : "";
190
+ const score = typeof s.score === "number" ? ` score: ${s.score}` : "";
191
+ return `[${i + 1}]${src ? ` source: ${src}` : ""}${title}${score}\n${typeof text === "string" ? text : JSON.stringify(text)}`;
192
+ };
193
+ // The CLI returns { query, answer, content, sources, provider }; older builds a bare array.
194
+ if (Array.isArray(parsed)) {
195
+ const hits = topK ? parsed.slice(0, topK) : parsed;
196
+ return `${hits.length} hits:\n\n` + hits.map(fmtHit).join("\n\n");
197
+ }
198
+ if (parsed && typeof parsed === "object") {
199
+ const parts = [];
200
+ if (parsed.query)
201
+ parts.push(`Query: ${parsed.query}`);
202
+ if (typeof parsed.answer === "string" && parsed.answer.trim())
203
+ parts.push(`Answer: ${parsed.answer.trim()}`);
204
+ const sources = Array.isArray(parsed.sources) ? parsed.sources : [];
205
+ const hits = topK ? sources.slice(0, topK) : sources;
206
+ parts.push(`${hits.length} hits:`);
207
+ parts.push(hits.map(fmtHit).join("\n\n"));
208
+ return parts.join("\n\n");
209
+ }
210
+ return undefined;
211
+ }
212
+ // ---------- deeptutor_note ----------
213
+ const noteTool = {
214
+ name: "deeptutor_note",
215
+ description: "Archive a Markdown learning note (study plan, deep-solve summary, wrong answers, etc.) to a DeepTutor notebook (auto-creates the notebook if missing). Only when the user asks to save/archive notes.",
216
+ parameters: {
217
+ notebook: { type: "string", required: true, description: "Notebook name, e.g. dotnet-learning (auto-created if missing)" },
218
+ title: { type: "string", required: true, description: "Record title" },
219
+ type: {
220
+ type: "string",
221
+ enum: ["chat", "question", "research", "solve"],
222
+ description: "Record type, default chat",
223
+ },
224
+ content: { type: "string", required: true, description: "Learning note content in Markdown" },
225
+ },
226
+ async execute(args, signal) {
227
+ const recordType = (args.type ?? "chat");
228
+ const nbName = String(args.notebook).trim();
229
+ const { stdout, stderr, code } = SSH_HOST
230
+ ? await cliNoteRemote(nbName, args.title, recordType, args.content, signal)
231
+ : await cliNoteLocal(nbName, args.title, recordType, args.content, signal);
232
+ if (code !== 0) {
233
+ throw new Error(`[deeptutor_note failed] exit=${code}\n${truncate(stderr || stdout, 2000)}` + configGuide());
234
+ }
235
+ return {
236
+ notebook: nbName,
237
+ title: args.title,
238
+ mode: "cli",
239
+ text: configHint() +
240
+ `[note archived] notebook=${nbName}, title=${args.title}, type=${recordType}\n${truncate((stdout ?? "").trim(), 2000)}`,
241
+ };
242
+ },
243
+ };
244
+ // ---------- deeptutor_cli ----------
245
+ /**
246
+ * Rich pads every line to the terminal width; drop that padding and blank-line
247
+ * runs so wide output does not waste the model's context. Pure; exported for tests.
248
+ */
249
+ export function tidyOutput(s) {
250
+ return s
251
+ .split(/\r?\n/)
252
+ .map((l) => l.trimEnd())
253
+ .join("\n")
254
+ .replace(/\n{3,}/g, "\n\n")
255
+ .trim();
256
+ }
257
+ /**
258
+ * Wider than Rich's 80-column default so list tables keep more of each cell,
259
+ * but not so wide that help boxes (which always span the full width) bloat.
260
+ */
261
+ const CLI_ENV = { COLUMNS: "120", NO_COLOR: "1", TERM: "dumb" };
262
+ const cliTool = {
263
+ name: "deeptutor_cli",
264
+ description: "Run any DeepTutor CLI command: `deeptutor <args>` (locally or over SSH, same as the other tools). Use it for everything the dedicated tools do not cover, and discover commands from the CLI itself instead of guessing: args=[\"--help\"], then [\"<command>\",\"--help\"]. Read-only commands (help, list/show/info/search/state/status, doctor, run) execute directly. Any other command may change DeepTutor state and needs confirm=true, which you may only set after the user explicitly asked for that change. Destructive commands (delete, remove, clear, uninstall, rollback…) and interactive ones (chat, start, serve, init, login) are refused; tell the user to run those in a terminal.",
265
+ parameters: {
266
+ args: {
267
+ type: "array",
268
+ items: { type: "string" },
269
+ required: true,
270
+ description: "Arguments after `deeptutor`, one per item, e.g. [\"kb\",\"list-sources\",\"dotnet\"] or [\"notebook\",\"show\",\"sqlserver-learning\"]",
271
+ },
272
+ confirm: {
273
+ type: "boolean",
274
+ description: "Set true only when the user explicitly asked for this state-changing command",
275
+ },
276
+ timeout: { type: "number", description: "Timeout in seconds, default 120" },
277
+ max_chars: { type: "number", description: "Max characters of output returned, default 30000" },
278
+ },
279
+ async execute(args, signal) {
280
+ const argv = args.args.map(String);
281
+ const verdict = classifyCliArgs(argv);
282
+ const shown = `deeptutor ${argv.join(" ")}`.trim();
283
+ if (verdict.tier === "blocked")
284
+ throw new Error(`[deeptutor_cli refused] ${shown}: ${verdict.reason}`);
285
+ if (verdict.tier === "confirm" && args.confirm !== true) {
286
+ throw new Error(`[deeptutor_cli needs confirmation] ${shown}: ${verdict.reason}. ` +
287
+ "Ask the user; retry with confirm=true only after they explicitly approve this command.");
288
+ }
289
+ const maxChars = args.max_chars ?? 30000;
290
+ const { stdout, stderr, code, killed } = await runCli(argv, {
291
+ signal,
292
+ timeoutMs: (args.timeout ?? 120) * 1000,
293
+ env: CLI_ENV,
294
+ });
295
+ if (killed)
296
+ throw new Error(signal.aborted ? "[deeptutor_cli cancelled]" : `[deeptutor_cli timed out] ${shown}`);
297
+ const out = tidyOutput([stdout, stderr ? `--- stderr ---\n${stderr}` : ""].filter(Boolean).join("\n"));
298
+ const text = `[${shown}] exit=${code}\n${truncate(out || "(no output)", maxChars)}`;
299
+ if (code !== 0)
300
+ throw new Error(code === 127 ? text + configGuide() : text);
301
+ return { args: argv, tier: verdict.tier, exitCode: code, text };
302
+ },
303
+ };
304
+ export const TOOLS = [runTool, kbTool, noteTool, cliTool];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-deeptutor",
3
- "version": "0.2.1",
4
- "description": "Learning assistant extension for DeepSeek Harness (dsh): DeepTutor tutoring for your agent — deep explanations, self-test questions, learning paths, personal knowledge-base search (RAG), and note archiving (drives the DeepTutor CLI locally or over SSH).",
3
+ "version": "0.3.1",
4
+ "description": "Learning assistant extension for DeepSeek Harness (dsh) and Claude Code (MCP): DeepTutor tutoring for your agent: deep explanations, self-test questions, learning paths, personal knowledge-base search (RAG), note archiving, and any other DeepTutor CLI command (drives the DeepTutor CLI locally or over SSH).",
5
5
  "keywords": [
6
6
  "dsh",
7
7
  "dsh-plugin",
@@ -9,7 +9,9 @@
9
9
  "deeptutor",
10
10
  "tutoring",
11
11
  "rag",
12
- "agent"
12
+ "agent",
13
+ "mcp",
14
+ "claude-code"
13
15
  ],
14
16
  "license": "MIT",
15
17
  "repository": {
@@ -28,7 +30,8 @@
28
30
  "main": "./lib/index.js",
29
31
  "types": "./lib/index.d.ts",
30
32
  "bin": {
31
- "dsh-deeptutor": "scripts/install-profile.mjs"
33
+ "dsh-deeptutor": "scripts/install-profile.mjs",
34
+ "dsh-deeptutor-mcp": "lib/mcp-main.js"
32
35
  },
33
36
  "exports": {
34
37
  ".": {
@@ -61,7 +64,8 @@
61
64
  "typecheck": "tsc --noEmit",
62
65
  "prepack": "npm run build && npm run typecheck",
63
66
  "pack:check": "npm pack --dry-run",
64
- "test": "node --test --experimental-strip-types"
67
+ "test": "node --test --experimental-strip-types",
68
+ "check:cli": "node --experimental-strip-types --no-warnings scripts/check-cli.ts"
65
69
  },
66
70
  "peerDependencies": {
67
71
  "@deepseek-ai/cordis": "^4.0.1",
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Drift check against a live DeepTutor deployment: `npm run check:cli`.
3
+ *
4
+ * The plugin discovers capabilities and commands at runtime, so a new DeepTutor
5
+ * release needs no plugin change, except when the few CLI shapes the dedicated
6
+ * tools are built on change. This script checks exactly those shapes on the
7
+ * deployment the DEEPTUTOR_* env vars point at, so a DeepTutor upgrade that
8
+ * breaks the plugin shows up here before a tool call fails.
9
+ *
10
+ * On Windows run it from PowerShell or cmd: Git Bash (MSYS) rewrites
11
+ * /home/... env values into C:/Program Files/Git/home/... before node sees them.
12
+ */
13
+ import { runCli } from "../src/cli-exec.ts";
14
+ import { SSH_HOST } from "../src/config.ts";
15
+ import { parseModuleList } from "../src/discovery.ts";
16
+
17
+ interface Check {
18
+ name: string;
19
+ args: string[];
20
+ /** Substrings that must appear in the output (Rich help tables are plain text inside). */
21
+ expect?: string[];
22
+ verify?: (stdout: string) => string | undefined;
23
+ }
24
+
25
+ const CHECKS: Check[] = [
26
+ { name: "top-level commands", args: ["--help"], expect: ["run", "kb", "notebook", "module"] },
27
+ {
28
+ name: "run options (deeptutor_run)",
29
+ args: ["run", "--help"],
30
+ expect: ["--format", "--kb", "--tool", "--session", "--language", "--config", "--config-json"],
31
+ },
32
+ { name: "kb subcommands (deeptutor_kb)", args: ["kb", "--help"], expect: ["list", "info", "search"] },
33
+ { name: "kb search options", args: ["kb", "search", "--help"], expect: ["--format", "--mode"] },
34
+ { name: "notebook subcommands (deeptutor_note)", args: ["notebook", "--help"], expect: ["list", "create", "add-md"] },
35
+ { name: "notebook add-md options", args: ["notebook", "add-md", "--help"], expect: ["--title", "--type"] },
36
+ { name: "notebook create options", args: ["notebook", "create", "--help"], expect: ["--description"] },
37
+ {
38
+ name: "module list JSON (capability discovery)",
39
+ args: ["module", "list"],
40
+ verify: (out) => {
41
+ const mods = parseModuleList(out);
42
+ if (!mods) return "output is not a JSON array";
43
+ if (!mods.some((m) => m.capabilities.length)) return "no module reports capabilities";
44
+ return undefined;
45
+ },
46
+ },
47
+ {
48
+ name: "kb list JSON",
49
+ args: ["kb", "list", "--format", "json"],
50
+ verify: (out) => {
51
+ try {
52
+ JSON.parse(out);
53
+ return undefined;
54
+ } catch {
55
+ return "output is not JSON";
56
+ }
57
+ },
58
+ },
59
+ ];
60
+
61
+ const env = { COLUMNS: "120", NO_COLOR: "1", TERM: "dumb" };
62
+ console.log(`Checking DeepTutor CLI (${SSH_HOST ? `remote via ${SSH_HOST}` : "local"})\n`);
63
+ let failed = 0;
64
+ for (const c of CHECKS) {
65
+ const res = await runCli(c.args, { timeoutMs: 90_000, env });
66
+ const out = res.stdout + "\n" + res.stderr;
67
+ const problems: string[] = [];
68
+ if (res.code !== 0) problems.push(`exit ${res.code}: ${res.stderr.trim().split("\n").slice(-3).join(" ")}`);
69
+ for (const s of c.expect ?? []) if (!out.includes(s)) problems.push(`missing "${s}"`);
70
+ const v = res.code === 0 ? c.verify?.(res.stdout) : undefined;
71
+ if (v) problems.push(v);
72
+ if (problems.length) {
73
+ failed++;
74
+ console.log(`✗ ${c.name} (deeptutor ${c.args.join(" ")})\n ${problems.join("\n ")}`);
75
+ } else {
76
+ console.log(`✓ ${c.name}`);
77
+ }
78
+ }
79
+ console.log(failed ? `\n${failed} check(s) failed: the plugin's CLI assumptions drifted.` : "\nAll checks passed.");
80
+ process.exit(failed ? 1 : 0);
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Launcher for the DeepTutor MCP server, used by the Claude Code plugin
4
+ * (`mcpServers` in .claude-plugin/plugin.json → `node ${CLAUDE_PLUGIN_ROOT}/scripts/mcp-launch.mjs`).
5
+ * The config lives in plugin.json, not a root .mcp.json: Claude Code also reads
6
+ * a root .mcp.json as project-scope config whenever this repo is the working
7
+ * directory, where ${CLAUDE_PLUGIN_ROOT} is undefined.
8
+ *
9
+ * A plugin installed from the marketplace is a plain git checkout: it has the
10
+ * TypeScript sources but no compiled lib/ and no node_modules. The server has no
11
+ * runtime dependencies, so the launcher picks the first entry that works:
12
+ *
13
+ * 1. lib/mcp-main.js compiled build (npm install, or a dev checkout after `npm run build`)
14
+ * 2. src/mcp-main.ts the checkout's own sources, via Node's type stripping (Node >= 22.6)
15
+ * 3. npx dsh-deeptutor@<this version> the published package, for older Node
16
+ *
17
+ * 1 and 2 run exactly the code of the installed plugin version; 3 pins the npm
18
+ * package to the same version, so plugin and server never drift apart.
19
+ * stdin/stdout are inherited untouched: they carry the MCP protocol.
20
+ */
21
+ import { spawn } from 'node:child_process';
22
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
23
+ import { dirname, join } from 'node:path';
24
+ import { fileURLToPath } from 'node:url';
25
+
26
+ const root = join(dirname(fileURLToPath(import.meta.url)), '..');
27
+ const lib = join(root, 'lib', 'mcp-main.js');
28
+ const src = join(root, 'src', 'mcp-main.ts');
29
+
30
+ /** Node >= 22.6 can run .ts with --experimental-strip-types (unflagged from 23.6). */
31
+ export function supportsTypeStripping(version = process.versions.node) {
32
+ const [major, minor] = version.split('.').map(Number);
33
+ return major > 22 || (major === 22 && minor >= 6);
34
+ }
35
+
36
+ /** Pick the command that starts the server. Exported for tests. */
37
+ export function pickLaunch({ hasLib, hasSrc, nodeVersion, version, platform = process.platform }) {
38
+ if (hasLib) return { command: process.execPath, args: [lib] };
39
+ if (hasSrc && supportsTypeStripping(nodeVersion)) {
40
+ return { command: process.execPath, args: ['--experimental-strip-types', '--no-warnings', src] };
41
+ }
42
+ const npxArgs = ['-y', '-p', `dsh-deeptutor@${version}`, 'dsh-deeptutor-mcp'];
43
+ // npx is a .cmd shim on Windows; spawning it needs a shell there.
44
+ return { command: 'npx', args: npxArgs, shell: platform === 'win32' };
45
+ }
46
+
47
+ function main() {
48
+ const version = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version;
49
+ const launch = pickLaunch({
50
+ hasLib: existsSync(lib),
51
+ hasSrc: existsSync(src),
52
+ nodeVersion: process.versions.node,
53
+ version,
54
+ });
55
+ const child = spawn(launch.command, launch.args, {
56
+ stdio: 'inherit',
57
+ shell: launch.shell ?? false,
58
+ windowsHide: true,
59
+ });
60
+ child.on('error', (err) => {
61
+ process.stderr.write(`[dsh-deeptutor mcp] failed to start ${launch.command}: ${err.message}\n`);
62
+ process.exit(1);
63
+ });
64
+ child.on('exit', (code, signal) => process.exit(signal ? 1 : (code ?? 0)));
65
+ for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => child.kill(sig));
66
+ }
67
+
68
+ // Run only when executed directly (tests import pickLaunch). realpath + lowercase:
69
+ // Windows hands us drive letters and separators in whatever case the caller used.
70
+ const same = (a, b) => realpathSync(a).toLowerCase() === realpathSync(b).toLowerCase();
71
+ if (process.argv[1] && existsSync(process.argv[1]) && same(process.argv[1], fileURLToPath(import.meta.url))) main();
package/src/cli-exec.ts CHANGED
@@ -107,14 +107,29 @@ function execFileResolved(
107
107
  });
108
108
  }
109
109
 
110
- async function sshExec(
111
- argv: string[],
112
- opts: { signal?: AbortSignal; timeoutMs?: number } = {},
113
- ): Promise<CliResult> {
110
+ /** Options shared by every CLI invocation. */
111
+ export interface CliOptions {
112
+ signal?: AbortSignal;
113
+ timeoutMs?: number;
114
+ /** Extra env vars for the deeptutor process (names must match [A-Z_][A-Z0-9_]*). */
115
+ env?: Record<string, string>;
116
+ }
117
+
118
+ const ENV_NAME = /^[A-Z_][A-Z0-9_]*$/;
119
+
120
+ /** `export K='v'` lines for the remote script; values are single-quote escaped. */
121
+ export function exportLines(env: Record<string, string> = {}): string[] {
122
+ return Object.entries(env)
123
+ .filter(([k]) => ENV_NAME.test(k))
124
+ .map(([k, v]) => `export ${k}='${v.replace(/'/g, `'\\''`)}'`);
125
+ }
126
+
127
+ async function sshExec(argv: string[], opts: CliOptions = {}): Promise<CliResult> {
114
128
  const payload = JSON.stringify({ argv, cwd: DEEPTUTOR_HOME });
115
129
  const script = [
116
130
  `export DEEPTUTOR_HOME=${DEEPTUTOR_HOME}`,
117
131
  "export DEEPTUTOR_SANDBOX_ALLOW_SUBPROCESS=1",
132
+ ...exportLines(opts.env),
118
133
  "F=/tmp/dt_pi_$$",
119
134
  `echo '${b64(payload)}' | base64 -d > "$F.json"`,
120
135
  `echo '${b64(PY_RUNNER)}' | base64 -d > "$F.py"`,
@@ -145,10 +160,7 @@ async function sshExec(
145
160
  * - Local deployment (SSH_HOST empty) → execute DEEPTUTOR_LOCAL_BIN locally.
146
161
  * args are deeptutor subcommand args (without the program name).
147
162
  */
148
- export async function runCli(
149
- args: string[],
150
- opts: { signal?: AbortSignal; timeoutMs?: number } = {},
151
- ): Promise<CliResult> {
163
+ export async function runCli(args: string[], opts: CliOptions = {}): Promise<CliResult> {
152
164
  if (SSH_HOST) {
153
165
  return sshExec([DEEPTUTOR_BIN, ...args], opts);
154
166
  }
@@ -159,7 +171,7 @@ export async function runCli(
159
171
  const res = await execFileResolved(cmd.file, cmd.args, {
160
172
  signal: opts.signal,
161
173
  timeoutMs: opts.timeoutMs,
162
- env: { PYTHONUTF8: "1", PYTHONIOENCODING: "utf-8" },
174
+ env: { ...opts.env, PYTHONUTF8: "1", PYTHONIOENCODING: "utf-8" },
163
175
  windowsVerbatimArguments: cmd.windowsVerbatimArguments,
164
176
  });
165
177
  const out = res.stdout ?? "";
@@ -0,0 +1,99 @@
1
+ /**
2
+ * DeepTutor bridge: safety tiers for the generic `deeptutor_cli` passthrough.
3
+ *
4
+ * The passthrough exists so the plugin never has to mirror DeepTutor's command
5
+ * surface: whatever the installed CLI offers is reachable, and the agent learns
6
+ * it from `--help`. The price is that the plugin cannot know what a command it
7
+ * has never seen does, so the default for anything unrecognised is "needs the
8
+ * user's explicit go-ahead" rather than "run".
9
+ *
10
+ * read run right away: help, and commands whose verb only reads
11
+ * confirm run only with confirm=true (writes, and every verb not listed here)
12
+ * blocked never run: destructive verbs, and interactive / long-running
13
+ * commands that would hang a non-interactive tool call
14
+ */
15
+
16
+ export type CliTier = "read" | "confirm" | "blocked";
17
+
18
+ export interface CliVerdict {
19
+ tier: CliTier;
20
+ reason: string;
21
+ }
22
+
23
+ /** Top-level commands that start a REPL, a server, a launcher or a setup wizard. */
24
+ const INTERACTIVE_TOP = new Set(["chat", "start", "stop", "serve", "init"]);
25
+
26
+ /** Verbs that only read. Matched against the subcommand (or a single-command top level). */
27
+ const READ_VERBS = new Set([
28
+ "list",
29
+ "show",
30
+ "info",
31
+ "search",
32
+ "state",
33
+ "status",
34
+ "doctor",
35
+ "get",
36
+ "view",
37
+ ]);
38
+
39
+ /** Verbs that delete, reset or roll back state, or need an interactive login. */
40
+ const BLOCKED_VERB =
41
+ /^(delete|remove|remove-.+|clear|uninstall|rollback|purge|reset|drop|wipe|prune|login|logout)$/;
42
+
43
+ /** Commands the plugin's own tools run without confirmation; same rule here. */
44
+ const READ_TOP = new Set(["doctor", "run"]);
45
+
46
+ /** Leading positional words (the command path), stopping at the first option. */
47
+ export function commandPath(args: readonly string[]): string[] {
48
+ const path: string[] = [];
49
+ for (const a of args) {
50
+ if (a.startsWith("-")) break;
51
+ path.push(a);
52
+ }
53
+ return path;
54
+ }
55
+
56
+ /** Classify one `deeptutor <args>` invocation. Pure; unit-tested. */
57
+ export function classifyCliArgs(args: readonly string[]): CliVerdict {
58
+ if (args.length === 0) {
59
+ return { tier: "read", reason: "no arguments prints the top-level help" };
60
+ }
61
+ if (args.includes("--help") || args.includes("-h")) {
62
+ return { tier: "read", reason: "--help only prints usage" };
63
+ }
64
+ const path = commandPath(args);
65
+ const [top, sub] = path;
66
+ if (!top) {
67
+ return { tier: "confirm", reason: "options without a command" };
68
+ }
69
+ if (INTERACTIVE_TOP.has(top)) {
70
+ return {
71
+ tier: "blocked",
72
+ reason: `\`deeptutor ${top}\` is interactive or long-running and would hang a tool call; run it in a terminal yourself`,
73
+ };
74
+ }
75
+ for (const word of [top, sub]) {
76
+ if (word && BLOCKED_VERB.test(word)) {
77
+ return {
78
+ tier: "blocked",
79
+ reason: `\`${word}\` deletes, resets or logs in; run it in a terminal yourself if you really mean it`,
80
+ };
81
+ }
82
+ }
83
+ if (READ_TOP.has(top)) {
84
+ return { tier: "read", reason: `\`deeptutor ${top}\` is a read-only or learning command` };
85
+ }
86
+ if (sub && (READ_VERBS.has(sub) || sub.startsWith("list-"))) {
87
+ return { tier: "read", reason: `\`${sub}\` only reads` };
88
+ }
89
+ if (!sub && path.length === 1) {
90
+ return {
91
+ tier: "confirm",
92
+ reason: `\`deeptutor ${top}\` without a subcommand; use --help to see what it does`,
93
+ };
94
+ }
95
+ return {
96
+ tier: "confirm",
97
+ reason: `\`${[top, sub].filter(Boolean).join(" ")}\` may change DeepTutor state`,
98
+ };
99
+ }
package/src/config.ts CHANGED
@@ -56,15 +56,5 @@ export function configGuide(): string {
56
56
  );
57
57
  }
58
58
 
59
- export const CAPABILITIES = [
60
- "chat",
61
- "deep_solve",
62
- "deep_question",
63
- "deep_research",
64
- "visualize",
65
- "math_animator",
66
- "mastery_path",
67
- ] as const;
68
-
69
59
  export const truncate = (s: string, n: number) =>
70
60
  s.length > n ? s.slice(0, n) + `\n…[truncated, ${s.length} chars total]` : s;