@aexol/spectral 0.2.13 → 0.2.15
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/dist/commands/login-oauth.js +14 -10
- package/dist/config.js +15 -10
- package/dist/extensions/aexol-mcp.js +206 -3
- package/dist/server/handlers/paths-autocomplete.js +56 -27
- package/package.json +1 -1
|
@@ -18,11 +18,11 @@ const DEFAULT_BACKEND_URL = "https://studio.aexol.ai";
|
|
|
18
18
|
const CALLBACK_TIMEOUT_MS = 120_000; // 2 minutes
|
|
19
19
|
/**
|
|
20
20
|
* Start a temporary HTTP server to receive the OAuth callback.
|
|
21
|
-
* Returns the
|
|
21
|
+
* Returns the server and a promise that resolves with the JWT token.
|
|
22
22
|
*/
|
|
23
23
|
function listenForCallback(timeoutMs) {
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
const server = createServer();
|
|
25
|
+
const tokenPromise = new Promise((resolve, reject) => {
|
|
26
26
|
const timeout = setTimeout(() => {
|
|
27
27
|
server.close();
|
|
28
28
|
reject(new Error(`Timed out after ${timeoutMs / 1000}s waiting for browser authorization.`));
|
|
@@ -34,7 +34,7 @@ function listenForCallback(timeoutMs) {
|
|
|
34
34
|
clearTimeout(timeout);
|
|
35
35
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
36
36
|
res.end("<html><body><h1>✓ Authorized</h1><p>You can close this tab and return to your terminal.</p></body></html>");
|
|
37
|
-
resolve(
|
|
37
|
+
resolve(token);
|
|
38
38
|
}
|
|
39
39
|
else {
|
|
40
40
|
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
@@ -46,10 +46,8 @@ function listenForCallback(timeoutMs) {
|
|
|
46
46
|
server.close();
|
|
47
47
|
reject(new Error(`Failed to start local server: ${err.message}`));
|
|
48
48
|
});
|
|
49
|
-
server.listen(0, "127.0.0.1", () => {
|
|
50
|
-
// Port 0 lets the OS assign an ephemeral port — we read it from the server.
|
|
51
|
-
});
|
|
52
49
|
});
|
|
50
|
+
return { server, tokenPromise };
|
|
53
51
|
}
|
|
54
52
|
/** Derive the landing base URL from the backend URL. */
|
|
55
53
|
function deriveLandingUrl(backendUrl) {
|
|
@@ -72,8 +70,14 @@ export async function runLoginOAuth() {
|
|
|
72
70
|
let server;
|
|
73
71
|
let token;
|
|
74
72
|
try {
|
|
75
|
-
const
|
|
76
|
-
server =
|
|
73
|
+
const { server: srv, tokenPromise } = listenForCallback(CALLBACK_TIMEOUT_MS);
|
|
74
|
+
server = srv;
|
|
75
|
+
// Start listening on a random port, then wait for it to be ready
|
|
76
|
+
server.listen(0, "127.0.0.1");
|
|
77
|
+
await new Promise((resolve, reject) => {
|
|
78
|
+
server.once("listening", resolve);
|
|
79
|
+
server.once("error", reject);
|
|
80
|
+
});
|
|
77
81
|
const addr = server.address();
|
|
78
82
|
const port = addr.port;
|
|
79
83
|
// Open browser to the CLI auth page
|
|
@@ -93,7 +97,7 @@ export async function runLoginOAuth() {
|
|
|
93
97
|
});
|
|
94
98
|
// Wait for the token
|
|
95
99
|
process.stdout.write(pc.dim("Waiting for authorization...\n"));
|
|
96
|
-
token =
|
|
100
|
+
token = await tokenPromise;
|
|
97
101
|
}
|
|
98
102
|
catch (err) {
|
|
99
103
|
const msg = err instanceof Error ? err.message : String(err);
|
package/dist/config.js
CHANGED
|
@@ -56,16 +56,21 @@ export async function readConfig() {
|
|
|
56
56
|
}
|
|
57
57
|
try {
|
|
58
58
|
const parsed = JSON.parse(raw);
|
|
59
|
-
if (typeof parsed.apiUrl
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
59
|
+
if (typeof parsed.apiUrl !== "string")
|
|
60
|
+
return null;
|
|
61
|
+
const teamApiKey = typeof parsed.teamApiKey === "string" && parsed.teamApiKey.length > 0
|
|
62
|
+
? parsed.teamApiKey
|
|
63
|
+
: "";
|
|
64
|
+
const userJwt = typeof parsed.userJwt === "string" && parsed.userJwt.length > 0
|
|
65
|
+
? parsed.userJwt
|
|
66
|
+
: undefined;
|
|
67
|
+
if (!teamApiKey && !userJwt)
|
|
68
|
+
return null;
|
|
69
|
+
return {
|
|
70
|
+
apiUrl: parsed.apiUrl,
|
|
71
|
+
teamApiKey,
|
|
72
|
+
userJwt,
|
|
73
|
+
};
|
|
69
74
|
}
|
|
70
75
|
catch {
|
|
71
76
|
return null;
|
|
@@ -4,8 +4,13 @@
|
|
|
4
4
|
* Loaded by the spectral wrapper via `--extension <abs-path>`. On startup it:
|
|
5
5
|
* 1. Reads ~/.spectral/config.json (writen by `spectral login`).
|
|
6
6
|
* 2. Calls tools/list against the MCP backend once.
|
|
7
|
-
* 3.
|
|
8
|
-
*
|
|
7
|
+
* 3. Reads the local Studio binding (`.aexol/aexol.jsonc`) so remote tools
|
|
8
|
+
* auto-inherit `teamId` / `projectId` without needing `remote_set_context`.
|
|
9
|
+
* 4. Registers every returned remote tool through pi.registerTool() so the
|
|
10
|
+
* model can call them like built-in tools.
|
|
11
|
+
* 5. Registers local binding-management tools (`local_bind_project`,
|
|
12
|
+
* `local_unbind_project`, `local_get_binding`) so users can bind a
|
|
13
|
+
* directory to an Aexol Studio project from within the agent.
|
|
9
14
|
*
|
|
10
15
|
* Each registered tool is just a thin proxy: when pi's model invokes it, we
|
|
11
16
|
* translate the call into a JSON-RPC tools/call POST and unwrap the response
|
|
@@ -17,6 +22,7 @@
|
|
|
17
22
|
*/
|
|
18
23
|
import { getApiUrl, getConfigFile, readConfig } from "../config.js";
|
|
19
24
|
import { AexolMcpClient, AexolMcpError } from "../mcp-client.js";
|
|
25
|
+
import { readStudioBinding, writeStudioBinding, deleteStudioBinding, } from "../studio-binding.js";
|
|
20
26
|
/**
|
|
21
27
|
* Render a backend tool result into a single string for pi.
|
|
22
28
|
*
|
|
@@ -54,6 +60,27 @@ function toLabel(name) {
|
|
|
54
60
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
55
61
|
.join(" ") || name;
|
|
56
62
|
}
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Studio binding – in-memory cache, refreshed on startup & by local tools
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
let currentBinding = null;
|
|
67
|
+
/**
|
|
68
|
+
* Augment tool-call params with `teamId` and `projectId` from the current
|
|
69
|
+
* Studio binding when they are not already set by the model. Only applies
|
|
70
|
+
* to remote tools (names that start with `remote_`).
|
|
71
|
+
*/
|
|
72
|
+
function augmentParams(toolName, params) {
|
|
73
|
+
if (!currentBinding || !toolName.startsWith("remote_"))
|
|
74
|
+
return params;
|
|
75
|
+
const augmented = { ...params };
|
|
76
|
+
if (currentBinding.teamId && !("teamId" in params)) {
|
|
77
|
+
augmented.teamId = currentBinding.teamId;
|
|
78
|
+
}
|
|
79
|
+
if (currentBinding.projectId && !("projectId" in params)) {
|
|
80
|
+
augmented.projectId = currentBinding.projectId;
|
|
81
|
+
}
|
|
82
|
+
return augmented;
|
|
83
|
+
}
|
|
57
84
|
export default async function aexolMcpExtension(pi) {
|
|
58
85
|
const cfg = await readConfig();
|
|
59
86
|
if (!cfg) {
|
|
@@ -63,6 +90,21 @@ export default async function aexolMcpExtension(pi) {
|
|
|
63
90
|
}
|
|
64
91
|
const apiUrl = getApiUrl(cfg.apiUrl);
|
|
65
92
|
const client = new AexolMcpClient(apiUrl, cfg.teamApiKey);
|
|
93
|
+
// ---- Load local Studio binding -------------------------------------------
|
|
94
|
+
try {
|
|
95
|
+
currentBinding = await readStudioBinding();
|
|
96
|
+
if (currentBinding) {
|
|
97
|
+
const displayName = currentBinding.name ?? currentBinding.projectId;
|
|
98
|
+
process.stderr.write(`[aexol-mcp] Studio binding found: ${displayName} (${currentBinding.projectId})` +
|
|
99
|
+
(currentBinding.teamId ? `, team ${currentBinding.teamId}` : "") +
|
|
100
|
+
`\n`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
105
|
+
process.stderr.write(`[aexol-mcp] Failed to read Studio binding: ${msg}\n`);
|
|
106
|
+
// Non-fatal — remote tools still work with explicit teamId/projectId.
|
|
107
|
+
}
|
|
66
108
|
let tools;
|
|
67
109
|
try {
|
|
68
110
|
tools = await client.listTools();
|
|
@@ -84,7 +126,8 @@ export default async function aexolMcpExtension(pi) {
|
|
|
84
126
|
description: tool.description ?? "",
|
|
85
127
|
parameters,
|
|
86
128
|
async execute(_toolCallId, params) {
|
|
87
|
-
const
|
|
129
|
+
const augmented = augmentParams(tool.name, params);
|
|
130
|
+
const res = await client.callTool(tool.name, augmented);
|
|
88
131
|
const text = renderContentToString(res.content);
|
|
89
132
|
if (res.isError === true) {
|
|
90
133
|
// Surface backend tool errors as a textual result, not a thrown
|
|
@@ -113,5 +156,165 @@ export default async function aexolMcpExtension(pi) {
|
|
|
113
156
|
process.stderr.write(`[aexol-mcp] Failed to register tool "${tool.name}": ${msg}\n`);
|
|
114
157
|
}
|
|
115
158
|
}
|
|
159
|
+
// ---- Register local binding-management tools -----------------------------
|
|
160
|
+
// These allow the model to bind / unbind / inspect the Studio project
|
|
161
|
+
// binding directly from within the agent, without the user having to drop
|
|
162
|
+
// out to the terminal and run `spectral bind` / `spectral unbind`.
|
|
163
|
+
try {
|
|
164
|
+
pi.registerTool({
|
|
165
|
+
name: "local_bind_project",
|
|
166
|
+
label: "Bind Project",
|
|
167
|
+
description: "Link the current working directory to an Aexol Studio project. " +
|
|
168
|
+
"After binding, all remote_* tools will automatically use this project context " +
|
|
169
|
+
"(no need to set teamId/projectId on every call). " +
|
|
170
|
+
"Use this when you want to work within a specific Aexol Studio project.",
|
|
171
|
+
parameters: {
|
|
172
|
+
type: "object",
|
|
173
|
+
properties: {
|
|
174
|
+
projectId: {
|
|
175
|
+
type: "string",
|
|
176
|
+
description: "The Aexol Studio project ID to bind to (required). Copy this from the project URL in Studio.",
|
|
177
|
+
},
|
|
178
|
+
teamId: {
|
|
179
|
+
type: "string",
|
|
180
|
+
description: "Optional team ID. If omitted, remote tools use only the projectId for context.",
|
|
181
|
+
},
|
|
182
|
+
name: {
|
|
183
|
+
type: "string",
|
|
184
|
+
description: "Optional human-readable name for the binding (shown in logs and status messages).",
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
required: ["projectId"],
|
|
188
|
+
},
|
|
189
|
+
async execute(_toolCallId, params) {
|
|
190
|
+
const p = params;
|
|
191
|
+
if (!p.projectId || typeof p.projectId !== "string") {
|
|
192
|
+
return {
|
|
193
|
+
content: [{ type: "text", text: "Error: projectId (string) is required." }],
|
|
194
|
+
details: { isError: true },
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
const binding = {
|
|
198
|
+
projectId: p.projectId,
|
|
199
|
+
...(p.teamId ? { teamId: p.teamId } : {}),
|
|
200
|
+
...(p.name ? { name: p.name } : {}),
|
|
201
|
+
};
|
|
202
|
+
try {
|
|
203
|
+
await writeStudioBinding(binding);
|
|
204
|
+
currentBinding = binding;
|
|
205
|
+
const displayName = binding.name ?? binding.projectId;
|
|
206
|
+
return {
|
|
207
|
+
content: [{
|
|
208
|
+
type: "text",
|
|
209
|
+
text: `✓ Bound to Studio project: ${displayName} (${binding.projectId})` +
|
|
210
|
+
(binding.teamId ? `, team ${binding.teamId}` : "") +
|
|
211
|
+
`\nAll remote_* tools will now use this project context by default.`,
|
|
212
|
+
}],
|
|
213
|
+
details: { isError: false },
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
218
|
+
return {
|
|
219
|
+
content: [{ type: "text", text: `Error writing binding: ${msg}` }],
|
|
220
|
+
details: { isError: true },
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
registered++;
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
229
|
+
process.stderr.write(`[aexol-mcp] Failed to register local_bind_project: ${msg}\n`);
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
pi.registerTool({
|
|
233
|
+
name: "local_unbind_project",
|
|
234
|
+
label: "Unbind Project",
|
|
235
|
+
description: "Remove the Aexol Studio project binding from the current working directory. " +
|
|
236
|
+
"After unbinding, remote_* tools will no longer receive automatic project context " +
|
|
237
|
+
"— you must pass teamId/projectId explicitly or use local_bind_project to re-bind.",
|
|
238
|
+
parameters: {
|
|
239
|
+
type: "object",
|
|
240
|
+
properties: {},
|
|
241
|
+
},
|
|
242
|
+
async execute() {
|
|
243
|
+
if (!currentBinding) {
|
|
244
|
+
return {
|
|
245
|
+
content: [{ type: "text", text: "No Studio binding found in this directory — nothing to unbind." }],
|
|
246
|
+
details: { isError: false },
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
const displayName = currentBinding.name ?? currentBinding.projectId;
|
|
250
|
+
const projectId = currentBinding.projectId;
|
|
251
|
+
try {
|
|
252
|
+
const deleted = await deleteStudioBinding();
|
|
253
|
+
currentBinding = null;
|
|
254
|
+
if (deleted) {
|
|
255
|
+
return {
|
|
256
|
+
content: [{
|
|
257
|
+
type: "text",
|
|
258
|
+
text: `✓ Unbound from Studio project: ${displayName} (${projectId}).`,
|
|
259
|
+
}],
|
|
260
|
+
details: { isError: false },
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
content: [{ type: "text", text: "Binding file was already removed." }],
|
|
265
|
+
details: { isError: false },
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
catch (err) {
|
|
269
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
270
|
+
return {
|
|
271
|
+
content: [{ type: "text", text: `Error removing binding: ${msg}` }],
|
|
272
|
+
details: { isError: true },
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
registered++;
|
|
278
|
+
}
|
|
279
|
+
catch (err) {
|
|
280
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
281
|
+
process.stderr.write(`[aexol-mcp] Failed to register local_unbind_project: ${msg}\n`);
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
pi.registerTool({
|
|
285
|
+
name: "local_get_binding",
|
|
286
|
+
label: "Get Binding",
|
|
287
|
+
description: "Show the current Aexol Studio project binding for this directory. " +
|
|
288
|
+
"Returns the bound projectId, teamId, and name (if set), or reports that no binding exists.",
|
|
289
|
+
parameters: {
|
|
290
|
+
type: "object",
|
|
291
|
+
properties: {},
|
|
292
|
+
},
|
|
293
|
+
async execute() {
|
|
294
|
+
if (!currentBinding) {
|
|
295
|
+
return {
|
|
296
|
+
content: [{
|
|
297
|
+
type: "text",
|
|
298
|
+
text: "No Aexol Studio binding found in this directory.\n" +
|
|
299
|
+
"Use local_bind_project to create one, or `spectral bind <project-id>` from the terminal.",
|
|
300
|
+
}],
|
|
301
|
+
details: { isError: false },
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
return {
|
|
305
|
+
content: [{
|
|
306
|
+
type: "text",
|
|
307
|
+
text: JSON.stringify(currentBinding, null, 2),
|
|
308
|
+
}],
|
|
309
|
+
details: { isError: false },
|
|
310
|
+
};
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
registered++;
|
|
314
|
+
}
|
|
315
|
+
catch (err) {
|
|
316
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
317
|
+
process.stderr.write(`[aexol-mcp] Failed to register local_get_binding: ${msg}\n`);
|
|
318
|
+
}
|
|
116
319
|
process.stderr.write(`[aexol-mcp] Registered ${registered} Aexol MCP tool(s) from ${apiUrl}.\n`);
|
|
117
320
|
}
|
|
@@ -7,6 +7,14 @@
|
|
|
7
7
|
* containing directory, and returns up to 10 directory suggestions whose
|
|
8
8
|
* basenames start with the typed partial segment.
|
|
9
9
|
*
|
|
10
|
+
* Two strategies:
|
|
11
|
+
* 1. If the expanded prefix exists on disk as a directory — show its
|
|
12
|
+
* immediate children (trailing separator is implicit). This is the
|
|
13
|
+
* natural behaviour when the user has already typed a real directory
|
|
14
|
+
* and wants to drill deeper.
|
|
15
|
+
* 2. Otherwise — split into parent + basename prefix and do a
|
|
16
|
+
* starts-with match so the user can filter siblings while typing.
|
|
17
|
+
*
|
|
10
18
|
* Dotfiles are excluded — they are rarely intentional project roots.
|
|
11
19
|
* Symlinks to directories ARE included (readdirSync with withFileTypes
|
|
12
20
|
* returns true for both real dirs and symlink dirs).
|
|
@@ -15,27 +23,62 @@
|
|
|
15
23
|
* fs ops block the event loop for microseconds on local SSDs; the form
|
|
16
24
|
* is typed by a single human, so this is perfectly fine.
|
|
17
25
|
*/
|
|
18
|
-
import { readdirSync } from "node:fs";
|
|
26
|
+
import { readdirSync, statSync } from "node:fs";
|
|
19
27
|
import { join, sep } from "node:path";
|
|
20
28
|
import { expandPath } from "../paths.js";
|
|
29
|
+
/** Maximum number of suggestions returned to the UI. */
|
|
30
|
+
const MAX_SUGGESTIONS = 10;
|
|
31
|
+
/**
|
|
32
|
+
* Build a suggestion list from `parentDir`, filtering directories whose
|
|
33
|
+
* basename starts with `basenamePrefix` (case-sensitive, POSIX).
|
|
34
|
+
*
|
|
35
|
+
* Dotfiles are always excluded. Returns at most `MAX_SUGGESTIONS` entries.
|
|
36
|
+
*/
|
|
37
|
+
function collectSuggestions(parentDir, basenamePrefix) {
|
|
38
|
+
try {
|
|
39
|
+
const entries = readdirSync(parentDir, { withFileTypes: true });
|
|
40
|
+
return entries
|
|
41
|
+
.filter((e) => e.isDirectory() &&
|
|
42
|
+
e.name.startsWith(basenamePrefix) &&
|
|
43
|
+
!e.name.startsWith("."))
|
|
44
|
+
.map((e) => join(parentDir, e.name))
|
|
45
|
+
.slice(0, MAX_SUGGESTIONS);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// Directory doesn't exist or is unreadable → empty list.
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
21
52
|
/**
|
|
22
|
-
* List
|
|
23
|
-
* trailing (post-separator) segment of the expanded prefix.
|
|
53
|
+
* List directory suggestions for the given path prefix.
|
|
24
54
|
*
|
|
25
55
|
* Examples (assume $HOME=/Users/alice, dirs are `projects`, `proj-old`,
|
|
26
56
|
* `Documents`, `.config`):
|
|
27
|
-
* prefix="
|
|
28
|
-
*
|
|
29
|
-
* prefix="~/
|
|
30
|
-
*
|
|
31
|
-
* prefix="
|
|
32
|
-
*
|
|
57
|
+
* prefix="~" → expanded="/Users/alice" (exists as dir)
|
|
58
|
+
* → children of /Users/alice/ ["projects","proj-old","Documents"]
|
|
59
|
+
* prefix="~/pro" → expanded="/Users/alice/pro" (does NOT exist)
|
|
60
|
+
* → starts-with "pro" in /Users/alice/ ["projects","proj-old"]
|
|
61
|
+
* prefix="~/projects/" → expanded="/Users/alice/projects/" (exists as dir)
|
|
62
|
+
* → children of /Users/alice/projects/
|
|
63
|
+
* prefix="~/projects/sr" → expanded="/Users/alice/projects/sr" (does NOT exist)
|
|
64
|
+
* → starts-with "sr" in /Users/alice/projects/
|
|
33
65
|
*/
|
|
34
66
|
export function handlePathAutocomplete(prefix) {
|
|
35
67
|
const expanded = expandPath(prefix);
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
68
|
+
// Strategy 1: expanded path is an existing directory → show children.
|
|
69
|
+
try {
|
|
70
|
+
const st = statSync(expanded);
|
|
71
|
+
if (st.isDirectory()) {
|
|
72
|
+
const parentDir = expanded.endsWith(sep) ? expanded : expanded + sep;
|
|
73
|
+
const suggestions = collectSuggestions(parentDir, "");
|
|
74
|
+
return { suggestions, expandedPrefix: expanded };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// Not a directory or doesn't exist — fall through to prefix matching.
|
|
79
|
+
}
|
|
80
|
+
// Strategy 2: expanded path doesn't exist (or exists but isn't a dir).
|
|
81
|
+
// Split into parent directory + basename prefix and match by starts-with.
|
|
39
82
|
const lastSep = expanded.lastIndexOf(sep);
|
|
40
83
|
let parentDir;
|
|
41
84
|
let basenamePrefix;
|
|
@@ -48,20 +91,6 @@ export function handlePathAutocomplete(prefix) {
|
|
|
48
91
|
parentDir = expanded.slice(0, lastSep + 1);
|
|
49
92
|
basenamePrefix = expanded.slice(lastSep + 1);
|
|
50
93
|
}
|
|
51
|
-
|
|
52
|
-
try {
|
|
53
|
-
const entries = readdirSync(parentDir, { withFileTypes: true });
|
|
54
|
-
suggestions = entries
|
|
55
|
-
.filter((e) => e.isDirectory() &&
|
|
56
|
-
e.name.startsWith(basenamePrefix) &&
|
|
57
|
-
!e.name.startsWith("."))
|
|
58
|
-
.map((e) => join(parentDir, e.name))
|
|
59
|
-
.slice(0, 10); // keep the list short for the UI
|
|
60
|
-
}
|
|
61
|
-
catch {
|
|
62
|
-
// Directory doesn't exist or is unreadable → return empty list.
|
|
63
|
-
// The caller (dispatcher) returns a 200 with an empty suggestions[]
|
|
64
|
-
// so the UI can show a "no matching directories" hint.
|
|
65
|
-
}
|
|
94
|
+
const suggestions = collectSuggestions(parentDir, basenamePrefix);
|
|
66
95
|
return { suggestions, expandedPrefix: expanded };
|
|
67
96
|
}
|