@aexol/spectral 0.2.8 → 0.2.9

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.
@@ -39,6 +39,7 @@
39
39
  * transparent to this layer — we just respond when we can.
40
40
  */
41
41
  import { BadRequestError, NotFoundError } from "../server/handlers/errors.js";
42
+ import { handlePathAutocomplete } from "../server/handlers/paths-autocomplete.js";
42
43
  import { handleCreateProject, handleDeleteProject, handleListProjects, handleListSessionsByProject, handleUpdateProject, } from "../server/handlers/projects.js";
43
44
  import { handleCreateSession, handleDeleteSession, handleGetSessionDetail, handleUpdateSession, } from "../server/handlers/sessions.js";
44
45
  import { shutdownState } from "../server/shutdown.js";
@@ -50,9 +51,16 @@ import { shutdownState } from "../server/shutdown.js";
50
51
  * also marginally slower and harder to read for ~9 routes.
51
52
  */
52
53
  export function matchRoute(method, path) {
53
- // Strip query string if any (we don't use any, but be defensive).
54
+ // Strip query string for path matching but keep it for the handler.
54
55
  const qIdx = path.indexOf("?");
55
56
  const cleanPath = qIdx === -1 ? path : path.slice(0, qIdx);
57
+ const query = qIdx >= 0 ? new URLSearchParams(path.slice(qIdx + 1)) : undefined;
58
+ // /api/paths/autocomplete
59
+ if (cleanPath === "/api/paths/autocomplete") {
60
+ if (method === "GET")
61
+ return { route: "list_path_autocomplete", query };
62
+ return null;
63
+ }
56
64
  // /api/projects
57
65
  if (cleanPath === "/api/projects") {
58
66
  if (method === "GET")
@@ -267,6 +275,10 @@ function dispatchRoute(match, body, deps) {
267
275
  }
268
276
  return { ok: true };
269
277
  }
278
+ case "list_path_autocomplete": {
279
+ const prefix = match.query?.get("prefix") ?? "";
280
+ return handlePathAutocomplete(prefix);
281
+ }
270
282
  }
271
283
  }
272
284
  /**
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Filesystem autocomplete for project path creation.
3
+ *
4
+ * When a user types a partial path in the New Project form, the browser
5
+ * sends `GET /api/paths/autocomplete?prefix=<partial>`. This handler
6
+ * expands the prefix (tilde → $HOME, relative → absolute), lists the
7
+ * containing directory, and returns up to 10 directory suggestions whose
8
+ * basenames start with the typed partial segment.
9
+ *
10
+ * Dotfiles are excluded — they are rarely intentional project roots.
11
+ * Symlinks to directories ARE included (readdirSync with withFileTypes
12
+ * returns true for both real dirs and symlink dirs).
13
+ *
14
+ * Thread-safety: synchronous, called from the relay-dispatcher hot path.
15
+ * fs ops block the event loop for microseconds on local SSDs; the form
16
+ * is typed by a single human, so this is perfectly fine.
17
+ */
18
+ import { readdirSync } from "node:fs";
19
+ import { join, sep } from "node:path";
20
+ import { expandPath } from "../paths.js";
21
+ /**
22
+ * List directories inside `expandedPrefix`'s parent that start with the
23
+ * trailing (post-separator) segment of the expanded prefix.
24
+ *
25
+ * Examples (assume $HOME=/Users/alice, dirs are `projects`, `proj-old`,
26
+ * `Documents`, `.config`):
27
+ * prefix="~/pro" → expanded="/Users/alice/pro", parent="/Users/alice/"
28
+ * → ["/Users/alice/projects", "/Users/alice/proj-old"]
29
+ * prefix="~/proj" → expanded="/Users/alice/proj"
30
+ * → ["/Users/alice/projects"]
31
+ * prefix="~" → expanded="/Users/alice", parent="/Users/alice/"
32
+ * → all top-level dirs in $HOME (except dotfiles)
33
+ */
34
+ export function handlePathAutocomplete(prefix) {
35
+ const expanded = expandPath(prefix);
36
+ // Split into parent directory and basename prefix.
37
+ // On POSIX, `expanded` is always absolute after expandPath, so we
38
+ // always hit the else branch below with a trailing sep.
39
+ const lastSep = expanded.lastIndexOf(sep);
40
+ let parentDir;
41
+ let basenamePrefix;
42
+ if (lastSep < 0) {
43
+ // Shouldn't happen after expandPath, but be defensive.
44
+ parentDir = expanded;
45
+ basenamePrefix = "";
46
+ }
47
+ else {
48
+ parentDir = expanded.slice(0, lastSep + 1);
49
+ basenamePrefix = expanded.slice(lastSep + 1);
50
+ }
51
+ let suggestions = [];
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
+ }
66
+ return { suggestions, expandedPrefix: expanded };
67
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aexol/spectral",
3
- "version": "0.2.8",
3
+ "version": "0.2.9",
4
4
  "description": "Always-on coding agent for Aexol — branded pi wrapper with relay-based browser access.",
5
5
  "type": "module",
6
6
  "private": false,