@pify/cli 0.3.1 → 0.4.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
@@ -1,5 +1,7 @@
1
1
  # @pify/cli
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/@pify/cli)](https://www.npmjs.com/package/@pify/cli) [![npm downloads](https://img.shields.io/npm/dm/@pify/cli)](https://www.npmjs.com/package/@pify/cli)
4
+
3
5
  Front door for the [Pify](https://github.com/pifydev) suite: install and update the [pi coding agent](https://github.com/earendil-works/pi), manage the `@pify/*` extension packages with short names, and scaffold new Pi Packages.
4
6
 
5
7
  > Not to be confused with the unscoped npm package `pify` (sindresorhus's promisify library) — unrelated.
@@ -20,14 +22,17 @@ npx @pify/cli setup
20
22
 
21
23
  ```
22
24
  pify setup # install the pi coding agent (safe to re-run)
23
- pify setup --pi-version 0.84.4 # pin the version your team has tested
25
+ pify setup --pi-version 0.85.1 # pin the version your team has tested
24
26
  pify setup --installer # run pi's official interactive installer (ps1/sh)
25
27
  pify list # show the @pify catalog with install state
26
28
  pify install goal task # short names resolve to @pify/goal, @pify/task
27
29
  pify install suite # the whole suite in one go (also: core, agents)
28
30
  pify install goal -l -a # project scope, pre-approved (CI-friendly)
29
31
  pify remove goal
32
+ pify update --check # what is out of date, changing nothing
30
33
  pify update # update pi + every installed @pify package
34
+ pify profile save # write the installed suite + versions to a file
35
+ pify profile apply f.json # diff it against this machine; --yes to apply
31
36
  pify update pi # agent only
32
37
  pify doctor # diagnose node / npm / pi / settings
33
38
  pify init my-extension # scaffold a new Pi Package
@@ -63,20 +68,33 @@ pify completions powershell | Out-String | Invoke-Expression # add to $PROFILE
63
68
  | `@pify/goal` | Pin a session goal and keep the agent anchored to it |
64
69
  | `@pify/memory` | Persistent memory across pi sessions |
65
70
  | `@pify/plan-mode` | Read-only planning mode with approve-then-execute gate |
66
- | `@pify/pretty` | Prettier TUI rendering for tool calls, diffs, markdown |
71
+ | `@pify/pretty` | Compact, theme-aware rendering for pi's built-in tools |
72
+ | `@pify/search` | Fuzzy file finding and indexed content search: `fffind` and `ffgrep` |
73
+ | `@pify/skills` | See what skills are loaded, what they cost per request, and which ones ever fire |
67
74
  | `@pify/subagent` | Spawn scoped subagents, including by `@agent` mention |
68
75
  | `@pify/swarm` | Coordinate multiple pi agents working in parallel |
69
76
  | `@pify/task` | Task tracking: dependency graph, evidence-gated completion, reminders |
70
- | `@pify/todo` | Agent working-memory checklist: TodoWrite-style, next-item surfacing |
77
+ | `@pify/todo` | Agent working-memory checklist with next-item surfacing |
71
78
  | `@pify/usage` | Token and cost reporting, plus what is filling the context window |
72
- | `@pify/workflow` | Deterministic agent orchestration: CC-style workflow scripts |
73
- | `@pify/worktree` | Safe git-worktree management with safety rails and merge-back |
79
+ | `@pify/workflow` | Deterministic agent orchestration through JavaScript workflow scripts |
80
+ | `@pify/worktree` | Safe git-worktree management, with `/worktree enter` to take the session along |
74
81
  | `@pify/yolo` | A safety gradient from auto-approve to ask-about-anything, with an undo trail |
75
82
 
76
83
  The catalog ships inside the CLI and refreshes (at most daily) from [`catalog.json` on `main`](https://github.com/pifydev/cli/blob/main/catalog.json), so newly published packages appear without a CLI update. A fetched catalog is validated before use — every entry must stay inside the `@pify` scope — and any invalid document is discarded entirely. Overrides: `PIFY_CATALOG_URL` (remote URL), `PIFY_OFFLINE=1` or `PI_OFFLINE=1` (skip all network lookups).
77
84
 
78
85
  Explicit `@pify/<name>` arguments bypass the catalog's published/planned gate (with a warning), so a freshly published package is installable before the catalog propagates.
79
86
 
87
+ ## Environment
88
+
89
+ | Variable | Effect |
90
+ |---|---|
91
+ | `PIFY_CATALOG_URL` | Fetch the catalog from somewhere other than the org's `main` |
92
+ | `PIFY_OFFLINE=1` / `PI_OFFLINE=1` | Skip every network lookup |
93
+ | `PIFY_TRUST_PROJECT=1` | Approve project-supplied config for the suite's extensions in a headless run |
94
+ | `NO_COLOR` / `FORCE_COLOR` | Colour, as usual |
95
+
96
+ `PIFY_TRUST_PROJECT` is read by the extensions rather than by this CLI, and it matters in CI. Several packages load files a repository ships — `@pify/memory`'s `.pi/memory/MEMORY.md`, `@pify/subagent`'s `.pi/agents/*.md`, `@pify/yolo`'s `.pi/yolo.json` — and each asks once before doing so, because pi's own trust prompt does not cover files pi itself does not load. With no UI to ask through, the answer is no. Set this to `1` where you have decided the checkout is trustworthy. It is an environment variable rather than a config file precisely because the repository being read cannot set one for itself.
97
+
80
98
  ## Exit codes
81
99
 
82
100
  | Code | Meaning |
@@ -90,6 +108,36 @@ Explicit `@pify/<name>` arguments bypass the catalog's published/planned gate (w
90
108
 
91
109
  Colored output respects `NO_COLOR`, `FORCE_COLOR`, `--no-color`, and non-TTY pipes. Results go to stdout; warnings and errors go to stderr. No interactive prompts anywhere — behavior is identical in TTY and CI except color.
92
110
 
111
+ ## Profiles
112
+
113
+ The suite you actually run, written down — which packages, at which versions, in which scope — so a second machine can reproduce it:
114
+
115
+ ```bash
116
+ pify profile save # → pify-profile.json
117
+ pify profile apply pify-profile.json # prints the plan, changes nothing
118
+ pify profile apply pify-profile.json --yes # applies it
119
+ ```
120
+
121
+ Applying always shows the difference first:
122
+
123
+ ```
124
+ change goal 0.5.0 → 0.4.0
125
+ extra todo installed here, not in the profile (left alone)
126
+ install swarm (latest)
127
+ ```
128
+
129
+ A package installed here but absent from the profile is reported as `extra` and **never removed**: a profile says what must be present, not what must be deleted. Every name still goes through the same catalog resolution as a typed install, so a profile file cannot reach outside the `@pify` scope. A CLI's review is a diff and an explicit flag rather than an interactive screen, so the same command works the same way in a script.
130
+
131
+ ## Conflict detection
132
+
133
+ Some extensions cannot run beside each other: they register the same command, tool, or flag, so pi loads both and one silently wins — and which one is not something you chose. `pify doctor` now reads pi's whole package list and says so:
134
+
135
+ ```
136
+ warn conflicts @pify/memory + pi-memory; @pify/pretty + pi-pretty-tui register the same commands or tools - remove one
137
+ ```
138
+
139
+ The pairs come from the catalog, where each is taken from the package's own README. Detecting this properly would mean asking a running pi which commands are registered, which a CLI outside pi cannot do — so this is the static equivalent, and it is only as complete as the catalog.
140
+
93
141
  ## Development
94
142
 
95
143
  ```bash
@@ -99,7 +147,7 @@ npm test # builds first, then node --test
99
147
  npm run typecheck
100
148
  ```
101
149
 
102
- Requires Node >= 22.19.0 (same floor as the pi coding agent). Tested with pi 0.84.4.
150
+ Requires Node >= 22.19.0 — the same floor as the pi coding agent.
103
151
 
104
152
  ## License
105
153
 
package/catalog.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": 17,
2
+ "version": 24,
3
3
  "org": "pifydev",
4
4
  "scope": "@pify",
5
5
  "remote": "https://raw.githubusercontent.com/pifydev/cli/main/catalog.json",
@@ -8,98 +8,123 @@
8
8
  "name": "ask-question",
9
9
  "npm": "@pify/ask-question",
10
10
  "repo": "https://github.com/pifydev/ask-question",
11
- "description": "CC AskUserQuestion-shaped tool on built-in dialogs: 1-4 questions, multi-select, Other free-text, /ask history",
11
+ "description": "Structured questions on built-in dialogs: 1-4 questions, options with trade-offs, multi-select, Other free-text",
12
12
  "status": "published"
13
13
  },
14
14
  {
15
15
  "name": "btw",
16
16
  "npm": "@pify/btw",
17
17
  "repo": "https://github.com/pifydev/btw",
18
- "description": "By-the-way side conversations: a read-only, codebase-aware side agent in a widget",
18
+ "description": "By-the-way side conversations: a read-only, codebase-aware side agent in a widget, out of the main context",
19
19
  "status": "published"
20
20
  },
21
21
  {
22
22
  "name": "goal",
23
23
  "npm": "@pify/goal",
24
24
  "repo": "https://github.com/pifydev/goal",
25
- "description": "Pin a session goal and keep the agent anchored: ordered steps, safety limits, evidence-gated completion with an optional independent audit",
25
+ "description": "Pin a session goal and keep the agent anchored: ordered steps, evidence-gated completion, cost-weighted budget",
26
26
  "status": "published"
27
27
  },
28
28
  {
29
29
  "name": "memory",
30
30
  "npm": "@pify/memory",
31
31
  "repo": "https://github.com/pifydev/memory",
32
- "description": "Persistent memory across sessions: two-tier markdown, FTS5 search, secret-scanned writes, opt-in LLM consolidation",
33
- "status": "published"
32
+ "description": "Persistent memory across sessions: two-tier markdown, FTS5 search, secret-scanned writes, consent-gated project tier",
33
+ "status": "published",
34
+ "conflicts": [
35
+ "pi-memory"
36
+ ]
34
37
  },
35
38
  {
36
39
  "name": "plan-mode",
37
40
  "npm": "@pify/plan-mode",
38
41
  "repo": "https://github.com/pifydev/plan-mode",
39
- "description": "Read-only planning with an approve-then-execute gate: enforced tool policy, tracked plan steps, saved-plan library, HTML export",
40
- "status": "published"
42
+ "description": "Read-only planning with an approve-then-execute gate: enforced tool policy, tracked steps, plans as files",
43
+ "status": "published",
44
+ "conflicts": [
45
+ "@narumitw/pi-plan-mode"
46
+ ]
41
47
  },
42
48
  {
43
49
  "name": "pretty",
44
50
  "npm": "@pify/pretty",
45
51
  "repo": "https://github.com/pifydev/pretty",
46
- "description": "Compact theme-aware rendering for built-in tools: summaries, highlighted reads, colorized diffs, configurable caps",
52
+ "description": "Compact theme-aware rendering for built-in tools: summaries, highlighted reads, word-level diff emphasis",
53
+ "status": "published",
54
+ "conflicts": [
55
+ "pi-pretty",
56
+ "pi-pretty-tui",
57
+ "pi-tool-display"
58
+ ]
59
+ },
60
+ {
61
+ "name": "search",
62
+ "npm": "@pify/search",
63
+ "repo": "https://github.com/pifydev/search",
64
+ "description": "Fuzzy file finding and indexed content search: fffind and ffgrep, with a pure-TypeScript fallback",
65
+ "status": "published"
66
+ },
67
+ {
68
+ "name": "skills",
69
+ "npm": "@pify/skills",
70
+ "repo": "https://github.com/pifydev/skills",
71
+ "description": "See the skills pi has loaded, what they cost in every request, and which ones ever fire",
47
72
  "status": "published"
48
73
  },
49
74
  {
50
75
  "name": "subagent",
51
76
  "npm": "@pify/subagent",
52
77
  "repo": "https://github.com/pifydev/subagent",
53
- "description": "Spawn scoped subagents: agent_run/agent_result, @agent mentions, custom agent types, turn caps and tool allowlists",
78
+ "description": "Spawn scoped subagents: agent_run/agent_result, @agent mentions, custom agent types, ask_supervisor, results delivered not polled",
54
79
  "status": "published"
55
80
  },
56
81
  {
57
82
  "name": "swarm",
58
83
  "npm": "@pify/swarm",
59
84
  "repo": "https://github.com/pifydev/swarm",
60
- "description": "Coordinate multiple pi agents in parallel: fan-out with auto-routing, concurrency queue, aggregated reports",
85
+ "description": "Run many pi agents in parallel: fan-out with per-item routing, concurrency queue, shared mailbox, results delivered not polled",
61
86
  "status": "published"
62
87
  },
63
88
  {
64
89
  "name": "task",
65
90
  "npm": "@pify/task",
66
91
  "repo": "https://github.com/pifydev/task",
67
- "description": "Task tracking with dependency graph, evidence-gated completion, live widget, CC-style reminders",
92
+ "description": "Task tracking with a dependency graph, evidence-gated completion, live widget, and a sweep when the list finishes",
68
93
  "status": "published"
69
94
  },
70
95
  {
71
96
  "name": "todo",
72
97
  "npm": "@pify/todo",
73
98
  "repo": "https://github.com/pifydev/todo",
74
- "description": "Agent working-memory checklist: CC TodoWrite-style complete-replacement, next-item surfacing, live widget",
99
+ "description": "Agent working-memory checklist: complete-replacement writes, next-item surfacing, live widget",
75
100
  "status": "published"
76
101
  },
77
102
  {
78
103
  "name": "usage",
79
104
  "npm": "@pify/usage",
80
105
  "repo": "https://github.com/pifydev/usage",
81
- "description": "Token and cost reporting: live footer, local-history dashboard with per-project costs, and /usage context showing what fills the window",
106
+ "description": "Token and cost reporting: live footer, local-history dashboard with per-project totals, context breakdown",
82
107
  "status": "published"
83
108
  },
84
109
  {
85
110
  "name": "workflow",
86
111
  "npm": "@pify/workflow",
87
112
  "repo": "https://github.com/pifydev/workflow",
88
- "description": "Deterministic agent orchestration: CC-style scripts with agent()/parallel()/pipeline(), schema-validated results, gates, and resume",
113
+ "description": "Deterministic agent orchestration through JavaScript scripts: agent()/parallel()/pipeline(), gates, resume",
89
114
  "status": "published"
90
115
  },
91
116
  {
92
117
  "name": "worktree",
93
118
  "npm": "@pify/worktree",
94
119
  "repo": "https://github.com/pifydev/worktree",
95
- "description": "Safe git-worktree management: create/list/merge/remove with safety rails, no shell interpolation",
120
+ "description": "Safe git-worktree management: create/list/merge/remove with safety rails, plus /worktree enter to take the session along",
96
121
  "status": "published"
97
122
  },
98
123
  {
99
124
  "name": "yolo",
100
125
  "npm": "@pify/yolo",
101
126
  "repo": "https://github.com/pifydev/yolo",
102
- "description": "A safety gradient from auto-approve-everything to ask-about-anything: four modes, secret-file gate, undo trail with git checkpoints",
127
+ "description": "A safety gradient from auto-approve-everything to ask-about-anything: four modes, read-before-write, undo trail, rewind to before a prompt",
103
128
  "status": "published"
104
129
  }
105
130
  ],
@@ -114,6 +139,8 @@
114
139
  "memory",
115
140
  "plan-mode",
116
141
  "pretty",
142
+ "search",
143
+ "skills",
117
144
  "subagent",
118
145
  "swarm",
119
146
  "task",
package/dist/cli.js CHANGED
@@ -87,14 +87,14 @@ function runCapture(command, args) {
87
87
  }
88
88
  function runInherit(command, args) {
89
89
  const [cmd, argv, opts] = spawnPlan(command, args);
90
- return new Promise((resolve2, reject) => {
90
+ return new Promise((resolve3, reject) => {
91
91
  const child = spawn(cmd, argv, {
92
92
  ...opts,
93
93
  stdio: "inherit",
94
94
  windowsHide: true
95
95
  });
96
96
  child.on("error", reject);
97
- child.on("close", (code) => resolve2(code ?? 1));
97
+ child.on("close", (code) => resolve3(code ?? 1));
98
98
  });
99
99
  }
100
100
  function runOfficialInstaller(kind) {
@@ -102,10 +102,10 @@ function runOfficialInstaller(kind) {
102
102
  "powershell",
103
103
  ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", "irm https://pi.dev/install.ps1 | iex"]
104
104
  ] : ["sh", ["-c", "curl -fsSL https://pi.dev/install.sh | sh"]];
105
- return new Promise((resolve2, reject) => {
105
+ return new Promise((resolve3, reject) => {
106
106
  const child = spawn(cmd, argv, { stdio: "inherit", windowsHide: false });
107
107
  child.on("error", reject);
108
- child.on("close", (code) => resolve2(code ?? 1));
108
+ child.on("close", (code) => resolve3(code ?? 1));
109
109
  });
110
110
  }
111
111
  function which(command) {
@@ -247,7 +247,7 @@ function npmPrefixEvidence(binPath) {
247
247
  const res = runCapture("npm", ["prefix", "-g"]);
248
248
  const prefix = res.status === 0 ? res.stdout.trim() : "";
249
249
  if (!prefix) return { managed: false, prefix: null };
250
- const resolve2 = (p) => {
250
+ const resolve3 = (p) => {
251
251
  try {
252
252
  return realpathSync(p);
253
253
  } catch {
@@ -255,7 +255,7 @@ function npmPrefixEvidence(binPath) {
255
255
  }
256
256
  };
257
257
  const normalize = (p) => isWindows ? p.toLowerCase() : p;
258
- const managed = normalize(resolve2(binPath)).startsWith(normalize(resolve2(prefix)));
258
+ const managed = normalize(resolve3(binPath)).startsWith(normalize(resolve3(prefix)));
259
259
  return { managed, prefix };
260
260
  }
261
261
  function guardNpmManaged(binPath) {
@@ -400,6 +400,21 @@ function readSettings(scope) {
400
400
  }
401
401
  }
402
402
  var PIFY_SOURCE = /^npm:@pify\/([A-Za-z0-9._-]+?)(?:@(.+))?$/;
403
+ function configuredNpmPackages() {
404
+ const found = [];
405
+ for (const scope of ["user", "project"]) {
406
+ const settings = readSettings(scope);
407
+ for (const entry of settings?.packages ?? []) {
408
+ const source = typeof entry === "string" ? entry : entry.source;
409
+ if (typeof source !== "string" || !source.startsWith("npm:")) continue;
410
+ const spec = source.slice("npm:".length);
411
+ const at = spec.indexOf("@", spec.startsWith("@") ? 1 : 0);
412
+ const name = at > 0 ? spec.slice(0, at) : spec;
413
+ if (name && !found.some((f) => f.name === name && f.scope === scope)) found.push({ name, scope });
414
+ }
415
+ }
416
+ return found;
417
+ }
403
418
  function installedPifyPackages() {
404
419
  const result = /* @__PURE__ */ new Map();
405
420
  for (const scope of ["user", "project"]) {
@@ -415,6 +430,24 @@ function installedPifyPackages() {
415
430
  }
416
431
  return result;
417
432
  }
433
+ async function fetchLatestPackageVersion(name) {
434
+ if (isOffline()) return null;
435
+ try {
436
+ const controller = new AbortController();
437
+ const timer = setTimeout(() => controller.abort(), 5e3);
438
+ const res = await fetch(`https://registry.npmjs.org/@pify/${encodeURIComponent(name)}/latest`, {
439
+ signal: controller.signal,
440
+ headers: { "user-agent": `pify/${VERSION}`, accept: "application/json" },
441
+ redirect: "error"
442
+ });
443
+ clearTimeout(timer);
444
+ if (!res.ok) return null;
445
+ const data = await res.json();
446
+ return typeof data.version === "string" ? data.version : null;
447
+ } catch {
448
+ return null;
449
+ }
450
+ }
418
451
  function installedVersionOnDisk(name, scope) {
419
452
  const root = scope === "user" ? join2(agentDir(), "npm") : join2(process.cwd(), ".pi", "npm");
420
453
  try {
@@ -491,6 +524,12 @@ function validateCatalog(data) {
491
524
  if (typeof p.name !== "string" || !/^[a-z0-9-]+$/.test(p.name)) return false;
492
525
  if (typeof p.npm !== "string" || !p.npm.startsWith("@pify/")) return false;
493
526
  if (p.status !== "published" && p.status !== "planned") return false;
527
+ if (p.conflicts !== void 0) {
528
+ if (!Array.isArray(p.conflicts)) return false;
529
+ if (!p.conflicts.every((c2) => typeof c2 === "string" && /^(@[a-z0-9._-]+\/)?[a-z0-9._-]+$/.test(c2))) {
530
+ return false;
531
+ }
532
+ }
494
533
  }
495
534
  if (c.bundles !== void 0) {
496
535
  if (!Array.isArray(c.bundles)) return false;
@@ -776,7 +815,171 @@ async function remove(names, opts) {
776
815
  return 0;
777
816
  }
778
817
 
818
+ // src/commands/profile.ts
819
+ import { existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
820
+ import { dirname as dirname3, resolve } from "path";
821
+ var PROFILE_VERSION = 1;
822
+ function buildProfile(now = /* @__PURE__ */ new Date()) {
823
+ const installed = installedPifyPackages();
824
+ const packages = [...installed.values()].map((entry) => ({
825
+ name: entry.name,
826
+ version: installedVersionOnDisk(entry.name, entry.scope).version,
827
+ scope: entry.scope
828
+ })).sort((a, b) => a.name.localeCompare(b.name));
829
+ return { version: PROFILE_VERSION, createdAt: now.toISOString(), packages };
830
+ }
831
+ function parseProfile(raw) {
832
+ let parsed;
833
+ try {
834
+ parsed = JSON.parse(raw);
835
+ } catch {
836
+ throw usageError("That profile is not valid JSON.");
837
+ }
838
+ if (typeof parsed !== "object" || parsed === null) throw usageError("That profile is not an object.");
839
+ const candidate = parsed;
840
+ if (candidate.version !== PROFILE_VERSION) {
841
+ throw usageError(
842
+ `Unsupported profile version ${String(candidate.version)}.`,
843
+ `This pify writes and reads version ${PROFILE_VERSION}.`
844
+ );
845
+ }
846
+ if (!Array.isArray(candidate.packages)) throw usageError("That profile has no packages array.");
847
+ const packages = [];
848
+ for (const entry of candidate.packages) {
849
+ if (typeof entry !== "object" || entry === null) throw usageError("A profile entry is not an object.");
850
+ const e = entry;
851
+ if (typeof e.name !== "string" || !/^[a-z0-9._-]+$/.test(e.name)) {
852
+ throw usageError(`Invalid package name in profile: ${JSON.stringify(e.name)}`);
853
+ }
854
+ if (e.version !== null && typeof e.version !== "string") {
855
+ throw usageError(`Invalid version for ${e.name} in profile.`);
856
+ }
857
+ if (e.scope !== "user" && e.scope !== "project") {
858
+ throw usageError(`Invalid scope for ${e.name} in profile.`);
859
+ }
860
+ packages.push({ name: e.name, version: e.version, scope: e.scope });
861
+ }
862
+ return { version: PROFILE_VERSION, createdAt: String(candidate.createdAt ?? ""), packages };
863
+ }
864
+ function planApply(profile2, installedNow) {
865
+ const rows = [];
866
+ for (const entry of profile2.packages) {
867
+ const current = installedNow.get(entry.name);
868
+ if (!current) {
869
+ rows.push({ name: entry.name, action: "install", from: null, to: entry.version });
870
+ continue;
871
+ }
872
+ const onDisk = installedVersionOnDisk(entry.name, current.scope).version;
873
+ if (entry.version && onDisk && entry.version !== onDisk) {
874
+ rows.push({ name: entry.name, action: "change", from: onDisk, to: entry.version });
875
+ } else {
876
+ rows.push({ name: entry.name, action: "keep", from: onDisk, to: entry.version });
877
+ }
878
+ }
879
+ for (const [name] of installedNow) {
880
+ if (!profile2.packages.some((p) => p.name === name)) {
881
+ rows.push({ name, action: "extra", from: installedVersionOnDisk(name, "user").version, to: null });
882
+ }
883
+ }
884
+ return rows.sort((a, b) => a.name.localeCompare(b.name));
885
+ }
886
+ function describe(row) {
887
+ switch (row.action) {
888
+ case "install":
889
+ return `install ${row.to ? `@ ${row.to}` : "(latest)"}`;
890
+ case "change":
891
+ return `${row.from} \u2192 ${row.to}`;
892
+ case "keep":
893
+ return `already ${row.from ?? "installed"}`;
894
+ case "extra":
895
+ return `installed here, not in the profile (left alone)`;
896
+ }
897
+ }
898
+ async function profile(args, opts) {
899
+ const [sub, file] = args;
900
+ if (sub === "save") {
901
+ const target = resolve(file ?? "pify-profile.json");
902
+ const built = buildProfile();
903
+ if (built.packages.length === 0) {
904
+ warn("No @pify packages are installed; saving an empty profile.");
905
+ }
906
+ mkdirSync2(dirname3(target), { recursive: true });
907
+ writeFileSync2(target, `${JSON.stringify(built, null, 2)}
908
+ `);
909
+ success(`Saved ${built.packages.length} package(s) to ${target}`);
910
+ return 0;
911
+ }
912
+ if (sub === "apply") {
913
+ if (!file) throw usageError("Which profile?", "Usage: pify profile apply <file> [--yes]");
914
+ const target = resolve(file);
915
+ if (!existsSync(target)) throw notFoundError(`No profile at ${target}.`);
916
+ const parsed = parseProfile(readFileSync3(target, "utf8"));
917
+ const catalog = await loadCatalog();
918
+ for (const entry of parsed.packages) resolveInstallTarget(catalog, entry.name);
919
+ const rows = planApply(parsed, installedPifyPackages());
920
+ if (opts.json) {
921
+ out(JSON.stringify({ profile: target, plan: rows }, null, 2));
922
+ return 0;
923
+ }
924
+ const width = rows.reduce((m, r) => Math.max(m, r.name.length), 0);
925
+ out(style.bold(`Applying ${target}`));
926
+ out();
927
+ for (const row of rows) {
928
+ out(` ${row.action.padEnd(8)}${row.name.padEnd(width + 2)}${style.dim(describe(row))}`);
929
+ }
930
+ out();
931
+ const work = rows.filter((r) => r.action === "install" || r.action === "change");
932
+ if (work.length === 0) {
933
+ success("Nothing to do \u2014 this machine already matches the profile.");
934
+ return 0;
935
+ }
936
+ if (!opts.yes) {
937
+ hint(`${work.length} package(s) would change. Re-run with --yes to apply.`);
938
+ return 0;
939
+ }
940
+ requirePi();
941
+ const failed = [];
942
+ for (const row of work) {
943
+ const spec = row.to ? `npm:@pify/${row.name}@${row.to}` : `npm:@pify/${row.name}`;
944
+ const argv = ["install", spec];
945
+ step(`pi ${argv.join(" ")}`);
946
+ if (await delegate(argv) !== 0) failed.push(row.name);
947
+ }
948
+ if (failed.length > 0) {
949
+ throw new PifyError(`Failed for: ${failed.join(", ")}`, ExitCode.SUBPROCESS);
950
+ }
951
+ success(`Applied ${work.length} package(s).`);
952
+ return 0;
953
+ }
954
+ throw usageError(
955
+ `Unknown profile subcommand ${JSON.stringify(sub ?? "")}.`,
956
+ "Usage: pify profile save [file] | pify profile apply <file> [--yes]"
957
+ );
958
+ }
959
+
779
960
  // src/commands/update.ts
961
+ async function checkUpdates() {
962
+ const installed = installedPifyPackages();
963
+ const names = [...installed.keys()].sort();
964
+ const statuses = await Promise.all(
965
+ names.map(async (name) => {
966
+ const entry = installed.get(name);
967
+ const onDisk = installedVersionOnDisk(name, entry.scope);
968
+ const latest = await fetchLatestPackageVersion(name);
969
+ if (!onDisk.present) return { name, installed: null, latest, state: "missing" };
970
+ if (!onDisk.version || !latest) {
971
+ return { name, installed: onDisk.version, latest, state: "unknown" };
972
+ }
973
+ return {
974
+ name,
975
+ installed: onDisk.version,
976
+ latest,
977
+ state: compareSemver(onDisk.version, latest) < 0 ? "outdated" : "current"
978
+ };
979
+ })
980
+ );
981
+ return statuses;
982
+ }
780
983
  async function update(targets, opts) {
781
984
  if (isOffline()) {
782
985
  warn("PIFY_OFFLINE/PI_OFFLINE is set - updates skipped.");
@@ -794,6 +997,27 @@ async function update(targets, opts) {
794
997
  }
795
998
  return 0;
796
999
  }
1000
+ if (opts.check) {
1001
+ if (targets.length > 0) throw usageError("--check reports on everything; drop the package names.");
1002
+ const statuses = await checkUpdates();
1003
+ if (opts.json) {
1004
+ out(JSON.stringify({ packages: statuses }, null, 2));
1005
+ return 0;
1006
+ }
1007
+ if (statuses.length === 0) {
1008
+ hint("No @pify packages installed.");
1009
+ return 0;
1010
+ }
1011
+ const width = statuses.reduce((m, s) => Math.max(m, s.name.length), 0);
1012
+ for (const status of statuses) {
1013
+ const detail = status.state === "outdated" ? `${status.installed} \u2192 ${status.latest}` : status.state === "missing" ? "configured but not on disk" : status.state === "unknown" ? `${status.installed ?? "?"} (registry unreachable)` : `${status.installed}`;
1014
+ out(` ${status.state.padEnd(9)}${status.name.padEnd(width + 2)}${detail}`);
1015
+ }
1016
+ const outdated = statuses.filter((s) => s.state === "outdated").length;
1017
+ out();
1018
+ out(outdated > 0 ? `${outdated} package(s) can be updated: pify update` : "Everything is current.");
1019
+ return 0;
1020
+ }
797
1021
  const updateEverything = targets.length === 0;
798
1022
  const piOnly = targets.length === 1 && (targets[0] === "pi" || targets[0] === "self");
799
1023
  const installed = installedPifyPackages();
@@ -921,7 +1145,7 @@ async function list(opts) {
921
1145
  }
922
1146
 
923
1147
  // src/commands/doctor.ts
924
- import { existsSync } from "fs";
1148
+ import { existsSync as existsSync2 } from "fs";
925
1149
  async function doctor(opts) {
926
1150
  const checks = [];
927
1151
  const push = (id, status, detail) => checks.push({ id, status, detail });
@@ -987,7 +1211,7 @@ async function doctor(opts) {
987
1211
  for (const scope of ["user", "project"]) {
988
1212
  const id = scope === "user" ? "settings-user" : "settings-project";
989
1213
  const path = settingsPath(scope);
990
- if (!existsSync(path)) {
1214
+ if (!existsSync2(path)) {
991
1215
  push(id, "info", scope === "user" ? "not found (run pi once to create it)" : "none");
992
1216
  continue;
993
1217
  }
@@ -1015,6 +1239,26 @@ async function doctor(opts) {
1015
1239
  push("pify-packages", "ok", `${installed.size} configured, all present on disk`);
1016
1240
  }
1017
1241
  }
1242
+ const catalog = await loadCatalog();
1243
+ const configured = configuredNpmPackages();
1244
+ const configuredNames2 = new Set(configured.map((p) => p.name));
1245
+ const clashes = [];
1246
+ for (const entry of catalog.packages) {
1247
+ if (!entry.conflicts?.length) continue;
1248
+ if (!configuredNames2.has(entry.npm)) continue;
1249
+ for (const other of entry.conflicts) {
1250
+ if (configuredNames2.has(other)) clashes.push(`${entry.npm} + ${other}`);
1251
+ }
1252
+ }
1253
+ if (clashes.length === 0) {
1254
+ push("conflicts", "ok", `no conflicting packages among ${configured.length} configured`);
1255
+ } else {
1256
+ push(
1257
+ "conflicts",
1258
+ "warn",
1259
+ `${clashes.join("; ")} register the same commands or tools - remove one (pi remove npm:<name>)`
1260
+ );
1261
+ }
1018
1262
  const envVars = ["PI_OFFLINE", "PIFY_OFFLINE", "PI_CODING_AGENT_DIR", "NO_COLOR", "PIFY_CATALOG_URL"].filter((name) => process.env[name] !== void 0).map((name) => `${name}=${process.env[name]}`);
1019
1263
  push("env", "info", envVars.length > 0 ? envVars.join(", ") : "none set");
1020
1264
  const problems = checks.filter((c) => c.status === "fail").length;
@@ -1044,8 +1288,8 @@ async function doctor(opts) {
1044
1288
  }
1045
1289
 
1046
1290
  // src/commands/init.ts
1047
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, readdirSync, writeFileSync as writeFileSync2 } from "fs";
1048
- import { basename, join as join4, resolve } from "path";
1291
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, readdirSync, writeFileSync as writeFileSync3 } from "fs";
1292
+ import { basename, join as join4, resolve as resolve2 } from "path";
1049
1293
 
1050
1294
  // src/templates.ts
1051
1295
  function interpolate(template, vars) {
@@ -1185,8 +1429,8 @@ Tested with pi {{piVersion}}.
1185
1429
  var NPM_NAME = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
1186
1430
  async function init(dirArg, opts) {
1187
1431
  const dir = dirArg ?? ".";
1188
- const target = resolve(dir);
1189
- if (existsSync2(target) && readdirSync(target).length > 0) {
1432
+ const target = resolve2(dir);
1433
+ if (existsSync3(target) && readdirSync(target).length > 0) {
1190
1434
  throw new PifyError(
1191
1435
  `Target directory ${JSON.stringify(dir)} is not empty.`,
1192
1436
  ExitCode.FAILURE,
@@ -1209,7 +1453,7 @@ async function init(dirArg, opts) {
1209
1453
  piVersion: TESTED_PI_VERSION,
1210
1454
  dir: basename(target)
1211
1455
  };
1212
- mkdirSync2(target, { recursive: true });
1456
+ mkdirSync3(target, { recursive: true });
1213
1457
  const files = [
1214
1458
  ["package.json", interpolate(packageJsonTemplate(name.startsWith("@")), vars)],
1215
1459
  ["index.ts", interpolate(INDEX_TS, vars)],
@@ -1218,7 +1462,7 @@ async function init(dirArg, opts) {
1218
1462
  ["README.md", interpolate(README_MD, vars)]
1219
1463
  ];
1220
1464
  for (const [file, content] of files) {
1221
- writeFileSync2(join4(target, file), content);
1465
+ writeFileSync3(join4(target, file), content);
1222
1466
  }
1223
1467
  success(`Created ${files.length} files in ${basename(target)}/`);
1224
1468
  for (const [file] of files) out(` ${file}`);
@@ -1277,6 +1521,8 @@ var COMMAND_SPECS = [
1277
1521
  positional: "updateTargets",
1278
1522
  flags: [
1279
1523
  { name: "catalog", description: "Refresh the catalog cache only" },
1524
+ { name: "check", description: "Report what is out of date and change nothing" },
1525
+ { name: "json", description: "Machine-readable output (with --check)" },
1280
1526
  { name: "dry-run", description: "Print the update plan without executing" }
1281
1527
  ]
1282
1528
  },
@@ -1294,6 +1540,16 @@ var COMMAND_SPECS = [
1294
1540
  positional: "none",
1295
1541
  flags: [{ name: "json", description: "Machine-readable output" }]
1296
1542
  },
1543
+ {
1544
+ name: "profile",
1545
+ aliases: [],
1546
+ description: "Save the installed suite to a file, or apply one",
1547
+ positional: "profile",
1548
+ flags: [
1549
+ { name: "yes", short: "y", description: "Apply the plan instead of only showing it" },
1550
+ { name: "json", description: "Machine-readable plan" }
1551
+ ]
1552
+ },
1297
1553
  {
1298
1554
  name: "init",
1299
1555
  aliases: [],
@@ -1343,6 +1599,8 @@ function positionalWords(spec) {
1343
1599
  return ["pi", ...packageNames()];
1344
1600
  case "shell":
1345
1601
  return [...COMPLETION_SHELLS];
1602
+ case "profile":
1603
+ return ["save", "apply"];
1346
1604
  default:
1347
1605
  return [];
1348
1606
  }
@@ -1495,6 +1753,7 @@ Commands:
1495
1753
  update [pi|<name...>] Update pi and installed @pify packages
1496
1754
  list Show the @pify catalog with install state
1497
1755
  doctor Diagnose the local pi/pify environment
1756
+ profile save|apply Save the installed suite to a file, or apply one
1498
1757
  init [dir] Scaffold a new Pi Package
1499
1758
  completions <shell> Print a completion script (bash|zsh|fish|powershell)
1500
1759
 
@@ -1557,6 +1816,21 @@ Examples:
1557
1816
 
1558
1817
  # Full native experience (Windows: install.ps1, Unix: install.sh)
1559
1818
  pify setup --installer
1819
+ `,
1820
+ profile: `Usage:
1821
+ pify profile save [file] # default: pify-profile.json
1822
+ pify profile apply <file> [--yes]
1823
+
1824
+ Write down the suite you actually run \u2014 which packages, at which versions \u2014
1825
+ so a second machine can reproduce it. Applying always prints the plan first
1826
+ and changes nothing without --yes.
1827
+
1828
+ A package installed here but absent from the profile is reported and left
1829
+ alone: a profile says what must be present, not what must be removed.
1830
+
1831
+ Options:
1832
+ -y, --yes Apply the plan instead of only showing it
1833
+ --json Machine-readable plan
1560
1834
  `,
1561
1835
  install: `Usage:
1562
1836
  pify install <name...> [-l] [-a] [--dry-run]
@@ -1682,6 +1956,7 @@ function commandHelp(command) {
1682
1956
 
1683
1957
  // src/cli.ts
1684
1958
  var RUNNERS = {
1959
+ profile: (p, v) => profile(p, { yes: Boolean(v.yes), json: Boolean(v.json) }),
1685
1960
  setup: (_p, v) => setup({
1686
1961
  force: Boolean(v.force),
1687
1962
  piVersion: v["pi-version"],
@@ -1697,7 +1972,12 @@ var RUNNERS = {
1697
1972
  approve: Boolean(v.approve),
1698
1973
  dryRun: Boolean(v["dry-run"])
1699
1974
  }),
1700
- update: (p, v) => update(p, { catalogOnly: Boolean(v.catalog), dryRun: Boolean(v["dry-run"]) }),
1975
+ update: (p, v) => update(p, {
1976
+ catalogOnly: Boolean(v.catalog),
1977
+ dryRun: Boolean(v["dry-run"]),
1978
+ check: Boolean(v.check),
1979
+ json: Boolean(v.json)
1980
+ }),
1701
1981
  list: (_p, v) => list({ json: Boolean(v.json) }),
1702
1982
  doctor: (_p, v) => doctor({ json: Boolean(v.json) }),
1703
1983
  init: (p, v) => init(p[0], {
package/dist/index.d.ts CHANGED
@@ -1,6 +1,37 @@
1
1
  /** CLI version, read from the packaged package.json (always in the tarball). */
2
2
  declare const VERSION: string;
3
3
 
4
+ interface ProfileEntry {
5
+ name: string;
6
+ /** Exact version recorded at save time, or null when it was unknown. */
7
+ version: string | null;
8
+ scope: "user" | "project";
9
+ }
10
+ interface Profile {
11
+ version: number;
12
+ createdAt: string;
13
+ packages: ProfileEntry[];
14
+ }
15
+ declare function buildProfile(now?: Date): Profile;
16
+ /** Read a profile file, refusing anything this version cannot honour. */
17
+ declare function parseProfile(raw: string): Profile;
18
+ type PlanAction = "install" | "change" | "keep" | "extra";
19
+ interface PlanRow {
20
+ name: string;
21
+ action: PlanAction;
22
+ from: string | null;
23
+ to: string | null;
24
+ }
25
+ /**
26
+ * What applying this profile would do. Packages installed here but absent from
27
+ * the profile are reported as `extra` and never removed: a profile says what
28
+ * must be present, and deleting whatever else someone installed is not a
29
+ * decision this command gets to make silently.
30
+ */
31
+ declare function planApply(profile: Profile, installedNow: Map<string, {
32
+ scope: "user" | "project";
33
+ }>): PlanRow[];
34
+
4
35
  interface CatalogPackage {
5
36
  /** Short name used on the pify command line, e.g. "goal". */
6
37
  name: string;
@@ -10,6 +41,12 @@ interface CatalogPackage {
10
41
  description: string;
11
42
  /** "published" once it exists on the npm registry; "planned" before that. */
12
43
  status: "published" | "planned";
44
+ /**
45
+ * npm packages that cannot run alongside this one — they register the same
46
+ * command, tool, or flag, so pi loads both and one silently wins. Each entry
47
+ * is documented in that package's own README (v0.4).
48
+ */
49
+ conflicts?: string[];
13
50
  }
14
51
  /** A named set of packages installable in one go, e.g. `pify install suite`. */
15
52
  interface CatalogBundle {
@@ -161,10 +198,6 @@ interface InstalledPifyPackage {
161
198
  source: string;
162
199
  pin: string | null;
163
200
  }
164
- /**
165
- * @pify packages configured in pi's settings, both scopes, deduped by name
166
- * (project wins, matching pi's own scope precedence).
167
- */
168
201
  declare function installedPifyPackages(): Map<string, InstalledPifyPackage>;
169
202
  interface OnDiskState {
170
203
  present: boolean;
@@ -196,7 +229,7 @@ interface FlagSpec {
196
229
  takesValue?: boolean;
197
230
  description: string;
198
231
  }
199
- type PositionalKind = "none" | "packages" | "updateTargets" | "directory" | "shell";
232
+ type PositionalKind = "none" | "packages" | "updateTargets" | "directory" | "shell" | "profile";
200
233
  interface CommandSpec {
201
234
  name: string;
202
235
  aliases: string[];
@@ -259,4 +292,4 @@ declare class PifyError extends Error {
259
292
  constructor(message: string, code?: ExitCodeValue, hint?: string);
260
293
  }
261
294
 
262
- export { COMMAND_SPECS, COMPLETION_SHELLS, type Catalog, type CatalogBundle, type CatalogPackage, type CommandSpec, type CompletionShell, ExitCode, type FlagSpec, GLOBAL_FLAGS, INDEX_TS, type InstallSpec, type InstallTarget, type InstalledPifyPackage, NODE_FLOOR, PI_PACKAGE, type PiSettings, type PiStatus, PifyError, README_MD, TESTED_PI_VERSION, type TemplateVars, VERSION, agentDir, assertSafeArg, compareSemver, expandBundles, fetchLatestPiVersion, findBundle, generateCompletion, installPi, installedPifyPackages, installedVersionOnDisk, interpolate, isOffline, loadBundledCatalog, loadCatalog, npmInstallPiArgs, officialInstallerCommand, packageJsonTemplate, parseArgsOptionsFor, parseInstallSpec, piStatus, readSettings, refreshCatalog, resolveInstallTarget, resolvePifyName, updatePi, validateCatalog, which };
295
+ export { COMMAND_SPECS, COMPLETION_SHELLS, type Catalog, type CatalogBundle, type CatalogPackage, type CommandSpec, type CompletionShell, ExitCode, type FlagSpec, GLOBAL_FLAGS, INDEX_TS, type InstallSpec, type InstallTarget, type InstalledPifyPackage, NODE_FLOOR, PI_PACKAGE, type PiSettings, type PiStatus, PifyError, type PlanRow, type Profile, type ProfileEntry, README_MD, TESTED_PI_VERSION, type TemplateVars, VERSION, agentDir, assertSafeArg, buildProfile, compareSemver, expandBundles, fetchLatestPiVersion, findBundle, generateCompletion, installPi, installedPifyPackages, installedVersionOnDisk, interpolate, isOffline, loadBundledCatalog, loadCatalog, npmInstallPiArgs, officialInstallerCommand, packageJsonTemplate, parseArgsOptionsFor, parseInstallSpec, parseProfile, piStatus, planApply, readSettings, refreshCatalog, resolveInstallTarget, resolvePifyName, updatePi, validateCatalog, which };
package/dist/index.js CHANGED
@@ -5,6 +5,10 @@ import { createRequire } from "module";
5
5
  var require2 = createRequire(import.meta.url);
6
6
  var VERSION = require2("../package.json").version;
7
7
 
8
+ // src/commands/profile.ts
9
+ import { existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
10
+ import { dirname as dirname3, resolve } from "path";
11
+
8
12
  // src/catalog.ts
9
13
  import { readFileSync, writeFileSync, mkdirSync } from "fs";
10
14
  import { homedir } from "os";
@@ -90,14 +94,14 @@ function runCapture(command, args) {
90
94
  }
91
95
  function runInherit(command, args) {
92
96
  const [cmd, argv, opts] = spawnPlan(command, args);
93
- return new Promise((resolve, reject) => {
97
+ return new Promise((resolve2, reject) => {
94
98
  const child = spawn(cmd, argv, {
95
99
  ...opts,
96
100
  stdio: "inherit",
97
101
  windowsHide: true
98
102
  });
99
103
  child.on("error", reject);
100
- child.on("close", (code) => resolve(code ?? 1));
104
+ child.on("close", (code) => resolve2(code ?? 1));
101
105
  });
102
106
  }
103
107
  function which(command) {
@@ -149,6 +153,12 @@ function validateCatalog(data) {
149
153
  if (typeof p.name !== "string" || !/^[a-z0-9-]+$/.test(p.name)) return false;
150
154
  if (typeof p.npm !== "string" || !p.npm.startsWith("@pify/")) return false;
151
155
  if (p.status !== "published" && p.status !== "planned") return false;
156
+ if (p.conflicts !== void 0) {
157
+ if (!Array.isArray(p.conflicts)) return false;
158
+ if (!p.conflicts.every((c2) => typeof c2 === "string" && /^(@[a-z0-9._-]+\/)?[a-z0-9._-]+$/.test(c2))) {
159
+ return false;
160
+ }
161
+ }
152
162
  }
153
163
  if (c.bundles !== void 0) {
154
164
  if (!Array.isArray(c.bundles)) return false;
@@ -581,6 +591,73 @@ function installedVersionOnDisk(name, scope) {
581
591
  }
582
592
  }
583
593
 
594
+ // src/commands/profile.ts
595
+ var PROFILE_VERSION = 1;
596
+ function buildProfile(now = /* @__PURE__ */ new Date()) {
597
+ const installed = installedPifyPackages();
598
+ const packages = [...installed.values()].map((entry) => ({
599
+ name: entry.name,
600
+ version: installedVersionOnDisk(entry.name, entry.scope).version,
601
+ scope: entry.scope
602
+ })).sort((a, b) => a.name.localeCompare(b.name));
603
+ return { version: PROFILE_VERSION, createdAt: now.toISOString(), packages };
604
+ }
605
+ function parseProfile(raw) {
606
+ let parsed;
607
+ try {
608
+ parsed = JSON.parse(raw);
609
+ } catch {
610
+ throw usageError("That profile is not valid JSON.");
611
+ }
612
+ if (typeof parsed !== "object" || parsed === null) throw usageError("That profile is not an object.");
613
+ const candidate = parsed;
614
+ if (candidate.version !== PROFILE_VERSION) {
615
+ throw usageError(
616
+ `Unsupported profile version ${String(candidate.version)}.`,
617
+ `This pify writes and reads version ${PROFILE_VERSION}.`
618
+ );
619
+ }
620
+ if (!Array.isArray(candidate.packages)) throw usageError("That profile has no packages array.");
621
+ const packages = [];
622
+ for (const entry of candidate.packages) {
623
+ if (typeof entry !== "object" || entry === null) throw usageError("A profile entry is not an object.");
624
+ const e = entry;
625
+ if (typeof e.name !== "string" || !/^[a-z0-9._-]+$/.test(e.name)) {
626
+ throw usageError(`Invalid package name in profile: ${JSON.stringify(e.name)}`);
627
+ }
628
+ if (e.version !== null && typeof e.version !== "string") {
629
+ throw usageError(`Invalid version for ${e.name} in profile.`);
630
+ }
631
+ if (e.scope !== "user" && e.scope !== "project") {
632
+ throw usageError(`Invalid scope for ${e.name} in profile.`);
633
+ }
634
+ packages.push({ name: e.name, version: e.version, scope: e.scope });
635
+ }
636
+ return { version: PROFILE_VERSION, createdAt: String(candidate.createdAt ?? ""), packages };
637
+ }
638
+ function planApply(profile, installedNow) {
639
+ const rows = [];
640
+ for (const entry of profile.packages) {
641
+ const current = installedNow.get(entry.name);
642
+ if (!current) {
643
+ rows.push({ name: entry.name, action: "install", from: null, to: entry.version });
644
+ continue;
645
+ }
646
+ const onDisk = installedVersionOnDisk(entry.name, current.scope).version;
647
+ if (entry.version && onDisk && entry.version !== onDisk) {
648
+ rows.push({ name: entry.name, action: "change", from: onDisk, to: entry.version });
649
+ } else {
650
+ rows.push({ name: entry.name, action: "keep", from: onDisk, to: entry.version });
651
+ }
652
+ }
653
+ for (const [name] of installedNow) {
654
+ if (!profile.packages.some((p) => p.name === name)) {
655
+ rows.push({ name, action: "extra", from: installedVersionOnDisk(name, "user").version, to: null });
656
+ }
657
+ }
658
+ return rows.sort((a, b) => a.name.localeCompare(b.name));
659
+ }
660
+
584
661
  // src/registry.ts
585
662
  var GLOBAL_FLAGS = [
586
663
  { name: "help", short: "h", description: "Show help" },
@@ -628,6 +705,8 @@ var COMMAND_SPECS = [
628
705
  positional: "updateTargets",
629
706
  flags: [
630
707
  { name: "catalog", description: "Refresh the catalog cache only" },
708
+ { name: "check", description: "Report what is out of date and change nothing" },
709
+ { name: "json", description: "Machine-readable output (with --check)" },
631
710
  { name: "dry-run", description: "Print the update plan without executing" }
632
711
  ]
633
712
  },
@@ -645,6 +724,16 @@ var COMMAND_SPECS = [
645
724
  positional: "none",
646
725
  flags: [{ name: "json", description: "Machine-readable output" }]
647
726
  },
727
+ {
728
+ name: "profile",
729
+ aliases: [],
730
+ description: "Save the installed suite to a file, or apply one",
731
+ positional: "profile",
732
+ flags: [
733
+ { name: "yes", short: "y", description: "Apply the plan instead of only showing it" },
734
+ { name: "json", description: "Machine-readable plan" }
735
+ ]
736
+ },
648
737
  {
649
738
  name: "init",
650
739
  aliases: [],
@@ -694,6 +783,8 @@ function positionalWords(spec) {
694
783
  return ["pi", ...packageNames()];
695
784
  case "shell":
696
785
  return [...COMPLETION_SHELLS];
786
+ case "profile":
787
+ return ["save", "apply"];
697
788
  default:
698
789
  return [];
699
790
  }
@@ -954,6 +1045,7 @@ export {
954
1045
  VERSION,
955
1046
  agentDir,
956
1047
  assertSafeArg,
1048
+ buildProfile,
957
1049
  compareSemver,
958
1050
  expandBundles,
959
1051
  fetchLatestPiVersion,
@@ -971,7 +1063,9 @@ export {
971
1063
  packageJsonTemplate,
972
1064
  parseArgsOptionsFor,
973
1065
  parseInstallSpec,
1066
+ parseProfile,
974
1067
  piStatus,
1068
+ planApply,
975
1069
  readSettings,
976
1070
  refreshCatalog,
977
1071
  resolveInstallTarget,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/cli",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "Front door for the Pify suite: install and update the pi coding agent, manage @pify packages, and scaffold Pi Packages",
5
5
  "keywords": [
6
6
  "pify",