@devmarketplacenpm/devmp 0.1.1-beta.5

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,201 @@
1
+ "use strict";
2
+
3
+ const { color } = require("./ui");
4
+ const { wrap, displayWidth } = require("./tty/ansi");
5
+
6
+ // The model streams markdown. Rendering it incrementally is only tractable
7
+ // because markdown's block structure is line-oriented: once a line is
8
+ // terminated by "\n" its block type can never change, so it can be rendered
9
+ // and committed to scrollback permanently. The still-unterminated tail is
10
+ // rendered separately into the live region, which is what makes text appear to
11
+ // type itself without ever having to redraw finished output.
12
+
13
+ const FENCE = /^\s*(```+|~~~+)\s*(\S*)/;
14
+ const HEADING = /^(#{1,6})\s+(.*)$/;
15
+ const BULLET = /^(\s*)([-*+])\s+(.*)$/;
16
+ const ORDERED = /^(\s*)(\d+)[.)]\s+(.*)$/;
17
+ const QUOTE = /^\s*>\s?(.*)$/;
18
+ const RULE = /^\s*([-*_])(\s*\1){2,}\s*$/;
19
+
20
+ // Model output is untrusted. The agent reads the user's repository, so a
21
+ // hostile file in it can steer what the model writes back — and any terminal
22
+ // escape sequence that survives into this stream would let that text erase
23
+ // lines the CLI has already printed, render itself invisible, or forge an
24
+ // approval prompt. That would defeat the permission model the CLI's safety
25
+ // rests on, so the sequences are stripped at the door.
26
+ //
27
+ // Stripping happens on the way in, before any buffering, which covers both the
28
+ // lines committed to scrollback and the live region rendered from the same
29
+ // buffer. The CLI's own colour is applied afterwards by the renderer, so no
30
+ // legitimate styling is lost.
31
+ const ANSI_SEQUENCE =
32
+ // CSI (colour, cursor, erase), OSC (window title), and lone two-byte escapes.
33
+ /\u001b(?:\[[0-9;?]*[ -/]*[@-~]|\][^\u0007\u001b]*(?:\u0007|\u001b\\)?|[@-Z\\-_])/g;
34
+ // Everything else in C0/C1 except tab and newline, which are meaningful here.
35
+ // NUL matters twice over: it is also this module's code-span sentinel, so
36
+ // letting it through would corrupt rendering as well.
37
+ const CONTROL_CHARS = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g;
38
+
39
+ /** Strip terminal control sequences from untrusted model text. */
40
+ function sanitizeModelText(text) {
41
+ return String(text)
42
+ .replace(/\r\n?/g, "\n") // keep the line break, drop the overwrite
43
+ .replace(ANSI_SEQUENCE, "")
44
+ .replace(CONTROL_CHARS, "");
45
+ }
46
+
47
+ // Parks code-span contents so emphasis patterns cannot reach inside them.
48
+ const SENTINEL = "\u0000";
49
+ const SENTINEL_PATTERN = /\u0000(\d+)\u0000/g;
50
+
51
+ /** Inline spans. Applied only to prose — never inside a fenced code block. */
52
+ function renderInline(text) {
53
+ let out = String(text);
54
+ // Code spans first: their contents must not be re-parsed for emphasis.
55
+ const codeSpans = [];
56
+ out = out.replace(/`([^`]+)`/g, (_, code) => {
57
+ codeSpans.push(code);
58
+ return `${SENTINEL}${codeSpans.length - 1}${SENTINEL}`;
59
+ });
60
+
61
+ out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) =>
62
+ `${color.cyan(label)} ${color.dim(`(${url})`)}`
63
+ );
64
+ out = out.replace(/\*\*([^*]+)\*\*/g, (_, inner) => color.bold(inner));
65
+ out = out.replace(/__([^_]+)__/g, (_, inner) => color.bold(inner));
66
+ out = out.replace(/(^|[\s(])\*([^*\s][^*]*)\*/g, (_, lead, inner) =>
67
+ `${lead}${color.yellow(inner)}`
68
+ );
69
+ out = out.replace(/(^|[\s(])_([^_\s][^_]*)_/g, (_, lead, inner) =>
70
+ `${lead}${color.yellow(inner)}`
71
+ );
72
+ out = out.replace(/~~([^~]+)~~/g, (_, inner) => color.dim(inner));
73
+
74
+ return out.replace(SENTINEL_PATTERN, (_, index) =>
75
+ color.cyan(codeSpans[Number(index)])
76
+ );
77
+ }
78
+
79
+ function createMarkdownStream({ width = 80, indent = " " } = {}) {
80
+ let buffer = "";
81
+ let inFence = false;
82
+ let fenceMarker = "";
83
+
84
+ const widthFn = typeof width === "function" ? width : () => width;
85
+ // Floor only at the point where wrapping stops being meaningful — a larger
86
+ // floor would silently ignore the real terminal width on narrow panes.
87
+ const inner = () => Math.max(8, widthFn() - displayWidth(indent));
88
+
89
+ /** Render one complete logical line into zero or more terminal lines. */
90
+ function renderLine(raw) {
91
+ const fence = raw.match(FENCE);
92
+ if (fence) {
93
+ const marker = fence[1];
94
+ if (!inFence) {
95
+ inFence = true;
96
+ fenceMarker = marker[0];
97
+ const lang = fence[2];
98
+ return [
99
+ `${indent}${color.dim("┌─")}${
100
+ lang ? ` ${color.dim(lang)}` : ""
101
+ }`,
102
+ ];
103
+ }
104
+ if (marker[0] === fenceMarker) {
105
+ inFence = false;
106
+ fenceMarker = "";
107
+ return [`${indent}${color.dim("└─")}`];
108
+ }
109
+ }
110
+
111
+ if (inFence) {
112
+ // Code is never wrapped or restyled — a broken line is worse than one
113
+ // that runs past the edge and can still be copied intact.
114
+ return [`${indent}${color.dim("│")} ${color.green(raw)}`];
115
+ }
116
+
117
+ if (!raw.trim()) return [""];
118
+
119
+ if (RULE.test(raw)) {
120
+ return [`${indent}${color.dim("─".repeat(Math.min(40, inner())))}`];
121
+ }
122
+
123
+ const heading = raw.match(HEADING);
124
+ if (heading) {
125
+ const level = heading[1].length;
126
+ const body = renderInline(heading[2]);
127
+ const styled = level <= 2 ? color.bold(color.cyan(body)) : color.bold(body);
128
+ return wrap(styled, inner()).map((row) => `${indent}${row}`);
129
+ }
130
+
131
+ const quote = raw.match(QUOTE);
132
+ if (quote) {
133
+ return wrap(color.dim(renderInline(quote[1])), inner() - 2).map(
134
+ (row) => `${indent}${color.dim("▏")} ${row}`
135
+ );
136
+ }
137
+
138
+ const bullet = raw.match(BULLET);
139
+ if (bullet) {
140
+ const pad = `${indent}${bullet[1]}`;
141
+ const rows = wrap(renderInline(bullet[3]), inner() - bullet[1].length - 2);
142
+ return rows.map((row, index) =>
143
+ index === 0 ? `${pad}${color.cyan("•")} ${row}` : `${pad} ${row}`
144
+ );
145
+ }
146
+
147
+ const ordered = raw.match(ORDERED);
148
+ if (ordered) {
149
+ const pad = `${indent}${ordered[1]}`;
150
+ const marker = `${ordered[2]}.`;
151
+ const rows = wrap(
152
+ renderInline(ordered[3]),
153
+ inner() - ordered[1].length - marker.length - 1
154
+ );
155
+ return rows.map((row, index) =>
156
+ index === 0
157
+ ? `${pad}${color.cyan(marker)} ${row}`
158
+ : `${pad}${" ".repeat(marker.length + 1)}${row}`
159
+ );
160
+ }
161
+
162
+ return wrap(renderInline(raw), inner()).map((row) => `${indent}${row}`);
163
+ }
164
+
165
+ return {
166
+ /** Feed a streamed delta; returns lines ready to commit to scrollback. */
167
+ push(delta) {
168
+ buffer += sanitizeModelText(delta);
169
+ const out = [];
170
+ let newline = buffer.indexOf("\n");
171
+ while (newline !== -1) {
172
+ const raw = buffer.slice(0, newline);
173
+ buffer = buffer.slice(newline + 1);
174
+ out.push(...renderLine(raw));
175
+ newline = buffer.indexOf("\n");
176
+ }
177
+ return out;
178
+ },
179
+
180
+ /** The unterminated tail, rendered for display in the live region. */
181
+ partial() {
182
+ if (!buffer) return [];
183
+ if (inFence) return [`${indent}${color.dim("│")} ${color.green(buffer)}`];
184
+ return wrap(renderInline(buffer), inner()).map((row) => `${indent}${row}`);
185
+ },
186
+
187
+ /** Emit whatever is left when the stream ends. */
188
+ flush() {
189
+ if (!buffer) return [];
190
+ const raw = buffer;
191
+ buffer = "";
192
+ return renderLine(raw);
193
+ },
194
+
195
+ get open() {
196
+ return Boolean(buffer);
197
+ },
198
+ };
199
+ }
200
+
201
+ module.exports = { createMarkdownStream, renderInline, sanitizeModelText };
@@ -0,0 +1,68 @@
1
+ "use strict";
2
+
3
+ const path = require("path");
4
+ const fs = require("fs/promises");
5
+
6
+ const { resolveInside, isSecretPath } = require("./workspace");
7
+
8
+ // `@path/to/file` in a prompt pulls that file into the turn.
9
+ //
10
+ // The agent can already read files on its own, but only after deciding it needs
11
+ // to — which costs a round trip and sometimes never happens. Naming a file with
12
+ // `@` is how someone says "this one, now", and it is the fastest way to give the
13
+ // model the thing the question is actually about.
14
+ //
15
+ // A mention is only expanded when the path really exists in the workspace, which
16
+ // is what keeps `@example.com`, `@scope/package` and `@Injectable` from being
17
+ // mangled: they are not files, so they are left exactly as written.
18
+
19
+ // Trailing punctuation is excluded from the path so "look at @app.js." works.
20
+ const MENTION = /@([A-Za-z0-9._~\-/]+[A-Za-z0-9_~\-/])/g;
21
+
22
+ const MAX_MENTION_BYTES = 32 * 1024;
23
+ const MAX_MENTIONS = 10;
24
+
25
+ /**
26
+ * Expand `@file` mentions in a prompt.
27
+ *
28
+ * Returns the rewritten prompt and the list of files that were attached, so the
29
+ * caller can tell the user what was sent rather than doing it silently.
30
+ */
31
+ async function expandMentions(rootDir, prompt) {
32
+ const text = String(prompt ?? "");
33
+ const candidates = [...new Set([...text.matchAll(MENTION)].map((m) => m[1]))];
34
+ if (!candidates.length) return { prompt: text, attached: [] };
35
+
36
+ const attached = [];
37
+ const blocks = [];
38
+
39
+ for (const rel of candidates) {
40
+ if (attached.length >= MAX_MENTIONS) break;
41
+ if (isSecretPath(rel)) continue;
42
+
43
+ const target = resolveInside(rootDir, rel);
44
+ if (!target) continue;
45
+
46
+ let body;
47
+ try {
48
+ const stat = await fs.stat(target);
49
+ if (!stat.isFile()) continue;
50
+ const buf = await fs.readFile(target);
51
+ body = buf.subarray(0, MAX_MENTION_BYTES).toString("utf8");
52
+ if (buf.length > MAX_MENTION_BYTES) {
53
+ body += "\n… truncated";
54
+ attached.push({ path: rel, bytes: buf.length, truncated: true });
55
+ } else {
56
+ attached.push({ path: rel, bytes: buf.length, truncated: false });
57
+ }
58
+ } catch {
59
+ continue; // not a file we can read — leave the mention as written
60
+ }
61
+ blocks.push(`<file path="${rel}">\n${body}\n</file>`);
62
+ }
63
+
64
+ if (!blocks.length) return { prompt: text, attached: [] };
65
+ return { prompt: `${blocks.join("\n\n")}\n\n${text}`, attached };
66
+ }
67
+
68
+ module.exports = { expandMentions, MAX_MENTION_BYTES, MAX_MENTIONS };
package/lib/prompt.js ADDED
@@ -0,0 +1,140 @@
1
+ "use strict";
2
+
3
+ const readline = require("readline");
4
+ const { formatUnifiedDiff, printUnifiedDiff } = require("./diff");
5
+
6
+ // Approvals have two hosts:
7
+ //
8
+ // legacy `devmp run` — no screen, no raw mode. Each prompt spins up its own
9
+ // readline, exactly as it always has.
10
+ // interactive shell — the composer owns stdin in raw mode for the whole
11
+ // session, so a second readline here would fight it for
12
+ // keystrokes. The shell installs a driver instead.
13
+ //
14
+ // The driver is process-wide because approvals are raised deep inside the
15
+ // executor and command runner, which have no reference to the shell.
16
+
17
+ let driver = null;
18
+
19
+ /** Install the interactive host's approval driver (the shell owns stdin). */
20
+ function setApprovalDriver(next) {
21
+ driver = next;
22
+ }
23
+
24
+ function clearApprovalDriver() {
25
+ driver = null;
26
+ }
27
+
28
+ /** True only when we can actually ask the user something interactively. */
29
+ function isInteractive() {
30
+ return Boolean(driver) || Boolean(process.stdin.isTTY && process.stdout.isTTY);
31
+ }
32
+
33
+ function askLine(question) {
34
+ return new Promise((resolve) => {
35
+ const rl = readline.createInterface({
36
+ input: process.stdin,
37
+ output: process.stdout,
38
+ });
39
+ rl.question(question, (answer) => {
40
+ rl.close();
41
+ resolve(String(answer).trim().toLowerCase());
42
+ });
43
+ });
44
+ }
45
+
46
+ /**
47
+ * Ask how to handle overwriting an existing file. Resolves to one of:
48
+ * 'yes' — overwrite this file
49
+ * 'no' — skip this file
50
+ * 'all' — overwrite this and every remaining existing file this run
51
+ */
52
+ async function confirmOverwrite(relPath) {
53
+ if (driver) {
54
+ return driver.choose({
55
+ question: `Overwrite existing ${relPath}?`,
56
+ hint: "[y]es [n]o [a]ll",
57
+ choices: { y: "yes", n: "no", a: "all" },
58
+ });
59
+ }
60
+ const answer = await askLine(
61
+ ` overwrite existing ${relPath}? [y]es / [n]o / [a]ll: `
62
+ );
63
+ if (answer === "a" || answer === "all") return "all";
64
+ if (answer === "y" || answer === "yes") return "yes";
65
+ return "no";
66
+ }
67
+
68
+ /** Review one proposed edit with its real before/after diff. */
69
+ async function confirmFileChange({ path, before, after }) {
70
+ const diff = formatUnifiedDiff({ path, before, after });
71
+ if (driver) {
72
+ // A brand-new file has no "before" worth diffing against — every line is
73
+ // an addition, so a full unified dump is noise that buries the prompt.
74
+ const isCreate = before === null || before === undefined;
75
+ driver.showDiff(diff, { isCreate, path });
76
+ return driver.choose({
77
+ question: `Apply this change to ${path}?`,
78
+ hint: "[y]es [n]o [a]ll edits this session",
79
+ choices: { y: "yes", n: "no", a: "all" },
80
+ });
81
+ }
82
+ printUnifiedDiff(diff);
83
+ const answer = await askLine(
84
+ " apply this change? [y]es / [n]o / [a]ll edits this session: "
85
+ );
86
+ if (answer === "a" || answer === "all") return "all";
87
+ if (answer === "y" || answer === "yes") return "yes";
88
+ return "no";
89
+ }
90
+
91
+ /** Delete/move never inherit broad edit permission; the user sees the exact
92
+ * operation and must approve it explicitly. */
93
+ async function confirmDestructive({ description, diffs = [] }) {
94
+ if (driver) {
95
+ driver.showText(description);
96
+ for (const diff of diffs) driver.showDiff(diff);
97
+ return driver.choose({
98
+ question: description,
99
+ hint: "[y]es [N]o",
100
+ choices: { y: "yes", n: "no" },
101
+ defaultValue: "no",
102
+ });
103
+ }
104
+ console.log(` ${description}`);
105
+ for (const diff of diffs) printUnifiedDiff(diff);
106
+ const answer = await askLine(" approve this destructive change? [y/N]: ");
107
+ return answer === "y" || answer === "yes" ? "yes" : "no";
108
+ }
109
+
110
+ /**
111
+ * Ask whether to run a command the agent requested on this machine. Resolves to
112
+ * 'yes' | 'no' | 'all' (every command this session) | 'exact' (this exact
113
+ * command this session). Default is always the safe 'no'.
114
+ */
115
+ async function confirmCommand(command) {
116
+ if (driver) {
117
+ return driver.choose({
118
+ question: `Run this command on your machine?\n$ ${command}`,
119
+ hint: "[y]es [n]o [e]xact command this session [a]ll commands",
120
+ choices: { y: "yes", n: "no", e: "exact", a: "all" },
121
+ });
122
+ }
123
+ const answer = await askLine(
124
+ ` run command on your machine?\n $ ${command}\n [y]es / [n]o / [e]xact command this session / [a]ll commands: `
125
+ );
126
+ if (answer === "a" || answer === "all") return "all";
127
+ if (answer === "e" || answer === "exact") return "exact";
128
+ if (answer === "y" || answer === "yes") return "yes";
129
+ return "no";
130
+ }
131
+
132
+ module.exports = {
133
+ isInteractive,
134
+ confirmOverwrite,
135
+ confirmFileChange,
136
+ confirmDestructive,
137
+ confirmCommand,
138
+ setApprovalDriver,
139
+ clearApprovalDriver,
140
+ };
package/lib/routes.js ADDED
@@ -0,0 +1,27 @@
1
+ "use strict";
2
+
3
+ // The versioned platform surface. One place that knows where the server lives,
4
+ // so a path cannot drift between the HTTP client, the WebSocket client and the
5
+ // status card — and so a future /platform/v2 is a one-line change here.
6
+ //
7
+ // `session.apiBaseUrl` already ends in `/api` (the server's global prefix);
8
+ // `platform/v1` is the versioned product surface within it.
9
+
10
+ const PLATFORM_BASE = "/platform/v1";
11
+
12
+ /** POST — one-shot NDJSON build (`devmp run`). */
13
+ function agentRunUrl(apiBaseUrl) {
14
+ return `${apiBaseUrl}${PLATFORM_BASE}/agent/run`;
15
+ }
16
+
17
+ /** WebSocket — the live tunnel and interactive sessions. */
18
+ function agentWsUrl(apiBaseUrl) {
19
+ return `${apiBaseUrl.replace(/^http/i, "ws")}${PLATFORM_BASE}/agent/ws`;
20
+ }
21
+
22
+ /** GET — token allowance and rate-limit headroom for the current user. */
23
+ function usageUrl(apiBaseUrl) {
24
+ return `${apiBaseUrl}${PLATFORM_BASE}/me/usage`;
25
+ }
26
+
27
+ module.exports = { PLATFORM_BASE, agentRunUrl, agentWsUrl, usageUrl };
package/lib/session.js ADDED
@@ -0,0 +1,107 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs/promises');
4
+ const os = require('os');
5
+ const path = require('path');
6
+
7
+ // Same on-disk location the existing device-auth client uses, so a login from
8
+ // either tool is shared. Overridable for tests/demos via DEVMP_CLI_CONFIG.
9
+ const AUTH_DIR = '.devmarketplace';
10
+ const AUTH_FILE = 'cli-auth.json';
11
+ const CONVERSATION_FILE = 'cli-conversations.json';
12
+
13
+ function sessionPath() {
14
+ return (
15
+ process.env.DEVMP_CLI_CONFIG || path.join(os.homedir(), AUTH_DIR, AUTH_FILE)
16
+ );
17
+ }
18
+
19
+ function conversationPath() {
20
+ return (
21
+ process.env.DEVMP_CLI_SESSIONS ||
22
+ path.join(os.homedir(), AUTH_DIR, CONVERSATION_FILE)
23
+ );
24
+ }
25
+
26
+ async function loadSession() {
27
+ try {
28
+ const raw = await fs.readFile(sessionPath(), 'utf8');
29
+ const parsed = JSON.parse(raw);
30
+ return parsed && parsed.accessToken ? parsed : null;
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ async function saveSession(session) {
37
+ const file = sessionPath();
38
+ await fs.mkdir(path.dirname(file), { recursive: true });
39
+ await fs.writeFile(file, `${JSON.stringify(session, null, 2)}\n`, {
40
+ encoding: 'utf8',
41
+ mode: 0o600,
42
+ });
43
+ await fs.chmod(file, 0o600).catch(() => undefined);
44
+ }
45
+
46
+ /**
47
+ * Remove the saved session. Returns true if a file was removed, false if there
48
+ * was nothing to remove.
49
+ *
50
+ * Only "it was not there" is swallowed. A permission or read-only-filesystem
51
+ * error must reach the caller: someone logging out on a shared machine has to
52
+ * be told when the credentials are still on disk, not reassured that they are
53
+ * gone.
54
+ */
55
+ async function clearSession() {
56
+ try {
57
+ await fs.unlink(sessionPath());
58
+ return true;
59
+ } catch (error) {
60
+ if (error && error.code === 'ENOENT') return false;
61
+ throw error;
62
+ }
63
+ }
64
+
65
+ function workspaceKey(apiBaseUrl, rootDir) {
66
+ return `${String(apiBaseUrl)}\n${path.resolve(rootDir)}`;
67
+ }
68
+
69
+ async function loadConversation(apiBaseUrl, rootDir) {
70
+ try {
71
+ const raw = await fs.readFile(conversationPath(), 'utf8');
72
+ const parsed = JSON.parse(raw);
73
+ const value = parsed?.workspaces?.[workspaceKey(apiBaseUrl, rootDir)];
74
+ return value && typeof value === 'object' ? value : null;
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ async function saveConversation(apiBaseUrl, rootDir, conversation) {
81
+ const file = conversationPath();
82
+ let document = { version: 1, workspaces: {} };
83
+ try {
84
+ const parsed = JSON.parse(await fs.readFile(file, 'utf8'));
85
+ if (parsed && parsed.workspaces) document = parsed;
86
+ } catch {
87
+ /* first saved conversation */
88
+ }
89
+ document.version = 1;
90
+ document.workspaces[workspaceKey(apiBaseUrl, rootDir)] = conversation;
91
+ await fs.mkdir(path.dirname(file), { recursive: true });
92
+ await fs.writeFile(file, `${JSON.stringify(document, null, 2)}\n`, {
93
+ encoding: 'utf8',
94
+ mode: 0o600,
95
+ });
96
+ await fs.chmod(file, 0o600).catch(() => undefined);
97
+ }
98
+
99
+ module.exports = {
100
+ sessionPath,
101
+ conversationPath,
102
+ loadSession,
103
+ saveSession,
104
+ clearSession,
105
+ loadConversation,
106
+ saveConversation,
107
+ };