pi-lazy-setup 3.0.0 → 3.0.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.
@@ -11,6 +11,11 @@
11
11
  // skills → `npx --yes skills add <repo> --skill <name> -g --agent pi -y`
12
12
  // clis → officecli: skill file from https://officecli.ai/SKILL.md plus
13
13
  // binary via `curl -fsSL https://d.officecli.ai/install.sh | bash`
14
+ // (Windows: `irm https://d.officecli.ai/install.ps1 | iex`)
15
+ //
16
+ // Windows note: pi.exec spawns without a shell, so npm's .cmd shims (pi,
17
+ // npx) cannot be launched directly — every command is routed through
18
+ // `cmd.exe /c`, the same workaround rpiv-pi's /rpiv-setup uses.
14
19
 
15
20
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
16
21
  import { homedir } from "node:os";
@@ -27,8 +32,18 @@ function globalSkillsDir() {
27
32
 
28
33
  const INSTALL_TIMEOUT_MS = 300_000;
29
34
  const BIN_CHECK_TIMEOUT_MS = 15_000;
35
+ const DOWNLOAD_TIMEOUT_MS = 120_000;
30
36
  const STDERR_SNIPPET_CHARS = 300;
31
37
 
38
+ // Read at call time (not module load) so tests can simulate win32.
39
+ function isWindows() {
40
+ return process.platform === "win32";
41
+ }
42
+
43
+ // Reference to the running ExtensionAPI, captured by the factory and used by
44
+ // the curl fallback in downloadSkillFile.
45
+ let piRef = null;
46
+
32
47
  /** Pi extension packages offered via `pi install <source>`. */
33
48
  const PACKAGES = [
34
49
  { source: "npm:pi-footer", description: "Footer status line (token usage, model, cost)." },
@@ -58,11 +73,16 @@ const CLIS = [
58
73
  bin: "officecli",
59
74
  description: "AI-friendly CLI for Office documents (.docx/.xlsx/.pptx).",
60
75
  skillFile: { name: "officecli", url: "https://officecli.ai/SKILL.md" },
76
+ // macOS/Linux — official install script
61
77
  installCommand: "curl -fsSL https://d.officecli.ai/install.sh | bash",
78
+ // Windows — official PowerShell install (officecli SKILL.md documents both)
79
+ installCommandWindows: "irm https://d.officecli.ai/install.ps1 | iex",
62
80
  },
63
81
  ];
64
82
 
65
83
  export default function piSetupExtension(pi) {
84
+ // captured for the curl fallback in downloadSkillFile
85
+ piRef = pi;
66
86
  pi.registerCommand("pi-setup", {
67
87
  description: "Install packages, skills, and CLI tools (each confirmed individually)",
68
88
  handler: async (_args, ctx) => {
@@ -150,28 +170,55 @@ async function installClis(pi, ctx, report) {
150
170
  if (await binaryAvailable(pi, c.bin)) {
151
171
  report.skipped.push(`cli:${c.bin}`);
152
172
  } else {
153
- const confirmed = await ctx.ui.confirm("Install CLI tool?", `${c.id} — ${c.description}\n${c.installCommand}`);
173
+ const installCmd = isWindows() ? (c.installCommandWindows ?? c.installCommand) : c.installCommand;
174
+ const confirmed = await ctx.ui.confirm("Install CLI tool?", `${c.id} — ${c.description}\n${installCmd}`);
154
175
  if (confirmed) {
155
176
  ctx.ui.notify(`Installing ${c.id}…`, "info");
156
- const result = await runExec(pi, "bash", ["-c", c.installCommand]);
177
+ // macOS/Linux: run the piped install script via bash -c.
178
+ // Windows: powershell.exe is a real binary — spawn it DIRECTLY
179
+ // (runExecDirect), because routing "… | …" through cmd.exe /c
180
+ // would let cmd parse the pipe itself.
181
+ const result = isWindows()
182
+ ? await runExecDirect(pi, "powershell", ["-NoProfile", "-Command", installCmd])
183
+ : await runExec(pi, "bash", ["-c", installCmd]);
157
184
  settle(result, `cli:${c.bin}`, report);
158
185
  }
159
186
  }
160
187
  }
161
188
  }
162
189
 
163
- /** Run a command via pi.exec and normalize into { ok, error }. */
190
+ /**
191
+ * Run a command via pi.exec and normalize into { ok, error }.
192
+ *
193
+ * On Windows, pi.exec's shell-less spawn cannot launch npm's .cmd shims
194
+ * (`pi`, `npx`) — the classic ENOENT trap (see rpiv-pi's pi-installer.ts) —
195
+ * so commands are routed through `cmd.exe /c`. Use runExecDirect for real
196
+ * .exe binaries whose arguments must NOT be re-parsed by cmd.exe
197
+ * (e.g. powershell -Command "… | …").
198
+ */
164
199
  async function runExec(pi, command, args, timeoutMs = INSTALL_TIMEOUT_MS) {
200
+ const { cmd, cmdArgs } = resolveCommand(command, args);
201
+ return runExecDirect(pi, cmd, cmdArgs, timeoutMs);
202
+ }
203
+
204
+ /** Run an exact command (no Windows cmd.exe re-parsing) and normalize into { ok, error }. */
205
+ async function runExecDirect(pi, cmd, cmdArgs, timeoutMs = INSTALL_TIMEOUT_MS) {
165
206
  try {
166
- const result = await pi.exec(command, args, { timeout: timeoutMs });
207
+ const result = await pi.exec(cmd, cmdArgs, { timeout: timeoutMs });
167
208
  if (result.code === 0) return { ok: true };
168
209
  const error = (result.stderr || result.stdout || `exit ${result.code}`).trim().slice(0, STDERR_SNIPPET_CHARS);
169
- return { ok: false, error };
210
+ return { ok: false, error: `${cmd} ${cmdArgs.join(" ")}: ${error}` };
170
211
  } catch (err) {
171
212
  return { ok: false, error: toMessage(err) };
172
213
  }
173
214
  }
174
215
 
216
+ /** Windows: route through cmd.exe (spawns .cmd shims, provides shell). */
217
+ function resolveCommand(command, args) {
218
+ if (!isWindows()) return { cmd: command, cmdArgs: args };
219
+ return { cmd: "cmd.exe", cmdArgs: ["/c", command, ...args] };
220
+ }
221
+
175
222
  function settle(result, item, report) {
176
223
  if (result.ok) report.installed.push(item);
177
224
  else report.failed.push({ item, error: result.error });
@@ -196,19 +243,59 @@ async function binaryAvailable(pi, name) {
196
243
  const result = await runExec(pi, name, ["--version"], BIN_CHECK_TIMEOUT_MS);
197
244
  return result.ok;
198
245
  }
199
-
200
- /** Download a SKILL.md into ~/.agents/skills/<name>/SKILL.md. */
246
+ /**
247
+ * Download a SKILL.md into ~/.agents/skills/<name>/SKILL.md.
248
+ *
249
+ * Tries global fetch first; on failure falls back to curl so environments
250
+ * where fetch fails (no direct network, system proxy that fetch ignores) can
251
+ * still reach the URL — curl honors http_proxy/https_proxy env vars.
252
+ */
201
253
  async function downloadSkillFile(name, url) {
202
- const res = await fetch(url);
203
- if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${url}`);
204
- const text = await res.text();
254
+ const fetchError = await tryFetchSkillFile(name, url);
255
+ if (fetchError === null) return;
256
+ // curl fallback
257
+ const curlArgs = ["-fsSL", "--max-time", "120", url];
258
+ const { cmd, cmdArgs } = resolveCommand("curl", curlArgs);
259
+ const result = await piExecDownload(cmd, cmdArgs);
260
+ if (result === null) throw new Error(`${toMessage(fetchError)} (curl fallback also failed)`);
205
261
  const dir = join(globalSkillsDir(), name);
206
262
  mkdirSync(dir, { recursive: true });
207
- writeFileSync(join(dir, "SKILL.md"), text);
263
+ writeFileSync(join(dir, "SKILL.md"), result);
264
+ }
265
+
266
+ /** Fetch + write the SKILL.md. Returns null on success, the error otherwise. */
267
+ async function tryFetchSkillFile(name, url) {
268
+ try {
269
+ const res = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
270
+ if (!res.ok) return new Error(`HTTP ${res.status} fetching ${url}`);
271
+ const text = await res.text();
272
+ const dir = join(globalSkillsDir(), name);
273
+ mkdirSync(dir, { recursive: true });
274
+ writeFileSync(join(dir, "SKILL.md"), text);
275
+ return null;
276
+ } catch (err) {
277
+ return err;
278
+ }
279
+ }
280
+
281
+ /** Fetch a URL body via curl through pi.exec. Returns null on failure. */
282
+ async function piExecDownload(cmd, cmdArgs) {
283
+ if (!piRef) return null;
284
+ try {
285
+ const result = await piRef.exec(cmd, cmdArgs, { timeout: DOWNLOAD_TIMEOUT_MS });
286
+ if (result.code !== 0) return null;
287
+ return result.stdout;
288
+ } catch {
289
+ return null;
290
+ }
208
291
  }
209
292
 
210
293
  function toMessage(err) {
211
- return String(err?.message ?? err);
294
+ const msg = String(err?.message ?? err);
295
+ // "fetch failed" alone is useless — surface the underlying cause
296
+ // (DNS, TLS, connect timeout, …) when Node attached one.
297
+ const cause = err?.cause?.code ?? err?.cause?.message;
298
+ return cause ? `${msg} (${cause})` : msg;
212
299
  }
213
300
 
214
301
  function buildReport(report) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-lazy-setup",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "description": "Initialize the Pi agent environment from a curated snapshot",
5
5
  "type": "module",
6
6
  "bin": {