@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,190 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs/promises");
4
+ const path = require("path");
5
+ const { IGNORE_DIRS, isSecretPath } = require("./workspace");
6
+
7
+ // Tab completion for the composer. Two things are worth completing: the slash
8
+ // commands, which are otherwise only discoverable through /help, and @-paths,
9
+ // which are useless if you have to type the whole path from memory.
10
+
11
+ /** Every command the shell actually implements, in the order /help lists them. */
12
+ const SLASH_COMMANDS = [
13
+ "/help",
14
+ "/init",
15
+ "/status",
16
+ "/doctor",
17
+ "/login",
18
+ "/mode",
19
+ "/plan",
20
+ "/model",
21
+ "/permissions",
22
+ "/diff",
23
+ "/undo",
24
+ "/jobs",
25
+ "/gigs",
26
+ "/new",
27
+ "/compact",
28
+ "/resume",
29
+ "/quit",
30
+ "/exit",
31
+ ];
32
+
33
+ /** Arguments worth suggesting once the command itself is complete. */
34
+ const SLASH_ARGUMENTS = {
35
+ "/mode": ["auto", "chat", "plan", "act"],
36
+ "/permissions": ["ask", "edits", "commands", "auto"],
37
+ "/model": ["openai", "anthropic", "ollama"],
38
+ "/jobs": ["stop"],
39
+ "/init": ["update"],
40
+ "/login": ["signup"],
41
+ };
42
+
43
+ const MAX_OPTIONS = 24;
44
+
45
+ function longestCommonPrefix(values) {
46
+ if (!values.length) return "";
47
+ let prefix = values[0];
48
+ for (const value of values.slice(1)) {
49
+ let i = 0;
50
+ while (i < prefix.length && i < value.length && prefix[i] === value[i]) i += 1;
51
+ prefix = prefix.slice(0, i);
52
+ if (!prefix) break;
53
+ }
54
+ return prefix;
55
+ }
56
+
57
+ /**
58
+ * The whitespace-delimited token the caret sits in. Returned as glyph indices,
59
+ * because that is what the composer's caret counts in — byte or UTF-16 offsets
60
+ * would drift on any line containing an emoji or an accented character.
61
+ */
62
+ function tokenAt(glyphs, caret) {
63
+ let from = caret;
64
+ while (from > 0 && !/\s/.test(glyphs[from - 1])) from -= 1;
65
+ let to = caret;
66
+ while (to < glyphs.length && !/\s/.test(glyphs[to])) to += 1;
67
+ return { from, to, value: glyphs.slice(from, to).join("") };
68
+ }
69
+
70
+ /**
71
+ * Turn a candidate list into an edit. One match completes outright; several
72
+ * advance to the common prefix and report the choices, which is the behaviour
73
+ * a shell trains people to expect.
74
+ */
75
+ function chooseCompletion({ from, to, prefix, candidates, suffix = " " }) {
76
+ const matches = candidates.filter((item) => item.startsWith(prefix));
77
+ if (!matches.length) return null;
78
+ if (matches.length === 1) {
79
+ const only = matches[0];
80
+ return {
81
+ from,
82
+ to,
83
+ insert: only + (only.endsWith("/") ? "" : suffix),
84
+ options: [],
85
+ };
86
+ }
87
+ const common = longestCommonPrefix(matches);
88
+ return {
89
+ from,
90
+ to,
91
+ // No progress to make: leave the text alone and just show the choices.
92
+ insert: common.length > prefix.length ? common : prefix,
93
+ options: matches.slice(0, MAX_OPTIONS),
94
+ truncated: Math.max(0, matches.length - MAX_OPTIONS),
95
+ };
96
+ }
97
+
98
+ function completeSlash(glyphs, caret) {
99
+ const token = tokenAt(glyphs, caret);
100
+ const line = glyphs.join("");
101
+ if (!line.startsWith("/")) return null;
102
+
103
+ // First token: complete the command name itself.
104
+ if (token.from === 0) {
105
+ return chooseCompletion({
106
+ from: token.from,
107
+ to: token.to,
108
+ prefix: token.value,
109
+ candidates: SLASH_COMMANDS,
110
+ });
111
+ }
112
+
113
+ // Later tokens: complete that command's known arguments, if it has any.
114
+ const command = line.slice(0, line.search(/\s/) === -1 ? line.length : line.search(/\s/));
115
+ const args = SLASH_ARGUMENTS[command];
116
+ if (!args) return null;
117
+ return chooseCompletion({
118
+ from: token.from,
119
+ to: token.to,
120
+ prefix: token.value,
121
+ candidates: args,
122
+ });
123
+ }
124
+
125
+ async function completeMention(rootDir, glyphs, caret) {
126
+ const token = tokenAt(glyphs, caret);
127
+ if (!token.value.startsWith("@")) return null;
128
+
129
+ const partial = token.value.slice(1);
130
+ const slash = partial.lastIndexOf("/");
131
+ const dir = slash === -1 ? "" : partial.slice(0, slash + 1);
132
+ const base = slash === -1 ? partial : partial.slice(slash + 1);
133
+
134
+ // Completion must not become a way to walk out of the workspace, and it must
135
+ // not name files the agent is forbidden to read.
136
+ const target = path.resolve(rootDir, dir);
137
+ const root = path.resolve(rootDir);
138
+ if (target !== root && !target.startsWith(root + path.sep)) return null;
139
+
140
+ let entries;
141
+ try {
142
+ entries = await fs.readdir(target, { withFileTypes: true });
143
+ } catch {
144
+ return null;
145
+ }
146
+
147
+ const candidates = [];
148
+ for (const entry of entries) {
149
+ if (entry.isDirectory() && IGNORE_DIRS.has(entry.name)) continue;
150
+ // Hidden files stay hidden until you ask for them by typing the dot.
151
+ if (entry.name.startsWith(".") && !base.startsWith(".")) continue;
152
+ const rel = dir + entry.name;
153
+ if (isSecretPath(rel)) continue;
154
+ candidates.push(entry.isDirectory() ? `${rel}/` : rel);
155
+ }
156
+ candidates.sort();
157
+
158
+ const result = chooseCompletion({
159
+ from: token.from,
160
+ to: token.to,
161
+ prefix: partial,
162
+ candidates,
163
+ });
164
+ if (!result) return null;
165
+ return {
166
+ ...result,
167
+ insert: `@${result.insert}`,
168
+ options: result.options.map((item) => `@${item}`),
169
+ };
170
+ }
171
+
172
+ /**
173
+ * Resolve a Tab press. Returns null when there is nothing sensible to do, so
174
+ * the composer can leave the text untouched rather than guessing.
175
+ */
176
+ async function completeInput(rootDir, value, caret) {
177
+ const glyphs = Array.from(value);
178
+ const position = Math.max(0, Math.min(caret, glyphs.length));
179
+ const mention = await completeMention(rootDir, glyphs, position);
180
+ if (mention) return mention;
181
+ return completeSlash(glyphs, position);
182
+ }
183
+
184
+ module.exports = {
185
+ completeInput,
186
+ completeSlash,
187
+ completeMention,
188
+ SLASH_COMMANDS,
189
+ SLASH_ARGUMENTS,
190
+ };
package/lib/config.js ADDED
@@ -0,0 +1,172 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+
7
+ // Environment resolution. Defaults to production; `--local` (or env overrides)
8
+ // points at a locally running backend/frontend. Kept identical to the existing
9
+ // device-auth client so sessions are interchangeable.
10
+ //
11
+ // An environment is a *pair* of URLs. They were resolved independently, so
12
+ // `--api-base-url <dev>` kept the production frontend and login opened an
13
+ // approval page on a different environment than the device code was created
14
+ // in — the code is simply not found there. Pairs stay together here.
15
+ const ENVIRONMENTS = {
16
+ production: {
17
+ apiBaseUrl: 'https://api-prod.devmarketplace.com/api',
18
+ frontendBaseUrl: 'https://beta.devmarketplace.com',
19
+ },
20
+ dev: {
21
+ apiBaseUrl: 'https://api-dev.devmarketplace.com/api',
22
+ frontendBaseUrl: 'https://dev.devmarketplace.com',
23
+ },
24
+ local: {
25
+ apiBaseUrl: 'http://localhost:8000/api',
26
+ frontendBaseUrl: 'http://localhost:3000',
27
+ },
28
+ };
29
+ const ENVIRONMENT_NAMES = Object.keys(ENVIRONMENTS);
30
+
31
+ // Settings a config file may carry. Anything else in the file is ignored rather
32
+ // than rejected, so a newer client's config does not break an older one.
33
+ const CONFIG_KEYS = new Set([
34
+ 'apiBaseUrl',
35
+ 'frontendBaseUrl',
36
+ 'local',
37
+ 'env',
38
+ 'provider',
39
+ 'model',
40
+ 'maxFiles',
41
+ 'yes',
42
+ 'allowCommands',
43
+ 'permissions',
44
+ 'mode',
45
+ ]);
46
+
47
+ const GLOBAL_CONFIG_PATH = () =>
48
+ process.env.DEVMP_CONFIG ||
49
+ path.join(os.homedir(), '.devmarketplace', 'config.json');
50
+
51
+ const PROJECT_CONFIG_NAME = '.devmp.json';
52
+
53
+ /**
54
+ * Read one config file. A missing file is the normal case; a malformed one is
55
+ * reported once and then ignored, because refusing to start over a stray comma
56
+ * in a convenience file would be a worse failure than the file not existing.
57
+ */
58
+ function readConfigFile(file, onWarn) {
59
+ let raw;
60
+ try {
61
+ raw = fs.readFileSync(file, 'utf8');
62
+ } catch {
63
+ return {};
64
+ }
65
+ let parsed;
66
+ try {
67
+ parsed = JSON.parse(raw);
68
+ } catch (error) {
69
+ onWarn?.(`Ignoring ${file}: ${error.message}`);
70
+ return {};
71
+ }
72
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
73
+ onWarn?.(`Ignoring ${file}: expected a JSON object.`);
74
+ return {};
75
+ }
76
+ const out = {};
77
+ for (const [key, value] of Object.entries(parsed)) {
78
+ if (CONFIG_KEYS.has(key)) out[key] = value;
79
+ }
80
+ return out;
81
+ }
82
+
83
+ /**
84
+ * Merge the config files that apply to this invocation.
85
+ *
86
+ * A project file sits next to the code it describes and wins over the global
87
+ * one, which is the order people expect from every other tool that does this.
88
+ */
89
+ function loadConfigFiles(cwd, onWarn) {
90
+ const global = readConfigFile(GLOBAL_CONFIG_PATH(), onWarn);
91
+ const project = readConfigFile(
92
+ path.join(path.resolve(cwd || process.cwd()), PROJECT_CONFIG_NAME),
93
+ onWarn,
94
+ );
95
+ return { ...global, ...project };
96
+ }
97
+
98
+ function stripTrailingSlash(value) {
99
+ return String(value).replace(/\/+$/, '');
100
+ }
101
+
102
+ /**
103
+ * Resolve the effective settings for this invocation.
104
+ *
105
+ * Precedence, highest first: an explicit flag, then the environment, then the
106
+ * project's `.devmp.json`, then the global config, then the built-in default.
107
+ * A flag typed now always beats a file written earlier.
108
+ */
109
+ function resolveConfig(flags = {}, { cwd, onWarn } = {}) {
110
+ const file = loadConfigFiles(cwd, onWarn);
111
+
112
+ const local = flags.local === true || (flags.local === undefined && file.local === true);
113
+
114
+ // `--local` is kept as the shorthand it always was; `--env <name>` is the
115
+ // general form. An unknown name falls back to production rather than failing,
116
+ // so a typo cannot strand someone with no usable target.
117
+ const requested = flags.env || process.env.DEVMP_ENV || file.env;
118
+ const envName = local
119
+ ? 'local'
120
+ : ENVIRONMENT_NAMES.includes(requested)
121
+ ? requested
122
+ : 'production';
123
+ const chosen = ENVIRONMENTS[envName];
124
+
125
+ const apiBaseUrl =
126
+ flags['api-base-url'] ||
127
+ process.env.DEVMP_API_BASE_URL ||
128
+ file.apiBaseUrl ||
129
+ chosen.apiBaseUrl;
130
+
131
+ const frontendBaseUrl =
132
+ flags['frontend-url'] ||
133
+ process.env.DEVMP_FRONTEND_URL ||
134
+ file.frontendBaseUrl ||
135
+ chosen.frontendBaseUrl;
136
+
137
+ return {
138
+ env: envName,
139
+ // Whether the user explicitly chose the API target this invocation. When
140
+ // false, commands keep using the URL the session was created against
141
+ // (so `devmp run` after `devmp login --local` still hits localhost).
142
+ apiExplicit: Boolean(
143
+ flags.local ||
144
+ flags.env ||
145
+ flags['api-base-url'] ||
146
+ file.apiBaseUrl ||
147
+ file.env ||
148
+ file.local,
149
+ ),
150
+ apiBaseUrl: stripTrailingSlash(apiBaseUrl),
151
+ frontendBaseUrl: stripTrailingSlash(frontendBaseUrl),
152
+ // Defaults a command may fall back to when no flag was given.
153
+ defaults: {
154
+ provider: typeof file.provider === 'string' ? file.provider : undefined,
155
+ model: typeof file.model === 'string' ? file.model : undefined,
156
+ maxFiles: Number.isInteger(file.maxFiles) ? file.maxFiles : undefined,
157
+ yes: file.yes === true,
158
+ allowCommands: file.allowCommands === true,
159
+ permissions:
160
+ typeof file.permissions === 'string' ? file.permissions : undefined,
161
+ mode: typeof file.mode === 'string' ? file.mode : undefined,
162
+ },
163
+ };
164
+ }
165
+
166
+ module.exports = {
167
+ resolveConfig,
168
+ loadConfigFiles,
169
+ GLOBAL_CONFIG_PATH,
170
+ PROJECT_CONFIG_NAME,
171
+ CONFIG_KEYS,
172
+ };
package/lib/diff.js ADDED
@@ -0,0 +1,216 @@
1
+ "use strict";
2
+
3
+ const { color } = require("./ui");
4
+
5
+ const CONTEXT_LINES = 3;
6
+ const MAX_LCS_CELLS = 2_000_000;
7
+
8
+ function linesOf(value) {
9
+ if (value === null || value === undefined || value === "") return [];
10
+ const lines = String(value).split("\n");
11
+ if (lines.at(-1) === "") lines.pop();
12
+ return lines;
13
+ }
14
+
15
+ function fallbackOps(before, after) {
16
+ let prefix = 0;
17
+ while (
18
+ prefix < before.length &&
19
+ prefix < after.length &&
20
+ before[prefix] === after[prefix]
21
+ ) {
22
+ prefix += 1;
23
+ }
24
+ let suffix = 0;
25
+ while (
26
+ suffix < before.length - prefix &&
27
+ suffix < after.length - prefix &&
28
+ before[before.length - 1 - suffix] === after[after.length - 1 - suffix]
29
+ ) {
30
+ suffix += 1;
31
+ }
32
+ return [
33
+ ...before.slice(0, prefix).map((line) => ({ type: " ", line })),
34
+ ...before
35
+ .slice(prefix, before.length - suffix)
36
+ .map((line) => ({ type: "-", line })),
37
+ ...after
38
+ .slice(prefix, after.length - suffix)
39
+ .map((line) => ({ type: "+", line })),
40
+ ...before
41
+ .slice(before.length - suffix)
42
+ .map((line) => ({ type: " ", line })),
43
+ ];
44
+ }
45
+
46
+ /** A small dependency-free line diff. LCS gives clean hunks for ordinary
47
+ * source files; very large comparisons use a bounded prefix/suffix fallback. */
48
+ function lineOps(beforeText, afterText) {
49
+ const before = linesOf(beforeText);
50
+ const after = linesOf(afterText);
51
+ const width = after.length + 1;
52
+ const cells = (before.length + 1) * width;
53
+ if (cells > MAX_LCS_CELLS) return fallbackOps(before, after);
54
+
55
+ const table = new Uint32Array(cells);
56
+ for (let i = before.length - 1; i >= 0; i -= 1) {
57
+ for (let j = after.length - 1; j >= 0; j -= 1) {
58
+ const at = i * width + j;
59
+ table[at] =
60
+ before[i] === after[j]
61
+ ? table[(i + 1) * width + j + 1] + 1
62
+ : Math.max(table[(i + 1) * width + j], table[at + 1]);
63
+ }
64
+ }
65
+
66
+ const ops = [];
67
+ let i = 0;
68
+ let j = 0;
69
+ while (i < before.length || j < after.length) {
70
+ if (i < before.length && j < after.length && before[i] === after[j]) {
71
+ ops.push({ type: " ", line: before[i] });
72
+ i += 1;
73
+ j += 1;
74
+ } else if (
75
+ j < after.length &&
76
+ (i >= before.length ||
77
+ table[i * width + j + 1] >= table[(i + 1) * width + j])
78
+ ) {
79
+ ops.push({ type: "+", line: after[j] });
80
+ j += 1;
81
+ } else {
82
+ ops.push({ type: "-", line: before[i] });
83
+ i += 1;
84
+ }
85
+ }
86
+ return ops;
87
+ }
88
+
89
+ function hunkRanges(ops) {
90
+ const changed = [];
91
+ for (let i = 0; i < ops.length; i += 1) {
92
+ if (ops[i].type !== " ") changed.push(i);
93
+ }
94
+ if (!changed.length) return [];
95
+
96
+ const ranges = [];
97
+ let start = Math.max(0, changed[0] - CONTEXT_LINES);
98
+ let end = Math.min(ops.length, changed[0] + CONTEXT_LINES + 1);
99
+ for (const index of changed.slice(1)) {
100
+ const nextStart = Math.max(0, index - CONTEXT_LINES);
101
+ const nextEnd = Math.min(ops.length, index + CONTEXT_LINES + 1);
102
+ if (nextStart <= end) end = Math.max(end, nextEnd);
103
+ else {
104
+ ranges.push([start, end]);
105
+ start = nextStart;
106
+ end = nextEnd;
107
+ }
108
+ }
109
+ ranges.push([start, end]);
110
+ return ranges;
111
+ }
112
+
113
+ function coordinates(ops, start, end) {
114
+ let oldLine = 1;
115
+ let newLine = 1;
116
+ for (const op of ops.slice(0, start)) {
117
+ if (op.type !== "+") oldLine += 1;
118
+ if (op.type !== "-") newLine += 1;
119
+ }
120
+ let oldCount = 0;
121
+ let newCount = 0;
122
+ for (const op of ops.slice(start, end)) {
123
+ if (op.type !== "+") oldCount += 1;
124
+ if (op.type !== "-") newCount += 1;
125
+ }
126
+ return { oldLine, newLine, oldCount, newCount };
127
+ }
128
+
129
+ function formatUnifiedDiff({ path, before, after }) {
130
+ const beforeExists = before !== null && before !== undefined;
131
+ const afterExists = after !== null && after !== undefined;
132
+ if (before === after) return "";
133
+ const ops = lineOps(before, after);
134
+ const output = [
135
+ `--- ${beforeExists ? `a/${path}` : "/dev/null"}`,
136
+ `+++ ${afterExists ? `b/${path}` : "/dev/null"}`,
137
+ ];
138
+ for (const [start, end] of hunkRanges(ops)) {
139
+ const c = coordinates(ops, start, end);
140
+ output.push(
141
+ `@@ -${c.oldLine},${c.oldCount} +${c.newLine},${c.newCount} @@`
142
+ );
143
+ for (const op of ops.slice(start, end)) output.push(`${op.type}${op.line}`);
144
+ }
145
+ return output.join("\n");
146
+ }
147
+
148
+ /**
149
+ * Styled diff lines. Returned rather than printed so the interactive screen can
150
+ * route them through its own writer and keep its row accounting intact.
151
+ *
152
+ * `indent` keeps a reviewed diff aligned with the rest of the transcript, and
153
+ * `maxLines` stops a whole generated file from burying the approval prompt —
154
+ * the point of review is to be readable, and nobody audits 400 lines in a
155
+ * terminal before pressing y.
156
+ */
157
+ function styleDiffLines(diff, { indent = "", maxLines = 0 } = {}) {
158
+ if (!diff) return [];
159
+ const raw = diff.split("\n");
160
+ const shown = maxLines > 0 && raw.length > maxLines ? raw.slice(0, maxLines) : raw;
161
+ const out = shown.map((line) => {
162
+ if (line.startsWith("+++") || line.startsWith("---")) {
163
+ return `${indent}${color.bold(line)}`;
164
+ }
165
+ if (line.startsWith("+")) return `${indent}${color.green(line)}`;
166
+ if (line.startsWith("-")) return `${indent}${color.red(line)}`;
167
+ if (line.startsWith("@@")) return `${indent}${color.cyan(line)}`;
168
+ return `${indent}${color.dim(line)}`;
169
+ });
170
+ if (shown.length < raw.length) {
171
+ out.push(
172
+ `${indent}${color.dim(`… ${raw.length - shown.length} more diff line(s)`)}`
173
+ );
174
+ }
175
+ return out;
176
+ }
177
+
178
+ /** Counts for a compact summary, so a create does not need a full +++ dump. */
179
+ function diffStats(diff) {
180
+ let added = 0;
181
+ let removed = 0;
182
+ for (const line of String(diff || "").split("\n")) {
183
+ if (line.startsWith("+++") || line.startsWith("---")) continue;
184
+ if (line.startsWith("+")) added += 1;
185
+ else if (line.startsWith("-")) removed += 1;
186
+ }
187
+ return { added, removed };
188
+ }
189
+
190
+ function printUnifiedDiff(diff) {
191
+ for (const line of styleDiffLines(diff)) console.log(line);
192
+ }
193
+
194
+ function checkpointDiff(checkpoint) {
195
+ if (!checkpoint?.files) return "";
196
+ return Object.entries(checkpoint.files)
197
+ .sort(([a], [b]) => a.localeCompare(b))
198
+ .map(([path, snapshots]) =>
199
+ formatUnifiedDiff({
200
+ path,
201
+ before: snapshots.before?.exists ? snapshots.before.content : null,
202
+ after: snapshots.after?.exists ? snapshots.after.content : null,
203
+ })
204
+ )
205
+ .filter(Boolean)
206
+ .join("\n\n");
207
+ }
208
+
209
+ module.exports = {
210
+ checkpointDiff,
211
+ diffStats,
212
+ formatUnifiedDiff,
213
+ lineOps,
214
+ printUnifiedDiff,
215
+ styleDiffLines,
216
+ };