@piagent/platform 1.0.1 → 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.
@@ -0,0 +1,189 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Checks that the published documentation site serves the released version from
4
+ // every address it resolves to.
5
+ //
6
+ // Opening the site in a browser is not this check. A domain with several A
7
+ // records answers from whichever one the resolver picked that second, so one
8
+ // address can be dead for a week while every manual look happens to land on a
9
+ // healthy one. This resolves the name, then asks each address directly with the
10
+ // right Host header and SNI, and reports them separately. A partial outage has
11
+ // to read as a failure, not as an average.
12
+ //
13
+ // A redirect from a pinned address back to the same host is reported rather
14
+ // than followed: that is the shape of a loop, and following it would only
15
+ // re-resolve the name and hide which address caused it.
16
+
17
+ import dns from "node:dns/promises";
18
+ import https from "node:https";
19
+ import path from "node:path";
20
+ import fs from "node:fs";
21
+ import { fileURLToPath, pathToFileURL } from "node:url";
22
+
23
+ const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
24
+ const REQUEST_TIMEOUT_MS = 15_000;
25
+ const MAX_BODY_BYTES = 4 * 1024 * 1024;
26
+
27
+ function fail(message) {
28
+ process.stderr.write(`FAIL: ${message}\n`);
29
+ process.exit(1);
30
+ }
31
+
32
+ function parseArguments(values) {
33
+ const options = { hosts: [], expect: null };
34
+ for (let index = 0; index < values.length; index += 1) {
35
+ const option = values[index];
36
+ if (option === "-h" || option === "--help") {
37
+ process.stdout.write("Usage: node scripts/check-published-site.mjs [--host <name>]... [--expect <text>]\n");
38
+ process.stdout.write("Verify every resolved address of the docs site serves the released version.\n");
39
+ process.stdout.write("Defaults to piagent.io.vn and www.piagent.io.vn, expecting the root package version.\n");
40
+ process.exit(0);
41
+ }
42
+ const value = values[index + 1];
43
+ if (option === "--host") {
44
+ if (!value || value.startsWith("--")) fail("--host requires a value");
45
+ if (!/^[a-z0-9.-]{1,253}$/i.test(value)) fail(`--host value ${JSON.stringify(value)} is not a hostname`);
46
+ options.hosts.push(value);
47
+ index += 1;
48
+ continue;
49
+ }
50
+ if (option === "--expect") {
51
+ if (!value || value.startsWith("--")) fail("--expect requires a value");
52
+ if (options.expect !== null) fail("duplicate option --expect");
53
+ options.expect = value;
54
+ index += 1;
55
+ continue;
56
+ }
57
+ fail(`unknown option ${option}`);
58
+ }
59
+ if (options.hosts.length === 0) options.hosts = ["piagent.io.vn", "www.piagent.io.vn"];
60
+ if (options.expect === null) {
61
+ const manifest = JSON.parse(fs.readFileSync(path.join(repositoryRoot, "package.json"), "utf8"));
62
+ options.expect = manifest.version;
63
+ }
64
+ return options;
65
+ }
66
+
67
+ async function resolveAddresses(host) {
68
+ const addresses = [];
69
+ for (const [family, resolver] of [[4, dns.resolve4], [6, dns.resolve6]]) {
70
+ try {
71
+ for (const address of await resolver(host)) addresses.push({ address, family });
72
+ } catch (error) {
73
+ if (error?.code !== "ENODATA" && error?.code !== "ENOTFOUND") throw error;
74
+ }
75
+ }
76
+ return addresses;
77
+ }
78
+
79
+ function request(host, { address, family }) {
80
+ return new Promise((resolve) => {
81
+ const call = https.request(
82
+ {
83
+ host: address,
84
+ family,
85
+ servername: host,
86
+ port: 443,
87
+ path: "/",
88
+ method: "GET",
89
+ headers: { Host: host, "User-Agent": "piagent-site-check" },
90
+ timeout: REQUEST_TIMEOUT_MS
91
+ },
92
+ (response) => {
93
+ let body = "";
94
+ let bytes = 0;
95
+ response.setEncoding("utf8");
96
+ response.on("data", (chunk) => {
97
+ bytes += Buffer.byteLength(chunk, "utf8");
98
+ if (bytes > MAX_BODY_BYTES) {
99
+ response.destroy();
100
+ return;
101
+ }
102
+ body += chunk;
103
+ });
104
+ response.on("end", () => resolve({ status: response.statusCode, location: response.headers.location, body }));
105
+ response.on("error", (error) => resolve({ error: error.message, code: error.code }));
106
+ }
107
+ );
108
+ call.on("timeout", () => {
109
+ call.destroy();
110
+ resolve({ error: `no response within ${REQUEST_TIMEOUT_MS}ms` });
111
+ });
112
+ call.on("error", (error) => resolve({ error: error.message, code: error.code }));
113
+ call.end();
114
+ });
115
+ }
116
+
117
+ // A redirect is only acceptable when it leaves this host for another name we are
118
+ // also checking. Pointing back at itself is the loop; pointing off the domain
119
+ // means the released page is not what this address serves.
120
+ export function judge(host, hosts, expect, result, family) {
121
+ // A machine with no route to the address family cannot say anything about the
122
+ // address. Calling that a site failure would be a false positive, and a gate
123
+ // that cries wolf gets ignored, so it is reported as unverified instead.
124
+ if (result.code === "ENETUNREACH" || result.code === "EHOSTUNREACH") {
125
+ return { unverified: `no route from this machine to IPv${family}: ${result.error}` };
126
+ }
127
+ if (result.error) return `unreachable: ${result.error}`;
128
+ if (result.status >= 300 && result.status < 400) {
129
+ let target;
130
+ try {
131
+ target = new URL(result.location ?? "", `https://${host}/`);
132
+ } catch {
133
+ return `HTTP ${result.status} with an unreadable Location header`;
134
+ }
135
+ if (target.hostname === host) return `HTTP ${result.status} redirecting to itself at ${target.href}`;
136
+ if (!hosts.includes(target.hostname)) return `HTTP ${result.status} redirecting off the site to ${target.href}`;
137
+ return null;
138
+ }
139
+ if (result.status !== 200) return `HTTP ${result.status}`;
140
+ if (!result.body.includes(expect)) return `HTTP 200 but the page does not mention ${expect}`;
141
+ return null;
142
+ }
143
+
144
+ async function main() {
145
+ const options = parseArguments(process.argv.slice(2));
146
+ const failures = [];
147
+ const unverified = [];
148
+ let checked = 0;
149
+
150
+ for (const host of options.hosts) {
151
+ let addresses;
152
+ try {
153
+ addresses = await resolveAddresses(host);
154
+ } catch (error) {
155
+ fail(`${host} did not resolve: ${error instanceof Error ? error.message : String(error)}`);
156
+ }
157
+ if (addresses.length === 0) fail(`${host} resolved to no addresses`);
158
+ for (const entry of addresses) {
159
+ const verdict = judge(host, options.hosts, options.expect, await request(host, entry), entry.family);
160
+ checked += 1;
161
+ if (verdict?.unverified) {
162
+ unverified.push(`${host} at ${entry.address}: ${verdict.unverified}`);
163
+ process.stdout.write(` ???? ${host} ${entry.address} — ${verdict.unverified}\n`);
164
+ } else if (verdict) {
165
+ failures.push(`${host} at ${entry.address}: ${verdict}`);
166
+ process.stdout.write(` FAIL ${host} ${entry.address} — ${verdict}\n`);
167
+ } else {
168
+ process.stdout.write(` ok ${host} ${entry.address}\n`);
169
+ }
170
+ }
171
+ }
172
+
173
+ for (const entry of unverified) process.stdout.write(`UNVERIFIED: ${entry}\n`);
174
+
175
+ if (failures.length > 0) {
176
+ process.stderr.write(`FAIL: ${failures.length} of ${checked} addresses do not serve ${options.expect}\n`);
177
+ for (const failure of failures) process.stderr.write(` ${failure}\n`);
178
+ process.stderr.write("Each address is a real path users take. Fix the DNS records or the deployment behind them.\n");
179
+ process.exit(1);
180
+ }
181
+
182
+ const verified = checked - unverified.length;
183
+ const note = unverified.length > 0 ? ` (${unverified.length} not reachable from this machine)` : "";
184
+ process.stdout.write(`PASS: ${verified} of ${checked} addresses serve ${options.expect}${note}\n`);
185
+ }
186
+
187
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
188
+ await main();
189
+ }
@@ -211,6 +211,22 @@ const servers = {
211
211
  }
212
212
  };
213
213
 
214
+ // Writing a server definition does not make it usable. What each one still
215
+ // needs from the operator is stated here so it can be printed at the moment the
216
+ // definition is written, rather than living only in a description string that
217
+ // shows up under --list.
218
+ const prerequisites = {
219
+ context7: ["optional: export CONTEXT7_API_KEY for a higher documentation quota"],
220
+ "chrome-devtools": ["a local Chrome installation"],
221
+ github: [
222
+ "Docker running",
223
+ "export GITHUB_PERSONAL_ACCESS_TOKEN with the scopes you intend to use"
224
+ ],
225
+ playwright: ["browsers installed on first use (npx playwright install)"],
226
+ figma: ["Figma OAuth, completed from the Pi MCP panel on first use"],
227
+ "figma-desktop": ["Figma desktop running with Dev Mode MCP enabled"]
228
+ };
229
+
214
230
  const presets = {
215
231
  minimal: [],
216
232
  docs: ["context7"],
@@ -227,7 +243,10 @@ const presets = {
227
243
  if (listOnly) {
228
244
  console.log(JSON.stringify({
229
245
  presets,
230
- servers: Object.fromEntries(Object.entries(servers).map(([name, value]) => [name, value.description]))
246
+ servers: Object.fromEntries(Object.entries(servers).map(([name, value]) => [
247
+ name,
248
+ { description: value.description, requires: prerequisites[name] ?? [] }
249
+ ]))
231
250
  }, null, 2));
232
251
  process.exit(0);
233
252
  }
@@ -290,6 +309,19 @@ const report = {
290
309
  serverCount: Object.keys(current.mcpServers).length
291
310
  };
292
311
 
312
+ // stdout stays machine-readable; the operator note goes to stderr.
313
+ function reportPrerequisites() {
314
+ const lines = [];
315
+ for (const name of presets[presetName]) {
316
+ for (const item of prerequisites[name] ?? []) lines.push(` ${name}: ${item}`);
317
+ }
318
+ if (lines.length === 0) return;
319
+ console.error("");
320
+ console.error("Server definitions are written. Each still needs this before it can connect:");
321
+ for (const line of lines) console.error(line);
322
+ console.error("Servers connect lazily, so nothing above is checked until the first call.");
323
+ }
324
+
293
325
  if (dryRun) {
294
326
  console.log(JSON.stringify(report, null, 2));
295
327
  console.log(output);
@@ -297,5 +329,6 @@ if (dryRun) {
297
329
  fs.mkdirSync(path.dirname(configPath), { recursive: true });
298
330
  fs.writeFileSync(configPath, output);
299
331
  console.log(JSON.stringify(report, null, 2));
332
+ reportPrerequisites();
300
333
  }
301
334
  NODE
@@ -238,6 +238,20 @@ NODE
238
238
  fi
239
239
  fi
240
240
 
241
+ # Running from an installed package rather than a checkout. The name and
242
+ # version on disk are exactly what a teammate resolves from the registry, so
243
+ # they are a portable source; the install path is not, and project settings
244
+ # are meant to be committed.
245
+ if [[ "$PLATFORM_ROOT" == */node_modules/* ]]; then
246
+ local installed_name installed_version
247
+ installed_name="$(node -e 'const fs=require("node:fs"); process.stdout.write(JSON.parse(fs.readFileSync(process.argv[1], "utf8")).name ?? "");' "$PLATFORM_ROOT/package.json")"
248
+ installed_version="$(node -e 'const fs=require("node:fs"); process.stdout.write(JSON.parse(fs.readFileSync(process.argv[1], "utf8")).version ?? "");' "$PLATFORM_ROOT/package.json")"
249
+ if [[ -n "$installed_name" && "$installed_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]]; then
250
+ printf 'npm:%s@%s\n' "$installed_name" "$installed_version"
251
+ return
252
+ fi
253
+ fi
254
+
241
255
  echo "WARN: No exact package source provided." >&2
242
256
  echo "WARN: .pi/settings.json will use local platform path; do not commit it for team use." >&2
243
257
  printf '%s\n' "$PLATFORM_ROOT"
@@ -365,5 +379,10 @@ echo " cd \"$PROJECT_PATH\""
365
379
  echo " pi"
366
380
  echo " /login # first time only"
367
381
  echo " /onboard-project # first project-read after model selection"
382
+ echo " /mcp # MCP servers come from the global install, not from this project"
368
383
  echo " /context-index # inspect compact project context index"
369
384
  echo " /memory-policy # inspect project memory policy when needed"
385
+ echo
386
+ echo "Note: .mcp.json and .pi/mcp.json are written empty on purpose. They exist so this"
387
+ echo "project can add its own servers later; the shared baseline lives in the global"
388
+ echo "config written by piagent-setup. An empty file here does not mean MCP is missing."
@@ -4,7 +4,7 @@ set -euo pipefail
4
4
  usage() {
5
5
  cat <<'USAGE'
6
6
  Usage:
7
- scripts/install-global.sh [--stable|--dev|--local|--channel <stable|dev|local>] [--version <tag>] [--resolve-tag] [--package-source <source>] [--dry-run] [--with-mcp] [--mcp-preset <preset>] [--with-subagents] [--subagents-preset <preset>] [--with-web-access] [--with-herdr] [--model-scope <preset>]
7
+ scripts/install-global.sh [--stable|--dev|--local|--channel <stable|dev|local>] [--version <tag>] [--resolve-tag] [--package-source <source>] [--dry-run] [--with-mcp|--no-mcp] [--mcp-preset <preset>] [--with-subagents] [--subagents-preset <preset>] [--with-web-access] [--with-herdr] [--model-scope <preset>]
8
8
 
9
9
  Purpose:
10
10
  Install the piagent Pi package into the current user's global Pi settings.
@@ -15,7 +15,7 @@ Package source examples:
15
15
  # Installs the stable tag after resolving it to a commit SHA.
16
16
 
17
17
  # Pin a specific release:
18
- scripts/install-global.sh --version v1.0.1 --resolve-tag
18
+ scripts/install-global.sh --version v1.1.0 --resolve-tag
19
19
 
20
20
  # Preview the planned install/update without changing user config:
21
21
  scripts/install-global.sh --stable --dry-run
@@ -35,7 +35,10 @@ Notes:
35
35
  - OAuth is intentionally not automated. Run `pi` then `/login`.
36
36
  - Model scope is configured with Pi's native `enabledModels` so users choose via `/model`, Ctrl+L, `/scoped-models`, and Ctrl+P.
37
37
  - Herdr integration is optional and modifies user-level Herdr/Pi config.
38
- - MCP preset defaults to core: Context7 docs, Chrome DevTools, GitHub.
38
+ - MCP is installed by default with the core preset: Context7 docs, Chrome DevTools, GitHub.
39
+ Skip it with --no-mcp. Writing the config does not start or authenticate anything:
40
+ the GitHub server additionally needs Docker running and GITHUB_PERSONAL_ACCESS_TOKEN
41
+ exported, and Chrome DevTools needs a local Chrome.
39
42
  - Subagents preset defaults to safe: compact tool description, bounded concurrency/depth.
40
43
  - Web access is optional. Install it only when you want the builtin `researcher` subagent to browse/fetch web sources inside Pi.
41
44
  USAGE
@@ -50,8 +53,9 @@ DEFAULT_REPO_REMOTE_URL="https://github.com/Vt-mmm/piagent.git"
50
53
  PACKAGE_SOURCE="${PIAGENT_PACKAGE_SOURCE:-}"
51
54
  PACKAGE_VERSION="${PIAGENT_PACKAGE_VERSION:-}"
52
55
  RELEASE_CHANNEL="${PIAGENT_RELEASE_CHANNEL:-}"
53
- WITH_MCP=false
56
+ WITH_MCP=true
54
57
  MCP_PRESET="core"
58
+ MCP_PRESET_EXPLICIT=false
55
59
  WITH_SUBAGENTS=false
56
60
  SUBAGENTS_PRESET="safe"
57
61
  SUBAGENTS_MODEL_SCOPE="none"
@@ -123,7 +127,7 @@ detect_runtime_surface() {
123
127
  printf 'native-windows/%s (not release-gated; terminal helpers require Bash semantics)' "$arch_name"
124
128
  ;;
125
129
  *)
126
- printf '%s/%s (outside v1.0.1 release matrix)' "$os_name" "$arch_name"
130
+ printf '%s/%s (outside v1.1.0 release matrix)' "$os_name" "$arch_name"
127
131
  ;;
128
132
  esac
129
133
  }
@@ -131,7 +135,7 @@ detect_runtime_surface() {
131
135
  warn_runtime_surface_if_needed() {
132
136
  local surface="$1"
133
137
  case "$surface" in
134
- *"supported target"*|*"experimental"*|*"not release-gated"*|*"outside v1.0.1 release matrix"*)
138
+ *"supported target"*|*"experimental"*|*"not release-gated"*|*"outside v1.1.0 release matrix"*)
135
139
  echo "WARN: runtime surface is $surface." >&2
136
140
  echo "WARN: For team rollout, run piagent-doctor plus the project smoke/verify suite on this machine before relying on it." >&2
137
141
  ;;
@@ -275,9 +279,14 @@ while [[ $# -gt 0 ]]; do
275
279
  WITH_MCP=true
276
280
  shift
277
281
  ;;
282
+ --no-mcp)
283
+ WITH_MCP=false
284
+ shift
285
+ ;;
278
286
  --mcp-preset)
279
287
  require_value "$1" "${2:-}"
280
288
  MCP_PRESET="$2"
289
+ MCP_PRESET_EXPLICIT=true
281
290
  shift 2
282
291
  ;;
283
292
  --with-subagents)
@@ -328,6 +337,13 @@ while [[ $# -gt 0 ]]; do
328
337
  esac
329
338
  done
330
339
 
340
+ # A preset chosen alongside --no-mcp used to be accepted and then quietly
341
+ # dropped, which reads as "the preset was applied".
342
+ if [[ "$WITH_MCP" == false && "$MCP_PRESET_EXPLICIT" == true ]]; then
343
+ echo "FAIL: --mcp-preset has no effect with --no-mcp. Drop one of them." >&2
344
+ exit 2
345
+ fi
346
+
331
347
  require_node_version
332
348
  CURRENT_RUNTIME_SURFACE="$(detect_runtime_surface)"
333
349
  warn_runtime_surface_if_needed "$CURRENT_RUNTIME_SURFACE"
@@ -438,7 +454,7 @@ if ! command -v pi >/dev/null 2>&1; then
438
454
  else
439
455
  echo "FAIL: pi is not on PATH." >&2
440
456
  echo "Install Pi first, then rerun this script." >&2
441
- echo "Expected command on npm-based installs: npm install -g --ignore-scripts @earendil-works/pi-coding-agent@0.81.1" >&2
457
+ echo "Expected command on npm-based installs: npm install -g --ignore-scripts @earendil-works/pi-coding-agent@0.82.0" >&2
442
458
  exit 1
443
459
  fi
444
460
  else
@@ -527,6 +543,8 @@ echo " pi"
527
543
  echo " /login"
528
544
  echo " /model # or Ctrl+L: select provider/model from Pi selector"
529
545
  echo " /scoped-models # optional: edit Ctrl+P model cycle scope"
530
- echo " /mcp # inspect MCP servers; authenticate Figma/GitHub only when needed"
546
+ if [[ "$WITH_MCP" == true ]]; then
547
+ echo " /mcp # inspect MCP servers; authenticate Figma/GitHub only when needed"
548
+ fi
531
549
  echo " /subagents-doctor"
532
550
  echo " /task Implement <task> # parent may auto-delegate scout/planner/reviewer"
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
6
6
  const scriptByCommand = {
7
7
  "piagent-setup": "scripts/setup.sh",
8
8
  "piagent-install": "scripts/install-global.sh",
9
+ "piagent-uninstall": "scripts/uninstall-global.sh",
9
10
  "piagent-init": "scripts/init-project.sh",
10
11
  "piagent-doctor": "scripts/team-doctor.sh",
11
12
  "piagent-benchmark": "scripts/quality-benchmark.sh",
package/scripts/setup.sh CHANGED
@@ -244,6 +244,20 @@ NODE
244
244
  fi
245
245
  fi
246
246
 
247
+ # Running from an installed package rather than a checkout. The name and
248
+ # version on disk are exactly what a teammate resolves from the registry, so
249
+ # they are a portable source; the install path is not, and project settings
250
+ # are meant to be committed.
251
+ if [[ "$PLATFORM_ROOT" == */node_modules/* ]]; then
252
+ local installed_name installed_version
253
+ installed_name="$(node -e 'const fs=require("node:fs"); process.stdout.write(JSON.parse(fs.readFileSync(process.argv[1], "utf8")).name ?? "");' "$PLATFORM_ROOT/package.json")"
254
+ installed_version="$(node -e 'const fs=require("node:fs"); process.stdout.write(JSON.parse(fs.readFileSync(process.argv[1], "utf8")).version ?? "");' "$PLATFORM_ROOT/package.json")"
255
+ if [[ -n "$installed_name" && "$installed_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]]; then
256
+ printf 'npm:%s@%s\n' "$installed_name" "$installed_version"
257
+ return
258
+ fi
259
+ fi
260
+
247
261
  echo "WARN: No exact package source provided." >&2
248
262
  echo "WARN: using local platform path; do not commit project .pi/settings.json with this value for team rollout." >&2
249
263
  printf '%s\n' "$PLATFORM_ROOT"
@@ -137,7 +137,7 @@ function runtimeMatrixStatus() {
137
137
  surface: `${platform}/${arch}`,
138
138
  status: "outside-release-matrix",
139
139
  teamRolloutReady: false,
140
- note: "This runtime is outside the documented v1.0.1 support matrix."
140
+ note: "This runtime is outside the documented v1.1.0 support matrix."
141
141
  };
142
142
  }
143
143
 
@@ -0,0 +1,231 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Removes what this platform installed, and nothing else.
5
+ #
6
+ # Uninstall is reported before it is performed, for the same reason install is:
7
+ # the two touch shared Pi state that other tools also write to. Anything this
8
+ # script cannot prove it installed is listed for the operator instead of being
9
+ # deleted.
10
+
11
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
12
+ PI_AGENT_DIR="${PI_CODING_AGENT_DIR:-"${HOME}/.pi/agent"}"
13
+ GLOBAL_SETTINGS="$PI_AGENT_DIR/settings.json"
14
+
15
+ APPLY=false
16
+ WITH_ADDONS=false
17
+ WITH_HOST=false
18
+ PROJECT_PATH=""
19
+
20
+ PLATFORM_PACKAGE="@piagent/platform"
21
+ ADDON_PACKAGES=("pi-mcp-adapter" "pi-subagents" "pi-web-access")
22
+ PI_HOST_PACKAGE="@earendil-works/pi-coding-agent"
23
+
24
+ usage() {
25
+ cat <<'USAGE'
26
+ Usage:
27
+ piagent-uninstall [--apply] [--with-addons] [--with-host] [--project <path>]
28
+
29
+ Removes the Pi package this platform installed from Pi's global settings.
30
+
31
+ (default) Dry run. Reports what would be removed and exits 0.
32
+ --apply Perform the removal.
33
+ --with-addons Also remove pi-mcp-adapter, pi-subagents, and pi-web-access.
34
+ --with-host Also uninstall the global Pi Coding Agent host.
35
+ --project <path> Also remove this platform's state from a project.
36
+
37
+ Never removed, at any flag combination: credentials, trust decisions, sessions,
38
+ todos, and project memory. Those are operator data, not platform state.
39
+ USAGE
40
+ }
41
+
42
+ while [[ $# -gt 0 ]]; do
43
+ case "$1" in
44
+ --apply) APPLY=true; shift ;;
45
+ --with-addons) WITH_ADDONS=true; shift ;;
46
+ --with-host) WITH_HOST=true; shift ;;
47
+ --project)
48
+ if [[ $# -lt 2 || -z "${2:-}" ]]; then
49
+ echo "Missing value for --project" >&2
50
+ exit 2
51
+ fi
52
+ PROJECT_PATH="$2"
53
+ shift 2
54
+ ;;
55
+ -h|--help) usage; exit 0 ;;
56
+ *)
57
+ echo "Unknown argument: $1" >&2
58
+ usage >&2
59
+ exit 2
60
+ ;;
61
+ esac
62
+ done
63
+
64
+ print_cmd() {
65
+ printf '+'
66
+ for arg in "$@"; do printf ' %q' "$arg"; done
67
+ printf '\n'
68
+ }
69
+
70
+ run_cmd() {
71
+ print_cmd "$@"
72
+ if [[ "$APPLY" == true ]]; then
73
+ "$@"
74
+ fi
75
+ }
76
+
77
+ # Reads the package sources Pi has registered, so removal targets what is
78
+ # actually installed rather than what this version happens to install today. An
79
+ # older release registered a different source shape, and it still has to come
80
+ # out.
81
+ platform_sources() {
82
+ [[ -f "$GLOBAL_SETTINGS" ]] || return 0
83
+ node --input-type=module - "$GLOBAL_SETTINGS" <<'NODE'
84
+ import fs from "node:fs";
85
+
86
+ let settings;
87
+ try {
88
+ settings = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
89
+ } catch {
90
+ process.exit(0);
91
+ }
92
+ const packages = Array.isArray(settings.packages) ? settings.packages : [];
93
+ // Every shape this platform has ever been installed as: the scoped npm
94
+ // package, the Git source, and a local checkout path.
95
+ const owned = /(@piagent\/platform|[/:]piagent(@|$|#)|Vt-mmm\/piagent|pi-company-platform)/;
96
+ for (const entry of packages) {
97
+ if (typeof entry === "string" && owned.test(entry)) process.stdout.write(`${entry}\n`);
98
+ }
99
+ NODE
100
+ }
101
+
102
+ addon_sources() {
103
+ [[ -f "$GLOBAL_SETTINGS" ]] || return 0
104
+ node --input-type=module - "$GLOBAL_SETTINGS" "${ADDON_PACKAGES[@]}" <<'NODE'
105
+ import fs from "node:fs";
106
+
107
+ let settings;
108
+ try {
109
+ settings = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
110
+ } catch {
111
+ process.exit(0);
112
+ }
113
+ const names = process.argv.slice(3);
114
+ const packages = Array.isArray(settings.packages) ? settings.packages : [];
115
+ for (const entry of packages) {
116
+ if (typeof entry !== "string") continue;
117
+ if (names.some((name) => entry.includes(name))) process.stdout.write(`${entry}\n`);
118
+ }
119
+ NODE
120
+ }
121
+
122
+ echo "Pi Agent Platform uninstall"
123
+ echo " mode: $([[ "$APPLY" == true ]] && echo apply || echo "dry run")"
124
+ echo " settings: $GLOBAL_SETTINGS"
125
+ echo " addons: $WITH_ADDONS"
126
+ echo " host: $WITH_HOST"
127
+ [[ -n "$PROJECT_PATH" ]] && echo " project: $PROJECT_PATH"
128
+ echo
129
+
130
+ if ! command -v pi >/dev/null 2>&1; then
131
+ echo "WARN: pi is not on PATH; skipping Pi package removal." >&2
132
+ echo "WARN: reinstall the host or remove entries from $GLOBAL_SETTINGS by hand." >&2
133
+ else
134
+ found=false
135
+ while IFS= read -r source; do
136
+ [[ -z "$source" ]] && continue
137
+ found=true
138
+ echo "Removing platform package:"
139
+ run_cmd pi remove "$source"
140
+ done < <(platform_sources)
141
+ if [[ "$found" == false ]]; then
142
+ echo "No platform package found in Pi settings; nothing to remove."
143
+ fi
144
+
145
+ if [[ "$WITH_ADDONS" == true ]]; then
146
+ addon_found=false
147
+ while IFS= read -r source; do
148
+ [[ -z "$source" ]] && continue
149
+ addon_found=true
150
+ echo "Removing add-on:"
151
+ run_cmd pi remove "$source"
152
+ done < <(addon_sources)
153
+ if [[ "$addon_found" == false ]]; then
154
+ echo "No pinned add-on found in Pi settings."
155
+ fi
156
+ # The MCP baseline and subagent config are shared files that the operator
157
+ # may have edited after install, so they are reported rather than reset.
158
+ # Install writes the shared global scope; the Pi-global file is the override
159
+ # scope and is listed too, because an operator who used it will look there.
160
+ echo "Left in place, review by hand if no longer wanted:"
161
+ echo " ${XDG_CONFIG_HOME:-"${HOME}/.config"}/mcp/mcp.json # written by install"
162
+ echo " $PI_AGENT_DIR/mcp.json # Pi-global override scope, only if you created it"
163
+ echo " $PI_AGENT_DIR/extensions/subagent/config.json"
164
+ fi
165
+ fi
166
+
167
+ if [[ "$WITH_HOST" == true ]]; then
168
+ echo
169
+ if command -v npm >/dev/null 2>&1; then
170
+ echo "Uninstalling Pi host:"
171
+ run_cmd npm uninstall -g "$PI_HOST_PACKAGE"
172
+ else
173
+ echo "WARN: npm is not on PATH; cannot uninstall $PI_HOST_PACKAGE." >&2
174
+ fi
175
+ fi
176
+
177
+ if [[ -n "$PROJECT_PATH" ]]; then
178
+ echo
179
+ if [[ ! -d "$PROJECT_PATH" ]]; then
180
+ echo "FAIL: project path does not exist: $PROJECT_PATH" >&2
181
+ exit 1
182
+ fi
183
+ PROJECT_PATH="$(cd "$PROJECT_PATH" && pwd)"
184
+ echo "Project state in $PROJECT_PATH:"
185
+
186
+ # Files this platform alone writes and owns.
187
+ for relative in ".pi/piagent-profile.json" ".pi/piagent-profile.lock.json"; do
188
+ [[ -e "$PROJECT_PATH/$relative" ]] && run_cmd rm -f "$PROJECT_PATH/$relative"
189
+ done
190
+ if [[ -d "$PROJECT_PATH/.pi/piagent-state" ]]; then
191
+ run_cmd rm -rf "$PROJECT_PATH/.pi/piagent-state"
192
+ fi
193
+
194
+ # settings.json belongs to Pi, not to this platform. Only the package entry
195
+ # pointing here comes out; everything else the operator configured stays.
196
+ if [[ -f "$PROJECT_PATH/.pi/settings.json" ]]; then
197
+ echo "+ drop platform package entry from $PROJECT_PATH/.pi/settings.json"
198
+ if [[ "$APPLY" == true ]]; then
199
+ node --input-type=module - "$PROJECT_PATH/.pi/settings.json" <<'NODE'
200
+ import fs from "node:fs";
201
+
202
+ const target = process.argv[2];
203
+ const settings = JSON.parse(fs.readFileSync(target, "utf8"));
204
+ if (Array.isArray(settings.packages)) {
205
+ const owned = /(@piagent\/platform|[/:]piagent(@|$|#)|Vt-mmm\/piagent|pi-company-platform)/;
206
+ const kept = settings.packages.filter((entry) => !(typeof entry === "string" && owned.test(entry)));
207
+ if (kept.length > 0) settings.packages = kept;
208
+ else delete settings.packages;
209
+ fs.writeFileSync(target, `${JSON.stringify(settings, null, 2)}\n`);
210
+ }
211
+ NODE
212
+ fi
213
+ fi
214
+
215
+ # Written from templates at init, then edited by whoever used the project.
216
+ # Deleting them would take the operator's work with them.
217
+ echo "Left in place because they carry project content:"
218
+ for relative in "AGENTS.md" "REVIEW_GUIDELINES.md" ".mcp.json" ".pi/settings.json" ".pi/mcp.json" \
219
+ ".pi/project-context.md" ".pi/context-index.json" ".pi/tech-stack.json" ".pi/tech-context" ".pi/memory"; do
220
+ [[ -e "$PROJECT_PATH/$relative" ]] && echo " $relative"
221
+ done
222
+ fi
223
+
224
+ echo
225
+ if [[ "$APPLY" == true ]]; then
226
+ echo "Uninstall complete."
227
+ echo "The npm-global helper is separate and stays until you remove it:"
228
+ echo " npm uninstall -g $PLATFORM_PACKAGE"
229
+ else
230
+ echo "Dry run only. Nothing was removed. Re-run with --apply to perform it."
231
+ fi