@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/executor.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
isInteractive,
|
|
5
|
+
confirmOverwrite,
|
|
6
|
+
confirmFileChange,
|
|
7
|
+
confirmDestructive,
|
|
8
|
+
} = require("./prompt");
|
|
9
|
+
const { formatUnifiedDiff } = require("./diff");
|
|
10
|
+
const {
|
|
11
|
+
listFiles,
|
|
12
|
+
readFileRaw,
|
|
13
|
+
writeFileRaw,
|
|
14
|
+
appendFileRaw,
|
|
15
|
+
editFileRaw,
|
|
16
|
+
deleteFileRaw,
|
|
17
|
+
moveFileRaw,
|
|
18
|
+
fileExists,
|
|
19
|
+
searchFiles,
|
|
20
|
+
} = require("./workspace");
|
|
21
|
+
|
|
22
|
+
const MAX_SAFE_CHANGE_BYTES = 4 * 1024 * 1024;
|
|
23
|
+
|
|
24
|
+
/** Execute server-requested filesystem operations inside the local workspace.
|
|
25
|
+
* Interactive M5 sessions opt into diff review + durable pre-image capture;
|
|
26
|
+
* one-shot M1-M3 clients retain their existing overwrite behavior. */
|
|
27
|
+
function createExecutor({
|
|
28
|
+
rootDir,
|
|
29
|
+
yes,
|
|
30
|
+
reviewChanges = false,
|
|
31
|
+
checkpoint,
|
|
32
|
+
onFile = () => undefined,
|
|
33
|
+
interactive: interactiveOverride,
|
|
34
|
+
confirmChange = confirmFileChange,
|
|
35
|
+
confirmDestructiveChange = confirmDestructive,
|
|
36
|
+
}) {
|
|
37
|
+
const interactive =
|
|
38
|
+
interactiveOverride === undefined ? isInteractive() : interactiveOverride;
|
|
39
|
+
let overwriteAll = yes === true;
|
|
40
|
+
const approved = new Set();
|
|
41
|
+
|
|
42
|
+
function assertSafeSize(content, relPath) {
|
|
43
|
+
if (Buffer.byteLength(String(content), "utf8") > MAX_SAFE_CHANGE_BYTES) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
`Refused ${relPath}: proposed content exceeds the checkpoint safety limit.`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function approveOrdinary(relPath, before, after) {
|
|
51
|
+
if (before === after) return "unchanged";
|
|
52
|
+
assertSafeSize(after, relPath);
|
|
53
|
+
if (overwriteAll) return "yes";
|
|
54
|
+
|
|
55
|
+
if (reviewChanges) {
|
|
56
|
+
if (!interactive) return "no";
|
|
57
|
+
const choice = await confirmChange({ path: relPath, before, after });
|
|
58
|
+
if (choice === "all") overwriteAll = true;
|
|
59
|
+
return choice;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Legacy behavior: new files and files already approved during this run do
|
|
63
|
+
// not prompt; existing user files do.
|
|
64
|
+
if (approved.has(relPath)) return "yes";
|
|
65
|
+
if (!(await fileExists(rootDir, relPath))) {
|
|
66
|
+
approved.add(relPath);
|
|
67
|
+
return "yes";
|
|
68
|
+
}
|
|
69
|
+
if (!interactive) return "no";
|
|
70
|
+
const choice = await confirmOverwrite(relPath);
|
|
71
|
+
if (choice === "all") overwriteAll = true;
|
|
72
|
+
if (choice !== "no") approved.add(relPath);
|
|
73
|
+
return choice;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function capture(paths) {
|
|
77
|
+
if (checkpoint) await checkpoint.capture(paths);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function handle(op, args = {}) {
|
|
81
|
+
if (op === "list") return { paths: await listFiles(rootDir) };
|
|
82
|
+
if (op === "read")
|
|
83
|
+
return { content: await readFileRaw(rootDir, args.path) };
|
|
84
|
+
// Read-only: searching never needs approval, and the repo never leaves the
|
|
85
|
+
// machine — only the matching lines do.
|
|
86
|
+
if (op === "search") return searchFiles(rootDir, args);
|
|
87
|
+
|
|
88
|
+
if (op === "write" || op === "append") {
|
|
89
|
+
const before = await readFileRaw(rootDir, args.path);
|
|
90
|
+
const after =
|
|
91
|
+
op === "write"
|
|
92
|
+
? String(args.content)
|
|
93
|
+
: `${before || ""}${String(args.content)}`;
|
|
94
|
+
const choice = await approveOrdinary(args.path, before, after);
|
|
95
|
+
if (choice === "unchanged") return { unchanged: true };
|
|
96
|
+
if (choice === "no") {
|
|
97
|
+
onFile(args.path, "skipped");
|
|
98
|
+
return { skipped: true };
|
|
99
|
+
}
|
|
100
|
+
await capture([args.path]);
|
|
101
|
+
const existed = before !== null;
|
|
102
|
+
const ok =
|
|
103
|
+
op === "write"
|
|
104
|
+
? await writeFileRaw(rootDir, args.path, args.content)
|
|
105
|
+
: await appendFileRaw(rootDir, args.path, args.content);
|
|
106
|
+
if (!ok) throw new Error(`refused ${args.path} (outside workspace)`);
|
|
107
|
+
const outcome = existed ? "updated" : "created";
|
|
108
|
+
onFile(args.path, outcome);
|
|
109
|
+
return { outcome };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (op === "edit") {
|
|
113
|
+
const before = await readFileRaw(rootDir, args.path);
|
|
114
|
+
if (before === null || !before.includes(args.oldString)) {
|
|
115
|
+
return { replaced: false };
|
|
116
|
+
}
|
|
117
|
+
const after = before.replace(args.oldString, args.newString);
|
|
118
|
+
const choice = await approveOrdinary(args.path, before, after);
|
|
119
|
+
if (choice === "unchanged") return { replaced: true, unchanged: true };
|
|
120
|
+
if (choice === "no") {
|
|
121
|
+
onFile(args.path, "skipped");
|
|
122
|
+
return { replaced: false, skipped: true };
|
|
123
|
+
}
|
|
124
|
+
await capture([args.path]);
|
|
125
|
+
const result = await editFileRaw(
|
|
126
|
+
rootDir,
|
|
127
|
+
args.path,
|
|
128
|
+
args.oldString,
|
|
129
|
+
args.newString
|
|
130
|
+
);
|
|
131
|
+
if (result.refused)
|
|
132
|
+
throw new Error(`refused ${args.path} (outside workspace)`);
|
|
133
|
+
if (result.replaced) onFile(args.path, "updated");
|
|
134
|
+
return {
|
|
135
|
+
replaced: result.replaced,
|
|
136
|
+
outcome: result.replaced ? "updated" : undefined,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (op === "delete") {
|
|
141
|
+
const before = await readFileRaw(rootDir, args.path);
|
|
142
|
+
if (before === null) return { deleted: false };
|
|
143
|
+
if (!interactive) return { deleted: false, skipped: true };
|
|
144
|
+
const choice = await confirmDestructiveChange({
|
|
145
|
+
description: `Delete ${args.path}`,
|
|
146
|
+
diffs: [formatUnifiedDiff({ path: args.path, before, after: null })],
|
|
147
|
+
});
|
|
148
|
+
if (choice !== "yes") {
|
|
149
|
+
onFile(args.path, "skipped");
|
|
150
|
+
return { deleted: false, skipped: true };
|
|
151
|
+
}
|
|
152
|
+
await capture([args.path]);
|
|
153
|
+
const result = await deleteFileRaw(rootDir, args.path);
|
|
154
|
+
if (result.refused)
|
|
155
|
+
throw new Error(`refused ${args.path} (unsafe file target)`);
|
|
156
|
+
if (result.deleted) onFile(args.path, "deleted");
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (op === "move") {
|
|
161
|
+
const fromPath = args.fromPath;
|
|
162
|
+
const toPath = args.toPath;
|
|
163
|
+
const source = await readFileRaw(rootDir, fromPath);
|
|
164
|
+
if (source === null) return { moved: false };
|
|
165
|
+
const destination = await readFileRaw(rootDir, toPath);
|
|
166
|
+
if (!interactive) return { moved: false, skipped: true };
|
|
167
|
+
const choice = await confirmDestructiveChange({
|
|
168
|
+
description: `Move ${fromPath} → ${toPath}${
|
|
169
|
+
destination !== null ? " (replace destination)" : ""
|
|
170
|
+
}`,
|
|
171
|
+
diffs: [
|
|
172
|
+
formatUnifiedDiff({ path: fromPath, before: source, after: null }),
|
|
173
|
+
formatUnifiedDiff({
|
|
174
|
+
path: toPath,
|
|
175
|
+
before: destination,
|
|
176
|
+
after: source,
|
|
177
|
+
}),
|
|
178
|
+
],
|
|
179
|
+
});
|
|
180
|
+
if (choice !== "yes") {
|
|
181
|
+
onFile(toPath, "skipped", { fromPath });
|
|
182
|
+
return { moved: false, skipped: true };
|
|
183
|
+
}
|
|
184
|
+
await capture([fromPath, toPath]);
|
|
185
|
+
const result = await moveFileRaw(rootDir, fromPath, toPath);
|
|
186
|
+
if (result.refused) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
`refused move ${fromPath} → ${toPath} (unsafe file target)`
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
if (result.moved) {
|
|
192
|
+
await checkpoint?.recordMove(fromPath, toPath);
|
|
193
|
+
onFile(toPath, "moved", { fromPath });
|
|
194
|
+
}
|
|
195
|
+
return result;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
throw new Error(`unknown fs op: ${op}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function setOverwriteAll(value) {
|
|
202
|
+
overwriteAll = value === true;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return { handle, setOverwriteAll };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
module.exports = { createExecutor };
|
package/lib/git.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { execFile } = require("child_process");
|
|
4
|
+
|
|
5
|
+
function git(args, cwd) {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
execFile(
|
|
8
|
+
"git",
|
|
9
|
+
args,
|
|
10
|
+
{ cwd, timeout: 3000, maxBuffer: 1024 * 1024, encoding: "utf8" },
|
|
11
|
+
(error, stdout) => {
|
|
12
|
+
if (error) reject(error);
|
|
13
|
+
else resolve(String(stdout).trim());
|
|
14
|
+
}
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function getGitInfo(rootDir) {
|
|
20
|
+
try {
|
|
21
|
+
const [topLevel, branch, status] = await Promise.all([
|
|
22
|
+
git(["rev-parse", "--show-toplevel"], rootDir),
|
|
23
|
+
git(["rev-parse", "--abbrev-ref", "HEAD"], rootDir),
|
|
24
|
+
git(["status", "--short", "--untracked-files=normal"], rootDir),
|
|
25
|
+
]);
|
|
26
|
+
const entries = status ? status.split("\n").filter(Boolean) : [];
|
|
27
|
+
return {
|
|
28
|
+
isRepo: true,
|
|
29
|
+
topLevel,
|
|
30
|
+
branch: branch === "HEAD" ? "detached HEAD" : branch,
|
|
31
|
+
changed: entries.length,
|
|
32
|
+
entries,
|
|
33
|
+
};
|
|
34
|
+
} catch {
|
|
35
|
+
return { isRepo: false, changed: 0, entries: [] };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = { getGitInfo };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const fs = require("fs/promises");
|
|
5
|
+
|
|
6
|
+
// Project instructions: the file a repository keeps to tell a coding agent how
|
|
7
|
+
// this codebase works — its conventions, its build, the things a newcomer gets
|
|
8
|
+
// wrong. Without it every session starts from zero and the agent relearns the
|
|
9
|
+
// project by guessing, which is the difference between a demo and a tool people
|
|
10
|
+
// keep using.
|
|
11
|
+
//
|
|
12
|
+
// `AGENTS.md` is first because it is the name several agents already read, so a
|
|
13
|
+
// repository that has one works here with no extra file. The others are
|
|
14
|
+
// accepted so a team that already standardised on one is not asked to rename it.
|
|
15
|
+
const CANDIDATES = ["AGENTS.md", "DEVMP.md", "CLAUDE.md", ".devmp.md"];
|
|
16
|
+
|
|
17
|
+
// Instructions ride along with every turn, so their size is a running cost, not
|
|
18
|
+
// a one-off. 8 KB is a generous page of conventions and still small next to a
|
|
19
|
+
// turn's own context.
|
|
20
|
+
const MAX_BYTES = 8 * 1024;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Find and read the workspace's instructions file.
|
|
24
|
+
*
|
|
25
|
+
* Returns null when there is none — the common case, and not an error. A file
|
|
26
|
+
* that exists but cannot be read is also null: instructions are an enhancement,
|
|
27
|
+
* and failing a run over them would be the wrong trade.
|
|
28
|
+
*/
|
|
29
|
+
async function readProjectInstructions(rootDir) {
|
|
30
|
+
const root = path.resolve(rootDir);
|
|
31
|
+
for (const name of CANDIDATES) {
|
|
32
|
+
let buf;
|
|
33
|
+
try {
|
|
34
|
+
buf = await fs.readFile(path.join(root, name));
|
|
35
|
+
} catch {
|
|
36
|
+
continue; // absent, unreadable, or a directory — try the next name
|
|
37
|
+
}
|
|
38
|
+
const truncated = buf.length > MAX_BYTES;
|
|
39
|
+
const text = buf.subarray(0, MAX_BYTES).toString("utf8").trim();
|
|
40
|
+
if (!text) return null; // an empty file is the same as no file
|
|
41
|
+
return { text, source: name, truncated };
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Which instructions file this workspace already has, if any. Separate from
|
|
48
|
+
* reading it: `/init` needs to know whether one exists before it offers to
|
|
49
|
+
* write one, and must never overwrite what is there.
|
|
50
|
+
*/
|
|
51
|
+
async function findProjectInstructionsFile(rootDir) {
|
|
52
|
+
const root = path.resolve(rootDir);
|
|
53
|
+
for (const name of CANDIDATES) {
|
|
54
|
+
try {
|
|
55
|
+
const stat = await fs.stat(path.join(root, name));
|
|
56
|
+
if (stat.isFile()) return name;
|
|
57
|
+
} catch {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = {
|
|
65
|
+
readProjectInstructions,
|
|
66
|
+
findProjectInstructionsFile,
|
|
67
|
+
CANDIDATES,
|
|
68
|
+
MAX_BYTES,
|
|
69
|
+
};
|