@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.
- package/README.md +208 -0
- package/bin/devmp.js +245 -0
- package/lib/api.js +231 -0
- package/lib/browser.js +21 -0
- package/lib/checkpoints.js +292 -0
- package/lib/command-runner.js +368 -0
- package/lib/commands.js +597 -0
- package/lib/completion.js +190 -0
- package/lib/config.js +172 -0
- package/lib/diff.js +216 -0
- package/lib/executor.js +208 -0
- package/lib/git.js +39 -0
- package/lib/instructions.js +69 -0
- package/lib/interactive-tunnel.js +420 -0
- package/lib/interactive.js +1269 -0
- package/lib/markdown.js +201 -0
- package/lib/mentions.js +68 -0
- package/lib/prompt.js +140 -0
- package/lib/routes.js +27 -0
- package/lib/session.js +107 -0
- package/lib/status.js +249 -0
- package/lib/tty/ansi.js +171 -0
- package/lib/tty/composer.js +680 -0
- package/lib/tty/screen.js +167 -0
- package/lib/ui.js +174 -0
- package/lib/version.js +134 -0
- package/lib/workspace.js +608 -0
- package/lib/ws-run.js +142 -0
- package/package.json +40 -0
package/lib/markdown.js
ADDED
|
@@ -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 };
|
package/lib/mentions.js
ADDED
|
@@ -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
|
+
};
|