@groeponline/pi-wishcraft 1.0.5 → 1.1.0

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/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.1.0] - 2026-08-23
6
+
7
+ ### Security
8
+ - Skills manager refuses to delete skill files or directories that resolve (through symlinks) outside the canonical skill roots.
9
+ - Skills editor command validates `EDITOR` before use, falling back to `nvim` for unsafe values.
10
+ - Open-ports overlay reuses the shared `sshCommand` helper and fails explicitly on an invalid host instead of falling back to a local probe.
11
+
5
12
  ## [1.0.5] - 2026-08-23
6
13
 
7
14
  ## [1.0.4] - 2026-08-23
package/README.md CHANGED
@@ -1,5 +1,12 @@
1
1
  <p>
2
- <img src="banner.png" alt="pi-wishcraft" width="1100">
2
+ <img src="https://raw.githubusercontent.com/GroepOnline/pi-wishcraft/main/banner.png" alt="pi-wishcraft" width="1100">
3
+ </p>
4
+
5
+ <p>
6
+ <a href="https://www.npmjs.com/package/@groeponline/pi-wishcraft"><img src="https://img.shields.io/npm/v/@groeponline/pi-wishcraft.svg" alt="npm version"></a>
7
+ <a href="https://www.npmjs.com/package/@groeponline/pi-wishcraft"><img src="https://img.shields.io/npm/dm/@groeponline/pi-wishcraft.svg?label=downloads" alt="npm downloads"></a>
8
+ <a href="https://pi.dev/packages/@groeponline/pi-wishcraft"><img src="https://img.shields.io/badge/Pi-package-9b59b6.svg" alt="Pi package"></a>
9
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-555.svg" alt="MIT license"></a>
3
10
  </p>
4
11
 
5
12
  # pi-wishcraft
@@ -26,7 +33,7 @@ Ephemeral VMs / CI:
26
33
  curl -fsSL https://raw.githubusercontent.com/GroepOnline/pi-wishcraft/main/scripts/install.sh | bash
27
34
  ```
28
35
 
29
- Then restart pi or `/reload`. Peer range is `@earendil-works/pi-coding-agent` `>=0.81.0 <0.85.0`.
36
+ Then restart pi or `/reload`. Pi host packages are declared as `peerDependencies: "*"`, matching the Pi package contract.
30
37
 
31
38
  ## What you get
32
39
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@groeponline/pi-wishcraft",
3
- "version": "1.0.5",
4
- "description": "Wishcraft cockpit for the pi coding agent — powerline status, vibes, idea inbox, bash mode, and full customization.",
3
+ "version": "1.1.0",
4
+ "description": "Operator cockpit for Pi: live powerline status, searchable skills, idea queue, sticky Bash, hooks, policy controls, and session UX.",
5
5
  "type": "module",
6
6
  "files": [
7
7
  "*.ts",
@@ -25,7 +25,12 @@
25
25
  "powerline",
26
26
  "status-bar",
27
27
  "extension",
28
- "groeponline"
28
+ "groeponline",
29
+ "terminal-ui",
30
+ "developer-tools",
31
+ "agent-ui",
32
+ "productivity",
33
+ "hooks"
29
34
  ],
30
35
  "publishConfig": {
31
36
  "access": "public",
@@ -45,14 +50,15 @@
45
50
  "typecheck": "tsc --noEmit",
46
51
  "test": "node --experimental-strip-types --test tests/**/*.test.ts",
47
52
  "release": "node scripts/release.mjs",
48
- "prepublishOnly": "tsc --noEmit && node scripts/verify-package.mjs",
49
- "verify:package": "node scripts/verify-package.mjs",
50
- "circular": "madge --circular src index.ts bash-mode queue"
53
+ "prepublishOnly": "tsc --noEmit && node scripts/verify-package.mjs && npm run verify:pi-package",
54
+ "verify:package": "node scripts/verify-package.mjs && node scripts/verify-pi-package-contract.mjs",
55
+ "circular": "madge --circular src index.ts bash-mode queue",
56
+ "verify:pi-package": "node scripts/verify-pi-package-contract.mjs"
51
57
  },
52
58
  "peerDependencies": {
53
- "@earendil-works/pi-ai": ">=0.81.0 <0.85.0",
54
- "@earendil-works/pi-coding-agent": ">=0.81.0 <0.85.0",
55
- "@earendil-works/pi-tui": ">=0.81.0 <0.85.0"
59
+ "@earendil-works/pi-ai": "*",
60
+ "@earendil-works/pi-coding-agent": "*",
61
+ "@earendil-works/pi-tui": "*"
56
62
  },
57
63
  "devDependencies": {
58
64
  "@earendil-works/pi-ai": "^0.84.0",
@@ -13,8 +13,10 @@
13
13
 
14
14
  import type { Theme } from "@earendil-works/pi-coding-agent";
15
15
  import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
16
- import { rmSync } from "node:fs";
16
+ import { realpathSync, rmSync } from "node:fs";
17
+ import { join, relative } from "node:path";
17
18
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
+ import { getAgentDir } from "../../paths/agent-dirs.ts";
18
20
  import type { RuntimeState } from "../core/types.ts";
19
21
  import {
20
22
  applySkillFilter,
@@ -84,9 +86,49 @@ function shellQuote(value: string): string {
84
86
  return `'${value.replace(/'/g, `'"'"'`)}'`;
85
87
  }
86
88
 
89
+ /**
90
+ * Resolve `target` through symlinks and confirm it stays within one of the
91
+ * canonical skill roots (agent skills dir, `<cwd>/.pi/skills`, `<cwd>/skills`).
92
+ * Guards the recursive delete against catalog paths that a cloned/untrusted
93
+ * repo could point outside the expected trees. Returns false when the path
94
+ * cannot be resolved (e.g. already removed) so callers fail closed.
95
+ */
96
+ export function isContainedInSkillRoots(target: string, cwd: string): boolean {
97
+ const roots = [
98
+ join(getAgentDir(), "skills"),
99
+ join(cwd, ".pi", "skills"),
100
+ join(cwd, "skills"),
101
+ ];
102
+ let real: string;
103
+ try {
104
+ real = realpathSync(target);
105
+ } catch {
106
+ return false;
107
+ }
108
+ return roots.some((root) => {
109
+ let realRoot: string;
110
+ try {
111
+ realRoot = realpathSync(root);
112
+ } catch {
113
+ return false;
114
+ }
115
+ const rel = relative(realRoot, real);
116
+ return rel !== "" && !rel.startsWith("..") && !rel.startsWith("/");
117
+ });
118
+ }
119
+
120
+ /**
121
+ * Only allow a bare, path-like editor invocation (no arguments, no shell
122
+ * metacharacters) so a hostile `EDITOR` value cannot inject into the `!` flow.
123
+ * Falls back to `nvim` when the value is unusable.
124
+ */
125
+ export function safeEditor(): string {
126
+ const ed = process.env.EDITOR?.trim();
127
+ return ed && /^[\w./-]+$/.test(ed) ? ed : "nvim";
128
+ }
129
+
87
130
  function editorCommand(path: string): string {
88
- const ed = process.env.EDITOR?.trim() || "nvim";
89
- return `!${ed} ${shellQuote(path)}`;
131
+ return `!${safeEditor()} ${shellQuote(path)}`;
90
132
  }
91
133
 
92
134
  export async function showSkillManager(ctx: any): Promise<"new" | null> {
@@ -137,11 +179,18 @@ export async function showSkillManager(ctx: any): Promise<"new" | null> {
137
179
  const close = () => done(null);
138
180
 
139
181
  const doDelete = (entry: SkillEntry) => {
182
+ const cwd = ctx.cwd ?? process.cwd();
140
183
  try {
141
184
  if (entry.isDirectorySkill && entry.filePath.endsWith("SKILL.md") &&
142
185
  (entry.category === "global" || entry.category === "project")) {
186
+ if (!isContainedInSkillRoots(entry.baseDir, cwd)) {
187
+ throw new Error(`refusing to delete outside skill roots: ${entry.baseDir}`);
188
+ }
143
189
  rmSync(entry.baseDir, { recursive: true, force: true });
144
190
  } else {
191
+ if (!isContainedInSkillRoots(entry.filePath, cwd)) {
192
+ throw new Error(`refusing to delete outside skill roots: ${entry.filePath}`);
193
+ }
145
194
  rmSync(entry.filePath, { force: true });
146
195
  }
147
196
  ctx.ui.notify(`Skill deleted: ${entry.name}`, "info");
@@ -21,7 +21,7 @@ import {
21
21
  passAutocompleteProviderThroughPreviousEditor,
22
22
  } from "../../editor/autocomplete-chain.ts";
23
23
  import { subscribeGitUpdates } from "../../git/status.ts";
24
- import { ansi, getFgAnsiCode } from "../../theme/colors.ts";
24
+ import { ansi, colorEnabled, getFgAnsiCode } from "../../theme/colors.ts";
25
25
  import {
26
26
  restorePromptHistory,
27
27
  snapshotPromptHistory,
@@ -320,7 +320,8 @@ export function setupCustomEditor(
320
320
  return originalRender(width);
321
321
  }
322
322
 
323
- const bc = (s: string) => `${getFgAnsiCode("sep")}${s}${ansi.reset}`;
323
+ const bc = (s: string) =>
324
+ `${getFgAnsiCode("sep")}${s}${colorEnabled() ? ansi.reset : ""}`;
324
325
  const captureDraft =
325
326
  !rt.bashModeActive && isSigilIdeaDraft(editor.getExpandedText());
326
327
  const promptGlyph = rt.bashModeActive
@@ -330,8 +331,10 @@ export function setupCustomEditor(
330
331
  : ">";
331
332
  const promptColor = captureDraft
332
333
  ? getFgAnsiCode("queue")
333
- : ansi.getFgAnsi(200, 200, 200);
334
- const prompt = `${promptColor}${promptGlyph}${ansi.reset}`;
334
+ : colorEnabled()
335
+ ? ansi.getFgAnsi(200, 200, 200)
336
+ : "";
337
+ const prompt = `${promptColor}${promptGlyph}${colorEnabled() ? ansi.reset : ""}`;
335
338
  const promptPrefix = ` ${prompt} `;
336
339
  const contPrefix = " ";
337
340
  const contentWidth = Math.max(1, width - 3);
@@ -9,7 +9,7 @@ import { getPreset } from "../../config/presets.ts";
9
9
  import { mergeSegmentsWithCustomItems } from "../../config/powerline-config.ts";
10
10
  import { getSeparator } from "../../theme/separators.ts";
11
11
  import { renderSegment } from "../../segments/index.ts";
12
- import { ansi, getFgAnsiCode } from "../../theme/colors.ts";
12
+ import { ansi, colorEnabled, getFgAnsiCode } from "../../theme/colors.ts";
13
13
  import { config } from "../core/state.ts";
14
14
 
15
15
  // ═══════════════════════════════════════════════════════════════════════════
@@ -41,7 +41,8 @@ export function buildContentFromParts(
41
41
  const separatorDef = getSeparator(separatorStyle);
42
42
  const sepAnsi = getFgAnsiCode("sep");
43
43
  const sep = separatorDef.left;
44
- return " " + parts.join(` ${sepAnsi}${sep}${ansi.reset} `) + ansi.reset + " ";
44
+ const reset = colorEnabled() ? ansi.reset : "";
45
+ return " " + parts.join(` ${sepAnsi}${sep}${reset} `) + reset + " ";
45
46
  }
46
47
 
47
48
  /**
@@ -18,6 +18,7 @@ import {
18
18
  countListeningPorts,
19
19
  listOpenPortProcesses,
20
20
  sanitizeSshHost,
21
+ sshCommand,
21
22
  } from "../../segments/system.ts";
22
23
  import {
23
24
  writePowerlineCustomPresetSetting,
@@ -63,8 +64,12 @@ export async function showOpenPortsList(ctx: any): Promise<void> {
63
64
  }
64
65
  const proto = includeUdp ? "-tulnp" : "-tlnp";
65
66
  const command = host
66
- ? `ssh -o ConnectTimeout=3 -o BatchMode=yes ${host} "ss ${proto} 2>/dev/null" 2>/dev/null`
67
+ ? sshCommand(host, `ss ${proto} 2>/dev/null`)
67
68
  : `ss ${proto} 2>/dev/null`;
69
+ if (!command) {
70
+ ctx.ui.notify(`Invalid open-ports host: ${host}`, "error");
71
+ return;
72
+ }
68
73
  const stdout = execSync(command, { encoding: "utf8" });
69
74
  publishPowerlineStatuses(ctx, {
70
75
  ports: formatPortsStatusValue(countListeningPorts(includeUdp, host)),
@@ -7,7 +7,7 @@ import {
7
7
  collectHiddenExtensionStatusKeys,
8
8
  getNotificationExtensionStatuses,
9
9
  } from "../../config/powerline-config.ts";
10
- import { ansi, getFgAnsiCode } from "../../theme/colors.ts";
10
+ import { ansi, colorEnabled, getFgAnsiCode } from "../../theme/colors.ts";
11
11
 
12
12
  import { computeResponsiveLayout } from "./layout.ts";
13
13
  import { getQueueContext } from "../queue/queue-context.ts";
@@ -210,7 +210,8 @@ export function renderLastPromptLines(
210
210
  ): string[] {
211
211
  if (rt.bashModeActive || !rt.showLastPrompt || !rt.lastUserPrompt) return [];
212
212
 
213
- const prefix = ` ${getFgAnsiCode("sep")}↳${ansi.reset} `;
213
+ const reset = colorEnabled() ? ansi.reset : "";
214
+ const prefix = ` ${getFgAnsiCode("sep")}↳${reset} `;
214
215
  const availableWidth = width - visibleWidth(prefix);
215
216
  if (availableWidth < 10) return [];
216
217
 
@@ -219,7 +220,7 @@ export function renderLastPromptLines(
219
220
 
220
221
  promptText = truncateToWidth(promptText, availableWidth, "…");
221
222
 
222
- const styledPrompt = `${getFgAnsiCode("sep")}${promptText}${ansi.reset}`;
223
+ const styledPrompt = `${getFgAnsiCode("sep")}${promptText}${reset}`;
223
224
  const line = `${prefix}${styledPrompt}`;
224
225
  return [truncateToWidth(line, width, "…")];
225
226
  }
@@ -135,6 +135,9 @@ export function applySegmentDecoration(
135
135
  return { content, visible: true };
136
136
  }
137
137
 
138
+ const SEGMENT_ERROR_LOG_INTERVAL_MS = 10_000;
139
+ const lastSegmentErrorLog = new Map<StatusLineSegmentId, number>();
140
+
138
141
  export const SEGMENTS: Record<BuiltinStatusLineSegmentId, StatusLineSegment> = {
139
142
  model: modelSegment,
140
143
  shell_mode: shellModeSegment,
@@ -177,11 +180,20 @@ export function renderSegment(
177
180
  rendered = segment ? segment.render(ctx) : { content: "", visible: false };
178
181
  }
179
182
  return applySegmentDecoration(id, ctx, rendered);
180
- } catch {
181
- // ponytail: per-segment fault isolation. A throwing segment (most often a
182
- // user `command` custom segment with a failing script) must not blank the
183
- // whole footer; surface a visible `!id` marker so the operator sees
184
- // which segment broke. No per-render log: the bar repaints ~30/s.
185
- return { content: `!${id}`, visible: true };
183
+ } catch (err) {
184
+ // Per-segment fault isolation: keep the footer alive and identify the
185
+ // segment that failed. Log at most once every 10 seconds per segment.
186
+ const now = Date.now();
187
+ const lastLogged = lastSegmentErrorLog.get(id) ?? 0;
188
+ if (now - lastLogged >= SEGMENT_ERROR_LOG_INTERVAL_MS) {
189
+ lastSegmentErrorLog.set(id, now);
190
+ const detail = err instanceof Error ? (err.stack ?? err.message) : String(err);
191
+ console.warn(`[wishcraft] segment "${id}" failed: ${detail}`);
192
+ }
193
+ return applySegmentDecoration(id, ctx, {
194
+ content: `!${id}`,
195
+ visible: true,
196
+ });
197
+
186
198
  }
187
199
  }
@@ -137,7 +137,7 @@ export function sanitizeSshHost(value: string | undefined): string | null {
137
137
  * inner `2>/dev/null`; `BatchMode` fails fast (no password prompt) on hosts
138
138
  * that require interactive auth or an unknown host key.
139
139
  */
140
- function sshCommand(
140
+ export function sshCommand(
141
141
  host: string | undefined,
142
142
  remoteCmd: string,
143
143
  ): string | null {
@@ -12,6 +12,18 @@ export const ansi: AnsiColors = {
12
12
  reset: "\x1b[0m",
13
13
  };
14
14
 
15
+ // ponytail: NO_COLOR (de-facto standard — present and non-empty) disables all
16
+ // wishcraft color so the status bar stays plain text in no-color terminals
17
+ // and color-blind pipelines. Computed lazily so test env changes take effect.
18
+ let _colorEnabled: boolean | undefined;
19
+ export function colorEnabled(): boolean {
20
+ if (_colorEnabled === undefined) {
21
+ const v = process.env.NO_COLOR;
22
+ _colorEnabled = !(v != null && v !== "");
23
+ }
24
+ return _colorEnabled;
25
+ }
26
+
15
27
  function hexToRgb(hex: string): [number, number, number] {
16
28
  const cleanHex = hex.startsWith("#") ? hex.slice(1) : hex;
17
29
  if (!/^[0-9A-Fa-f]{6}$/.test(cleanHex)) {
@@ -51,10 +63,12 @@ for (const key of Object.keys(PALETTE)) {
51
63
  }
52
64
 
53
65
  export function fgOnly(color: ColorName, text: string): string {
66
+ if (!colorEnabled()) return text;
54
67
  const code = ansiCodeCache.get(color as string) ?? "";
55
68
  return code ? `${code}${text}` : text;
56
69
  }
57
70
 
58
71
  export function getFgAnsiCode(color: ColorName): string {
72
+ if (!colorEnabled()) return "";
59
73
  return ansiCodeCache.get(color as string) ?? "";
60
74
  }
@@ -1,5 +1,5 @@
1
1
  import { visibleWidth } from "@earendil-works/pi-tui";
2
- import { ansi, fgOnly, getFgAnsiCode } from "../theme/colors.ts";
2
+ import { ansi, colorEnabled, fgOnly, getFgAnsiCode } from "../theme/colors.ts";
3
3
  import { centerText, fitToWidth, getBoxLayout } from "./layout.ts";
4
4
  import type { WelcomeData, WelcomeWidget, WidgetRenderContext } from "./types.ts";
5
5
 
@@ -32,7 +32,7 @@ function bold(text: string): string {
32
32
  }
33
33
 
34
34
  export function dim(text: string): string {
35
- return getFgAnsiCode("sep") + text + ansi.reset;
35
+ return getFgAnsiCode("sep") + text + (colorEnabled() ? ansi.reset : "");
36
36
  }
37
37
 
38
38
  function gradientLine(line: string): string {
@@ -16,6 +16,9 @@ import { getAgentPath } from "../paths/agent-dirs.ts";
16
16
 
17
17
  const MAX_WHATSNEW_LINES = 8;
18
18
  const WHATS_NEW_STATE_FILE = "whats-new.json";
19
+ // First GroepOnline release. On a first run (no stored version) the welcome
20
+ // panel shows history from here forward, never the pre-1.0 `0.x` prehistory.
21
+ const FIRST_OWN_RELEASE = "1.0.0";
19
22
 
20
23
  const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
21
24
 
@@ -37,38 +40,71 @@ function isVersionNewer(version: string, lastSeen: string | null): boolean {
37
40
  return false;
38
41
  }
39
42
 
43
+ function isVersionAtOrNewer(version: string, floor: string): boolean {
44
+ const next = parseVersion(version);
45
+ const min = parseVersion(floor);
46
+ if (!next || !min) return false;
47
+ for (let i = 0; i < 3; i++) {
48
+ if (next[i] > min[i]) return true;
49
+ if (next[i] < min[i]) return false;
50
+ }
51
+ return true;
52
+ }
53
+
40
54
  /**
41
55
  * Extract the bullet lines from released changelog sections whose version is
42
- * strictly newer than `lastSeenVersion` (null = first run, so the newest
43
- * sections win). Markdown bold markers are stripped; backticks are kept so
44
- * commands like `/powerline doctor` stay readable in the terminal.
56
+ * strictly newer than `lastSeenVersion`. On a first run (`lastSeenVersion` is
57
+ * null) `floorVersion` becomes the lower bound, so the panel shows history from
58
+ * the first own release forward and skips the pre-1.0 prehistory. Sections are
59
+ * returned oldest-first (chronological "since your last version") and capped at
60
+ * `maxLines`. Markdown bold markers are stripped; backticks are kept so commands
61
+ * like `/powerline doctor` stay readable in the terminal.
45
62
  */
46
63
  export function parseChangelogDelta(
47
64
  changelog: string,
48
65
  lastSeenVersion: string | null,
49
66
  maxLines: number = MAX_WHATSNEW_LINES,
67
+ floorVersion: string | null = FIRST_OWN_RELEASE,
50
68
  ): string[] {
51
- const entries: string[] = [];
52
- let includeSection = false;
69
+ // The changelog is newest-first; collect per section, then reverse to read
70
+ // oldest-first before capping.
71
+ const sections: string[][] = [];
72
+ let current: string[] | null = null;
73
+ // A stored last-seen version is a strict lower bound (only strictly newer
74
+ // sections). On a first run the floor is inclusive, so the first own release
75
+ // itself is shown while the pre-1.0 prehistory is skipped.
76
+ const matchesBound =
77
+ lastSeenVersion !== null
78
+ ? (version: string) => isVersionNewer(version, lastSeenVersion)
79
+ : floorVersion !== null
80
+ ? (version: string) => isVersionAtOrNewer(version, floorVersion)
81
+ : () => true;
53
82
 
54
83
  for (const rawLine of changelog.split("\n")) {
55
84
  const line = rawLine.trimEnd();
56
85
  const versionMatch = line.match(/^##\s+\[([^\]]+)\]/);
57
86
  if (versionMatch) {
58
87
  const version = versionMatch[1].trim();
59
- includeSection =
60
- version !== "Unreleased" && isVersionNewer(version, lastSeenVersion);
88
+ const include = version !== "Unreleased" && matchesBound(version);
89
+ current = include ? [] : null;
90
+ if (current) sections.push(current);
61
91
  continue;
62
92
  }
63
- if (!includeSection) continue;
93
+ if (!current) continue;
64
94
  if (!line.startsWith("- ")) continue;
65
95
 
66
96
  const cleaned = line.slice(2).trim().replace(/\*\*/g, "").trim();
67
97
  if (!cleaned) continue;
68
- entries.push(cleaned);
69
- if (entries.length >= maxLines) break;
98
+ current.push(cleaned);
70
99
  }
71
100
 
101
+ const entries: string[] = [];
102
+ for (const section of sections.reverse()) {
103
+ for (const entry of section) {
104
+ entries.push(entry);
105
+ if (entries.length >= maxLines) return entries;
106
+ }
107
+ }
72
108
  return entries;
73
109
  }
74
110