@stevecastaneda/paseo-progress 1.3.0 → 1.3.1

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 CHANGED
@@ -36,7 +36,7 @@ Or from Paseo's plugin registry: `paseo plugin add stevecastaneda/progress`.
36
36
 
37
37
  Enable plugins under **Settings → Plugins** on the Paseo host. You need Node.js 22.18 or later on the daemon host.
38
38
 
39
- Then install the `paseo-progress` command agents run. Open **Progress** from Command Center and press **Install command** in the banner. It adds one file, `~/.local/bin/paseo-progress`, on the machine running the Paseo daemon, and the banner disappears once agents can run it. The plugin writes nothing until you press it, and it never replaces a file something else put at that path.
39
+ Then install the `paseo-progress` command agents run. Open **Progress** from Command Center and press **Install command** in the banner. It adds one file, `~/.local/bin/paseo-progress`, on the machine running the Paseo daemon, and the banner disappears once agents can run it. On Windows it also adds `paseo-progress.cmd` next to it, because PowerShell and cmd only run files with an extension like `.cmd`. The plugin writes nothing until you press it, and it never replaces a file something else put at that path.
40
40
 
41
41
  To install from a terminal instead, or to put the command somewhere else:
42
42
 
@@ -47,6 +47,15 @@ node "$([ -f "$dir/dist/server/cli.js" ] && echo "$dir/dist/server/cli.js" || ec
47
47
 
48
48
  If `paseo` isn't on your `PATH`, use `/Applications/Paseo.app/Contents/Resources/bin/paseo`. The command doesn't name a plugin folder: each run asks Paseo (`paseo plugin ls`) which copy of the plugin it is running and runs that copy, so it keeps working after updates and `npm run dev` switches. The npm package carries the command as one JavaScript file in `dist/`, built with esbuild when publishing, because Node won't run TypeScript from `node_modules`. That file includes zod (MIT), so installing the plugin downloads no other packages.
49
49
 
50
+ The command, and **Install skill** in the panel, look for Paseo's own command in this order:
51
+
52
+ 1. `PASEO_BIN`, if you set it.
53
+ 2. `PASEO_CLI`, which Paseo sets for the agents it starts.
54
+ 3. `paseo` on your `PATH`.
55
+ 4. The app's default install: `/Applications/Paseo.app/Contents/Resources/bin/paseo` on macOS, `%LOCALAPPDATA%\Programs\Paseo\resources\bin\paseo.cmd` on Windows.
56
+
57
+ Setting `PASEO_PROGRESS_PLUGIN_DIR` to a plugin folder makes the command run that copy without asking Paseo. Use it only for testing, because an installed plugin's folder changes with every update.
58
+
50
59
  ## Where progress is kept
51
60
 
52
61
  Each worktree's progress lives outside the repo, in its own folder under `~/.local/state/paseo-progress/` on the daemon host. There's nothing to set up and nothing to hide: git never sees these files, and the plugin never edits a `.gitignore` or git's exclude file. The panel's empty state shows the exact path.
@@ -63,7 +72,7 @@ Earlier versions kept progress in `.scratch/` in each worktree, hidden by a `.sc
63
72
 
64
73
  ## Agent skill
65
74
 
66
- The plugin ships an agent skill, `paseo-progress`, that tells agents when to run each command during a multi-ticket job: set up the run, move tickets through their stages, ask questions without stopping, flag stuck work, and finish. Press **Install skill** in the Progress panel. It links `paseo-progress` into `~/.agents/skills`, `~/.claude/skills`, and `~/.codex/skills` (the folders Paseo installs its own skills into), pointing at the copy of the plugin Paseo runs, so plugin updates reach agents. If Paseo later runs the plugin from somewhere else, the button changes to **Update skill**. It never replaces a skill it didn't link; that folder is skipped.
75
+ The plugin ships an agent skill, `paseo-progress`, that tells agents when to run each command during a multi-ticket job: set up the run, move tickets through their stages, ask questions without stopping, flag stuck work, and finish. Press **Install skill** in the Progress panel. It links `paseo-progress` into `~/.agents/skills`, `~/.claude/skills`, and `~/.codex/skills` (the folders Paseo installs its own skills into), pointing at the copy of the plugin Paseo runs, so plugin updates reach agents. On Windows the links are junctions, which don't need admin rights or Developer Mode. If Paseo later runs the plugin from somewhere else, the button changes to **Update skill**. It never replaces a skill it didn't link; that folder is skipped.
67
76
 
68
77
  From a terminal instead:
69
78
 
@@ -125,6 +134,14 @@ The progress file, `progress.jsonl`, holds one JSON event per line, each with a
125
134
 
126
135
  ## Changelog
127
136
 
137
+ ### 1.3.1
138
+
139
+ - Progress works on Windows (#84). Before, `paseo-progress` stopped with "The progress plugin is not installed in Paseo", and **Install skill** couldn't find the plugin. On Windows, Paseo's command is `paseo.cmd`, and Node can start a `.cmd` file only through cmd. Both now go through cmd on Windows, and both also check where Paseo installs its command.
140
+ - On Windows, **Install command** also adds `paseo-progress.cmd`, so agents in PowerShell or cmd can run `paseo-progress`, not just those in Git Bash.
141
+ - On Windows, **Install skill** makes junctions instead of symlinks, so it works without admin rights or Developer Mode.
142
+ - The banner no longer says the command's folder is missing from `PATH` on Windows when it's there.
143
+ - If you worked around this by hand, delete your own `paseo-progress.cmd`, then press **Update command**. Also remove `PASEO_PROGRESS_PLUGIN_DIR`, or the command keeps running the folder it names after the plugin updates. Skill links you made yourself keep working, and the panel offers **Update skill** once the plugin folder changes.
144
+
128
145
  ### 1.3.0
129
146
 
130
147
  - The file preview has a **Copy** button beside the path. It copies the full path, so you can paste it into your message to the agent without closing the preview. Before, the path was cut off when it was long, and you had to close the preview and copy it from the list.
@@ -22,7 +22,7 @@ export function skillQueryKey(hostId: string) {
22
22
  export function launcherNotice(status: LauncherStatus): { text: string; action?: string } | null {
23
23
  switch (status.state) {
24
24
  case "missing":
25
- return { text: `Agents record progress with the paseo-progress command, which isn't installed yet. Installing adds one file: ${status.path}`, action: "Install command" };
25
+ return { text: `Agents record progress with the paseo-progress command, which isn't installed yet. Installing adds it at ${status.path}`, action: "Install command" };
26
26
  case "outdated":
27
27
  return { text: `An older paseo-progress command is installed at ${status.path}.`, action: "Update command" };
28
28
  case "foreign":
@@ -34,7 +34,7 @@ export function launcherNotice(status: LauncherStatus): { text: string; action?:
34
34
 
35
35
  // What the skill banner says, or null when every agent folder has the skill.
36
36
  export function skillNotice(status: SkillStatus): { text: string; action?: string } | null {
37
- const folders = (state: string) => status.targets.filter((target) => target.state === state).map((target) => target.path.replace(/\/paseo-progress$/, ""));
37
+ const folders = (state: string) => status.targets.filter((target) => target.state === state).map((target) => target.path.replace(/[\\/]paseo-progress$/, ""));
38
38
  const skipped = folders("foreign").length ? ` Something else is already at ${folders("foreign").join(", ")}, so the plugin leaves it alone.` : "";
39
39
  switch (status.state) {
40
40
  case "missing":
@@ -2007,8 +2007,8 @@ function emoji() {
2007
2007
  }
2008
2008
  var ipv4 = /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/;
2009
2009
  var ipv6 = /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:))$/;
2010
- var mac = (delimiter2) => {
2011
- const escapedDelim = escapeRegex(delimiter2 ?? ":");
2010
+ var mac = (delimiter) => {
2011
+ const escapedDelim = escapeRegex(delimiter ?? ":");
2012
2012
  return new RegExp(`^(?:[0-9A-F]{2}${escapedDelim}){5}[0-9A-F]{2}$|^(?:[0-9a-f]{2}${escapedDelim}){5}[0-9a-f]{2}$`);
2013
2013
  };
2014
2014
  var cidrv4 = /^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/([0-9]|[1-2][0-9]|3[0-2])$/;
@@ -20250,7 +20250,7 @@ function findRoot(cwd) {
20250
20250
 
20251
20251
  // server/launcher.ts
20252
20252
  import { chmod, mkdir as mkdir3, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
20253
- import { delimiter, dirname as dirname2, join as join3 } from "node:path";
20253
+ import { basename, dirname as dirname2, join as join3, posix, win32 } from "node:path";
20254
20254
  var PLUGIN_ID = "progress";
20255
20255
  function launcherScript() {
20256
20256
  return `#!/usr/bin/env node
@@ -20266,11 +20266,28 @@ const { spawnSync } = process.getBuiltinModule("node:child_process");
20266
20266
  const { existsSync } = process.getBuiltinModule("node:fs");
20267
20267
  const { join } = process.getBuiltinModule("node:path");
20268
20268
 
20269
+ const windows = process.platform === "win32";
20270
+
20271
+ function paseoCommands() {
20272
+ const env = process.env;
20273
+ const installed = windows
20274
+ ? env.LOCALAPPDATA && join(env.LOCALAPPDATA, "Programs", "Paseo", "resources", "bin", "paseo.cmd")
20275
+ : "/Applications/Paseo.app/Contents/Resources/bin/paseo";
20276
+ return [env.PASEO_BIN, env.PASEO_CLI, "paseo", installed].filter(Boolean);
20277
+ }
20278
+
20279
+ // On Windows Paseo's command is paseo.cmd, which Node can start, or find on
20280
+ // PATH, only through cmd.exe.
20281
+ function askPaseo(cli) {
20282
+ return windows
20283
+ ? spawnSync('"' + cli + '" plugin ls ${PLUGIN_ID} --json', { shell: true, encoding: "utf8" })
20284
+ : spawnSync(cli, ["plugin", "ls", "${PLUGIN_ID}", "--json"], { encoding: "utf8" });
20285
+ }
20286
+
20269
20287
  function pluginDirectory() {
20270
20288
  if (process.env.PASEO_PROGRESS_PLUGIN_DIR) return process.env.PASEO_PROGRESS_PLUGIN_DIR;
20271
- const clis = [process.env.PASEO_BIN, "paseo", "/Applications/Paseo.app/Contents/Resources/bin/paseo"].filter(Boolean);
20272
- for (const cli of clis) {
20273
- const result = spawnSync(cli, ["plugin", "ls", "${PLUGIN_ID}", "--json"], { encoding: "utf8" });
20289
+ for (const cli of paseoCommands()) {
20290
+ const result = askPaseo(cli);
20274
20291
  if (result.error || result.status !== 0) continue;
20275
20292
  try {
20276
20293
  const plugin = JSON.parse(result.stdout).find((entry) => entry.id === "${PLUGIN_ID}");
@@ -20295,20 +20312,41 @@ process.exit(run.status === null ? 1 : run.status);
20295
20312
  `;
20296
20313
  }
20297
20314
  var MARKER = `Written by the ${PLUGIN_ID} Paseo plugin`;
20298
- async function launcherState(path) {
20299
- const current = await readFile2(path, "utf8").catch(() => null);
20315
+ function launcherFiles(path, platform) {
20316
+ const files = [{ path, content: launcherScript() }];
20317
+ if (platform === "win32") {
20318
+ files.push({ path: `${path}.cmd`, content: `@echo off\r
20319
+ rem ${MARKER}. Lets PowerShell and cmd run ${basename(path)}.\r
20320
+ node "%~dp0${basename(path)}" %*\r
20321
+ exit /b %errorlevel%\r
20322
+ ` });
20323
+ }
20324
+ return files;
20325
+ }
20326
+ async function fileState(file2) {
20327
+ const current = await readFile2(file2.path, "utf8").catch(() => null);
20300
20328
  if (current === null) return "missing";
20301
- if (current === launcherScript()) return "current";
20329
+ if (current === file2.content) return "current";
20302
20330
  return current.includes(MARKER) ? "outdated" : "foreign";
20303
20331
  }
20304
- async function installLauncher(path) {
20305
- const state = await launcherState(path);
20306
- if (state === "current") return false;
20307
- if (state === "foreign") throw new Error(`${path} already exists and was not written by this plugin. Move it, or pass another path.`);
20308
- await mkdir3(dirname2(path), { recursive: true });
20309
- await writeFile2(path, launcherScript());
20310
- await chmod(path, 493);
20311
- return true;
20332
+ async function fileStates(path, platform) {
20333
+ const files = launcherFiles(path, platform);
20334
+ const states = await Promise.all(files.map(fileState));
20335
+ return files.map((file2, index) => ({ ...file2, state: states[index] }));
20336
+ }
20337
+ async function installLauncher(path, platform = process.platform) {
20338
+ const files = await fileStates(path, platform);
20339
+ const foreign = files.find((file2) => file2.state === "foreign");
20340
+ if (foreign) throw new Error(`${foreign.path} already exists and was not written by this plugin. Move it, or pass another path.`);
20341
+ let wrote = false;
20342
+ for (const file2 of files) {
20343
+ if (file2.state === "current") continue;
20344
+ await mkdir3(dirname2(file2.path), { recursive: true });
20345
+ await writeFile2(file2.path, file2.content);
20346
+ await chmod(file2.path, 493);
20347
+ wrote = true;
20348
+ }
20349
+ return wrote;
20312
20350
  }
20313
20351
 
20314
20352
  // server/store.ts
@@ -20317,7 +20355,7 @@ import { createHash } from "node:crypto";
20317
20355
  import { realpathSync } from "node:fs";
20318
20356
  import { appendFile as appendFile2, mkdir as mkdir4, readdir, readFile as readFile3, rm as rm2, rmdir, stat as stat3, writeFile as writeFile3 } from "node:fs/promises";
20319
20357
  import { homedir } from "node:os";
20320
- import { basename, join as join4, normalize, resolve as resolve2 } from "node:path";
20358
+ import { basename as basename2, join as join4, normalize, resolve as resolve2 } from "node:path";
20321
20359
  import { promisify } from "node:util";
20322
20360
  var LOCK_NAME2 = `${PROGRESS_FILE_NAME}.lock`;
20323
20361
  var SAVE_RUNS_KEY = "paseo-progress.saveRuns";
@@ -20346,7 +20384,7 @@ function outsideDirectory(root, home = homedir()) {
20346
20384
  } catch {
20347
20385
  }
20348
20386
  const hash2 = createHash("sha1").update(real).digest("hex").slice(0, 8);
20349
- return join4(home, ".local", "state", "paseo-progress", `${basename(real)}-${hash2}`);
20387
+ return join4(home, ".local", "state", "paseo-progress", `${basename2(real)}-${hash2}`);
20350
20388
  }
20351
20389
  var trackedLegacy = /* @__PURE__ */ new Map();
20352
20390
  async function legacyTracked(root, mtimeMs) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stevecastaneda/paseo-progress",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "description": "A live progress dashboard per worktree for Paseo, drawn from a file agents write with the paseo-progress command.",
5
5
  "license": "MIT",
6
6
  "author": "Steve Castaneda",
@@ -44,6 +44,7 @@
44
44
  "test": "node --test \"*/*.test.ts\"",
45
45
  "dev": "node ../scripts/dev.mjs",
46
46
  "check:install": "node ../scripts/check-install.mjs",
47
+ "check:windows": "node scripts/windows-check.mjs",
47
48
  "build:cli": "rm -rf dist && esbuild server/cli.ts --bundle --platform=node --format=esm --target=node22 --outfile=dist/server/cli.js --log-level=warning",
48
49
  "prepack": "npm run build:cli",
49
50
  "postpack": "rm -rf dist"
@@ -1,9 +1,19 @@
1
1
  import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
- import { delimiter, dirname, join } from "node:path";
3
+ import { basename, dirname, join, posix, win32 } from "node:path";
4
4
 
5
5
  export const PLUGIN_ID = "progress";
6
6
 
7
+ // Where Paseo's command may be, in the order tried: an override, the one
8
+ // Paseo names for the agents and plugins it starts, PATH, then the app's
9
+ // default install. The launcher script below carries the same list.
10
+ export function paseoCommands(env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform): string[] {
11
+ const installed = platform === "win32"
12
+ ? env.LOCALAPPDATA && win32.join(env.LOCALAPPDATA, "Programs", "Paseo", "resources", "bin", "paseo.cmd")
13
+ : "/Applications/Paseo.app/Contents/Resources/bin/paseo";
14
+ return [env.PASEO_BIN, env.PASEO_CLI, "paseo", installed].filter((cli): cli is string => Boolean(cli));
15
+ }
16
+
7
17
  // The launcher never names a plugin folder. Each run asks Paseo which copy of
8
18
  // the plugin it is running, so updates and `npm run dev` switches keep working.
9
19
  export function launcherScript(): string {
@@ -20,11 +30,28 @@ const { spawnSync } = process.getBuiltinModule("node:child_process");
20
30
  const { existsSync } = process.getBuiltinModule("node:fs");
21
31
  const { join } = process.getBuiltinModule("node:path");
22
32
 
33
+ const windows = process.platform === "win32";
34
+
35
+ function paseoCommands() {
36
+ const env = process.env;
37
+ const installed = windows
38
+ ? env.LOCALAPPDATA && join(env.LOCALAPPDATA, "Programs", "Paseo", "resources", "bin", "paseo.cmd")
39
+ : "/Applications/Paseo.app/Contents/Resources/bin/paseo";
40
+ return [env.PASEO_BIN, env.PASEO_CLI, "paseo", installed].filter(Boolean);
41
+ }
42
+
43
+ // On Windows Paseo's command is paseo.cmd, which Node can start, or find on
44
+ // PATH, only through cmd.exe.
45
+ function askPaseo(cli) {
46
+ return windows
47
+ ? spawnSync('"' + cli + '" plugin ls ${PLUGIN_ID} --json', { shell: true, encoding: "utf8" })
48
+ : spawnSync(cli, ["plugin", "ls", "${PLUGIN_ID}", "--json"], { encoding: "utf8" });
49
+ }
50
+
23
51
  function pluginDirectory() {
24
52
  if (process.env.PASEO_PROGRESS_PLUGIN_DIR) return process.env.PASEO_PROGRESS_PLUGIN_DIR;
25
- const clis = [process.env.PASEO_BIN, "paseo", "/Applications/Paseo.app/Contents/Resources/bin/paseo"].filter(Boolean);
26
- for (const cli of clis) {
27
- const result = spawnSync(cli, ["plugin", "ls", "${PLUGIN_ID}", "--json"], { encoding: "utf8" });
53
+ for (const cli of paseoCommands()) {
54
+ const result = askPaseo(cli);
28
55
  if (result.error || result.status !== 0) continue;
29
56
  try {
30
57
  const plugin = JSON.parse(result.stdout).find((entry) => entry.id === "${PLUGIN_ID}");
@@ -57,31 +84,64 @@ export function defaultLauncherPath(home = homedir()): string {
57
84
 
58
85
  export type LauncherState = "missing" | "current" | "outdated" | "foreign";
59
86
 
60
- export async function launcherState(path: string): Promise<LauncherState> {
61
- const current = await readFile(path, "utf8").catch(() => null);
87
+ type LauncherFile = { path: string; content: string };
88
+
89
+ // PowerShell and cmd run only files with an extension like .cmd, so on
90
+ // Windows a .cmd file beside the launcher runs it with node.
91
+ function launcherFiles(path: string, platform: NodeJS.Platform): LauncherFile[] {
92
+ const files = [{ path, content: launcherScript() }];
93
+ if (platform === "win32") {
94
+ files.push({ path: `${path}.cmd`, content: `@echo off\r\nrem ${MARKER}. Lets PowerShell and cmd run ${basename(path)}.\r\nnode "%~dp0${basename(path)}" %*\r\nexit /b %errorlevel%\r\n` });
95
+ }
96
+ return files;
97
+ }
98
+
99
+ async function fileState(file: LauncherFile): Promise<LauncherState> {
100
+ const current = await readFile(file.path, "utf8").catch(() => null);
62
101
  if (current === null) return "missing";
63
- if (current === launcherScript()) return "current";
102
+ if (current === file.content) return "current";
64
103
  return current.includes(MARKER) ? "outdated" : "foreign";
65
104
  }
66
105
 
67
- // Writes the launcher when it is missing or out of date. Never replaces a file
68
- // something else put there. Returns whether it wrote.
69
- export async function installLauncher(path: string): Promise<boolean> {
70
- const state = await launcherState(path);
71
- if (state === "current") return false;
72
- if (state === "foreign") throw new Error(`${path} already exists and was not written by this plugin. Move it, or pass another path.`);
73
- await mkdir(dirname(path), { recursive: true });
74
- await writeFile(path, launcherScript());
75
- await chmod(path, 0o755);
76
- return true;
106
+ async function fileStates(path: string, platform: NodeJS.Platform) {
107
+ const files = launcherFiles(path, platform);
108
+ const states = await Promise.all(files.map(fileState));
109
+ return files.map((file, index) => ({ ...file, state: states[index] }));
110
+ }
111
+
112
+ // Writes the launcher files that are missing or out of date. Never replaces a
113
+ // file something else put there. Returns whether it wrote.
114
+ export async function installLauncher(path: string, platform: NodeJS.Platform = process.platform): Promise<boolean> {
115
+ const files = await fileStates(path, platform);
116
+ const foreign = files.find((file) => file.state === "foreign");
117
+ if (foreign) throw new Error(`${foreign.path} already exists and was not written by this plugin. Move it, or pass another path.`);
118
+ let wrote = false;
119
+ for (const file of files) {
120
+ if (file.state === "current") continue;
121
+ await mkdir(dirname(file.path), { recursive: true });
122
+ await writeFile(file.path, file.content);
123
+ await chmod(file.path, 0o755);
124
+ wrote = true;
125
+ }
126
+ return wrote;
77
127
  }
78
128
 
79
129
  // For the panel: whether agents on the daemon host can run `paseo-progress`.
80
- export async function launcherStatus(path = defaultLauncherPath(), pathEnv = process.env.PATH ?? "") {
130
+ // When something else is in the way, path names that file.
131
+ export async function launcherStatus(path = defaultLauncherPath(), pathEnv = process.env.PATH ?? "", platform: NodeJS.Platform = process.platform) {
132
+ const files = await fileStates(path, platform);
133
+ const states = files.map((file) => file.state);
134
+ const foreign = files.find((file) => file.state === "foreign");
135
+ const state: LauncherState = foreign ? "foreign"
136
+ : states.every((each) => each === "current") ? "current"
137
+ : states.every((each) => each === "missing") ? "missing" : "outdated";
138
+ // Windows paths ignore case and may end in either slash.
139
+ const paths = platform === "win32" ? win32 : posix;
140
+ const folder = (dir: string) => platform === "win32" ? dir.replace(/[\\/]+$/, "").toLowerCase() : dir.replace(/\/+$/, "");
81
141
  return {
82
- path,
83
- state: await launcherState(path),
84
- onPath: pathEnv.split(delimiter).some((entry) => entry.replace(/\/+$/, "") === dirname(path)),
142
+ path: foreign?.path ?? path,
143
+ state,
144
+ onPath: pathEnv.split(paths.delimiter).some((entry) => folder(entry) === folder(paths.dirname(path))),
85
145
  };
86
146
  }
87
147
 
package/server/skill.ts CHANGED
@@ -1,9 +1,9 @@
1
- import { execFile } from "node:child_process";
1
+ import { exec, execFile } from "node:child_process";
2
2
  import { lstat, mkdir, readlink, symlink, unlink } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import { basename, dirname, join, resolve } from "node:path";
5
5
  import { promisify } from "node:util";
6
- import { PLUGIN_ID } from "./launcher.ts";
6
+ import { paseoCommands, PLUGIN_ID } from "./launcher.ts";
7
7
 
8
8
  export const SKILL_NAME = "paseo-progress";
9
9
 
@@ -41,7 +41,9 @@ export async function installSkill(paths: string[], pluginDirectory: string): Pr
41
41
  if (states[index] !== "missing" && states[index] !== "outdated") continue;
42
42
  if (states[index] === "outdated") await unlink(path);
43
43
  await mkdir(dirname(path), { recursive: true });
44
- await symlink(skillSource(pluginDirectory), path, "dir");
44
+ // A junction on Windows, where a directory symlink needs admin rights or
45
+ // Developer Mode. Other systems ignore the type and make a symlink.
46
+ await symlink(skillSource(pluginDirectory), path, "junction");
45
47
  wrote = true;
46
48
  }
47
49
  return wrote;
@@ -60,10 +62,9 @@ export async function skillStatus(paths: string[], pluginDirectory: string) {
60
62
  // The server bundle doesn't know its own folder, so ask Paseo, the same way
61
63
  // the launcher does.
62
64
  export async function findPluginDirectory(): Promise<string> {
63
- const clis = [process.env.PASEO_BIN, "paseo", "/Applications/Paseo.app/Contents/Resources/bin/paseo"].filter((cli): cli is string => Boolean(cli));
64
- for (const cli of clis) {
65
+ for (const cli of paseoCommands()) {
65
66
  try {
66
- const { stdout } = await promisify(execFile)(cli, ["plugin", "ls", PLUGIN_ID, "--json"]);
67
+ const { stdout } = await askPaseo(cli);
67
68
  const plugin = (JSON.parse(stdout) as Array<{ id: string; path?: string }>).find((entry) => entry.id === PLUGIN_ID);
68
69
  if (plugin?.path) return plugin.path;
69
70
  } catch {
@@ -73,6 +74,14 @@ export async function findPluginDirectory(): Promise<string> {
73
74
  throw new Error("Could not ask Paseo where this plugin is installed.");
74
75
  }
75
76
 
77
+ // On Windows Paseo's command is paseo.cmd, which Node can start, or find on
78
+ // PATH, only through cmd.exe.
79
+ function askPaseo(cli: string) {
80
+ return process.platform === "win32"
81
+ ? promisify(exec)(`"${cli}" plugin ls ${PLUGIN_ID} --json`)
82
+ : promisify(execFile)(cli, ["plugin", "ls", PLUGIN_ID, "--json"]);
83
+ }
84
+
76
85
  export async function handleSkillStatus() {
77
86
  return skillStatus(defaultSkillPaths(), await findPluginDirectory());
78
87
  }