promptdock 1.2.1-rc.15.1 → 1.2.2

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
@@ -7,7 +7,9 @@ Codex, Gemini CLI, Cursor, GitHub Copilot, and more.
7
7
  npx promptdock@latest install <handle>/<slug>
8
8
  ```
9
9
 
10
- ## 60-second quickstart
10
+ ## Quickstart
11
+
12
+ Signed in: about 1-2 minutes. New account by email: about 2-5 minutes.
11
13
 
12
14
  Replace `<handle>/<slug>` with a real skill's ref — every skill page on
13
15
  [promptdock.ai/skills](https://www.promptdock.ai/skills) shows its own install command,
@@ -19,17 +21,17 @@ npx promptdock@latest install <handle>/<slug>
19
21
 
20
22
  # 2) Approve the login code in your browser when prompted (one time per machine)
21
23
  # No account yet? The page it opens has a sign-up link: create the account,
22
- # confirm the email it sends, then approve. The code lasts 15 minutes for
23
- # exactly this, the terminal waits, and if it does run out the CLI issues a
24
- # fresh one once.
24
+ # confirm the email it sends, then enter the code from your terminal if the
25
+ # page asks for it. The code lasts 15 minutes for exactly this, the terminal
26
+ # waits, and if it does run out the CLI issues a fresh one once.
25
27
 
26
28
  # 3) Start a NEW session in Claude Code and type /<slug>
27
29
  ```
28
30
 
29
31
  **Auth model, stated plainly: login is required.** A free PromptDock account
30
- installs free skills — no card, no subscription. Premium skills need the
31
- Explorer plan ($10/mo) or higher. `login` opens your browser; you approve a
32
- one-time code and the terminal continues. The token lives in
32
+ installs free skills — no card, no subscription. Premium skills come with any
33
+ paid plan ([pricing](https://www.promptdock.ai/pricing)). `login` opens your
34
+ browser; you approve a one-time code and the terminal continues. The token lives in
33
35
  `~/.promptdock/config.json` (owner-only permissions; on Windows the user
34
36
  profile's ACL is the boundary).
35
37
 
@@ -42,11 +44,13 @@ profile's ACL is the boundary).
42
44
  | `whoami [--json]` | Show the signed-in account + token expiry |
43
45
  | `install <ref> [options]` | Install a skill (`ref` = `handle/slug`, `@handle/slug`, or a pasted URL) |
44
46
  | `uninstall <ref \| --all>` | Remove an installed skill (receipt-driven — see below) |
45
- | `update [ref] [--all] [--check]` | Update to the latest approved version (`upgrade` is an alias) |
47
+ | `update [ref] [--all] [--check]` | Update installed skills to the latest approved version (`upgrade` is an alias; it updates skills, not this CLI: `npm i -g promptdock@latest`) |
46
48
  | `list [--json] [-g] [--all]` | Show installed skills |
47
49
 
48
50
  Install options: `-g/--global` (per-user dir), `--target <tool>`, `--dir <path>`,
49
- `-y/--yes` (skip prompts — **never** implies `--force`), `--force`, `--dry-run`
51
+ `-y/--yes` (skip prompts — **never** implies `--force`), `--force` (replace the files
52
+ you edited and your files at paths the new version ships, keeping every other file
53
+ you added; or replace another skill's folder, listing what it removed), `--dry-run`
50
54
  (print the plan, install nothing), `--json`.
51
55
 
52
56
  Update/uninstall options: `--check` (update only — report, apply nothing), `--all`,
@@ -149,19 +153,25 @@ confirms from painting a picker over your JSON.
149
153
  | --- | --- |
150
154
  | `whoami --json` | `{ handle, role, expires_at }` |
151
155
  | `list --json` | `[{ dir, target, scope, ref, version, version_id, installed_at, schema }]` — an unreadable receipt yields `{ dir, target, scope, receipt: "newer_schema" \| "invalid" }` instead |
152
- | `install --json` | `{ installed: true, update, ref, version, version_id, target, path, files }` |
153
- | `install --json` (nothing to do) | `{ installed: false, up_to_date: true, update: true, ref, version, version_id, target, path }` |
154
- | `install --dry-run --json` | `{ dry_run: true, ref, skill_id, version_id, version, title, author, is_free, file_count, total_bytes, target, path, update }` |
156
+ | `install --json` | `{ installed: true, update, ref, version, version_id, target, path, files, kept_files, replaced?, removed? }` — `replaced` under `--force`, `removed` when another skill's folder was replaced |
157
+ | `install --json` (nothing to do) | `{ installed: false, up_to_date: true, update: true, ref, version, version_id, target, path, changes? }` |
158
+ | `install --dry-run --json` | `{ dry_run: true, ref, skill_id, version_id, version, title, author, is_free, file_count, total_bytes, target, path, update, changes? }` |
155
159
  | `update --json` | `{ results: [ … ] }` — always, including when nothing is installed (`{ results: [] }`) |
156
160
  | `uninstall --json` | `{ uninstalled: [{ ref, dir }], results: [ … ] }` |
157
161
 
158
- A `results` row is `{ ref, dir, status, reason?, failed?, exit?, from?, to? }`:
162
+ A `results` row is `{ ref, dir, status, reason?, failed?, exit?, code?, changes?, kept_files?, replaced?, from?, to? }`:
159
163
 
160
164
  - `status` — `updated` · `up_to_date` · `available` (`--check`) · `uninstalled` ·
161
165
  `skipped` (declined, or failed).
162
166
  - `failed` — present and `true` **only** for a real failure. This is the field to
163
167
  branch on; `status` keeps the vocabulary 1.2.0 shipped.
164
168
  - `exit` — that failure's own exit code. The process exits with the first one.
169
+ - `code` — that failure's docs anchor (`local_changes`, `download`, …), from 1.2.2.
170
+ - `changes` — `[{ path, reason }]` for the local changes that blocked (or, on an
171
+ `available` row from `--check`, that would block); `reason` is `modified`,
172
+ `path_conflict` or `not_regular_file`.
173
+ - `kept_files` / `replaced` — on an `updated` row: the files of yours that were kept,
174
+ and what `--force` replaced.
165
175
  - `from`/`to` — version numbers, on `update` rows only.
166
176
 
167
177
  A **failure** that produced no report of its own is written to stdout as one document:
@@ -170,6 +180,8 @@ A **failure** that produced no report of its own is written to stdout as one doc
170
180
  { "error": { "code": "existing_dir", "message": "…", "exit_code": 5, "doc_url": "https://www.promptdock.ai/docs/cli/errors#existing_dir" } }
171
181
  ```
172
182
 
183
+ A `local_changes` refusal adds `"changes": [{ "path", "reason" }]` to `error`.
184
+
173
185
  `code` is the docs anchor (`"error"` when the failure has none, and `doc_url` then
174
186
  points at the index). `update`/`uninstall` do **not** add this — their `results` report
175
187
  already answers the same question per item — so stdout is always exactly one JSON
@@ -189,15 +201,24 @@ skill id, ref, version, and the sha256 of every installed file. It is the
189
201
  project; `-g` the global scope; `--all` both.
190
202
  - `uninstall` removes **only** files the receipt lists, then the receipt, then
191
203
  any emptied directories. Files you added are never touched.
192
- - If you **edited** installed files, update/uninstall refuse without `--force`
193
- ("copy your changes out first") — your local changes never die to a default.
204
+ - `install` and `update` **keep the files you added** (and your folders and links,
205
+ which are never followed) when they install a new version.
206
+ - If you **edited** installed files, or put a file of yours where the new version
207
+ ships one, `install`, `update` and `uninstall` stop with exit 4 and change nothing.
208
+ The message lists each file with its reason and both ways out: move the files out
209
+ and run the same command again, or add `--force`, which replaces the listed files
210
+ and keeps every other file you added. Line endings alone (a CRLF checkout) are not
211
+ an edit.
194
212
  - A receipt written by a newer CLI version is left alone with a notice naming
195
213
  the exact `npx promptdock@latest …` command to re-run — never a guessy
196
214
  uninstall. It counts as a failed item (see [batch exit codes](#batch-exit-codes)).
197
215
 
198
216
  Downloads are verified against the server manifest (per-file sha256) and every
199
217
  path is re-validated before any write; a failed check aborts **all-or-nothing**
200
- ("integrity check failed — nothing installed").
218
+ ("integrity check failed — nothing installed"). Files download into a temporary
219
+ folder above the skills folder, so an interrupted install never leaves a half-copied
220
+ skill where your agent looks for skills; Ctrl-C removes it, and if an update is cut
221
+ off mid-swap the next promptdock command puts your folder back.
201
222
 
202
223
  ## Exit codes & errors
203
224
 
@@ -207,10 +228,10 @@ path is re-validated before any write; a failed check aborts **all-or-nothing**
207
228
  | 1 | usage (bad flags, missing `--target`/`-y` in CI) |
208
229
  | 2 | auth (not logged in / token rejected) |
209
230
  | 3 | denied (tier gate, daily cap, denial, rate limit, version floor, not found) |
210
- | 4 | integrity (sha mismatch, unsafe manifest path, receipt schema) |
231
+ | 4 | integrity or a local-safety conflict: checksum, unsafe path, receipt schema or local changes (an edited file, a file where the new version ships one, a special file) that an install or update would otherwise destroy |
211
232
  | 5 | filesystem (permissions, disk space, non-empty directory) |
212
- | 6 | network |
213
- | 130 | interrupted (`Ctrl-C` during an interactive prompt; `Esc` — a deliberate in-UI cancel — exits 1) |
233
+ | 6 | network (including a connection that drops mid-download) |
234
+ | 130 | interrupted (`Ctrl-C` during a prompt or a download; `Esc` — a deliberate in-UI cancel — exits 1) |
214
235
 
215
236
  Every named error links `https://www.promptdock.ai/docs/cli/errors#<code>`. That page
216
237
  lists every code with what happened, why, and what to do.
@@ -252,11 +273,36 @@ updates everything else and then exits `3` for the one it could not do (see
252
273
 
253
274
  - Node.js ≥ 18. Zero runtime dependencies.
254
275
  - The CLI always installs the **latest approved** version of a skill (no semver
255
- pinning); every published skill has passed PromptDock's human review queue.
256
- Review is a screening step, not a warranty — read what you install.
276
+ pinning). Skills that members submit are reviewed by a person before they go
277
+ live; review is a screening step, not a warranty — read what you install.
257
278
  - Skills are plain files on your machine after install; premium gates access,
258
279
  not copying.
259
280
 
281
+ ## Changes
282
+
283
+ - **1.2.2:**
284
+ - `install` refuses to overwrite files you edited (exit 4). Before, it exited 0
285
+ and the edits were lost.
286
+ - `install` and `update` keep the files you added; before, they deleted them.
287
+ - A connection that drops during a download exits 6 (was 1) and names the file;
288
+ expired download links are refreshed once, by themselves.
289
+ - CRLF checkouts no longer count as edits.
290
+ - Temporary files live above the skills folder; Ctrl-C removes them, and the next
291
+ command restores a folder an interrupted update set aside.
292
+ - `promptdock upgrade` says it updates your skills, not the CLI.
293
+ - The receipt schema stays 1.
294
+ - Why older versions are refused for installs and updates: promptdock 1.2.1 and
295
+ older can overwrite files you edited and delete files you added when they update
296
+ a skill, so installing and updating need 1.2.2 or later; login, list and
297
+ uninstall still work. 1.2.1's `update --all` prints that refusal once per
298
+ installed skill.
299
+ - Did 1.2.1 overwrite your edits? Re-running `install` over an edited skill
300
+ replaced the edited files and deleted the ones you had added. Compare the folder
301
+ with your version control.
302
+ - **1.2.1:** the premium refusal names no price: "Premium skills come with any
303
+ paid plan", with the pricing link (the old "$10/mo" was wrong in euros and
304
+ rupees).
305
+
260
306
  ## License
261
307
 
262
308
  [MIT](./LICENSE) © Prompt Dock, Inc.
package/dist/auth.js CHANGED
@@ -34,13 +34,19 @@ export async function deviceFlowLogin(ctx, baseUrl, opts) {
34
34
  ctx.openUrl(start.verification_url);
35
35
  ctx.io.out("(if the page didn't open, paste the URL into your browser)");
36
36
  }
37
- ctx.io.out("");
38
37
  // Clamp through Number.isFinite (review find): a missing/renamed numeric in
39
38
  // the start response made `deadline` NaN, and `remainMs <= 0` is FALSE for
40
39
  // NaN — an infinite countdown spin. A malformed numeric now degrades to the
41
40
  // protocol defaults instead of hanging the login.
42
41
  const expiresIn = Number(start.expires_in);
43
- const deadline = ctx.now() + (Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : 600) * 1000;
42
+ const lifetimeSecs = Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : 600;
43
+ // DX-A4: a member signing up on that page confirms their email first, and the
44
+ // confirmation link can land them on a page that asks for this code. Say so here,
45
+ // where they still have the code in front of them.
46
+ const minutes = Math.max(1, Math.floor(lifetimeSecs / 60));
47
+ ctx.io.out(`New here? Sign up on that page and confirm your email. If a page then asks for a code, enter ${terminalLine(start.user_code, SERVER_LABEL_MAX)}. This terminal waits up to ${minutes} ${minutes === 1 ? "minute" : "minutes"}.`);
48
+ ctx.io.out("");
49
+ const deadline = ctx.now() + lifetimeSecs * 1000;
44
50
  const interval0 = Number(start.interval);
45
51
  let intervalSecs = Number.isFinite(interval0) && interval0 > 0 ? interval0 : 5;
46
52
  poll: for (;;) {
@@ -9,6 +9,8 @@ import { newerSchemaError, rerunCommand } from "./lifecycle.js";
9
9
  import { CliError, EXIT, usageError } from "../errors.js";
10
10
  import { SERVER_LABEL_MAX, SERVER_PATH_MAX, terminalLine } from "../terminal-text.js";
11
11
  import { checkInstallResponse, performInstall } from "../installer.js";
12
+ import { sweepAndReport, tempRootsFor } from "../leftovers.js";
13
+ import { changeRows, changesJson, defaultPathKey, forceLines, keptLine, localChangesRefusal, receiptSideChanges, unlistedEntries, } from "../local-changes.js";
12
14
  import { formatRef, parseSkillRef } from "../ref.js";
13
15
  import { detectionLabel, detectTargets, detectTargetsAnyScope, nextStepLine, scopeChoices, targetById, targetInstallDir, TARGETS, } from "../registry.js";
14
16
  import { readReceipt } from "../receipts.js";
@@ -37,6 +39,10 @@ export async function runInstall(ctx, positionals, flags) {
37
39
  }
38
40
  // ── target resolution (D-UX11) ─────────────────────────────────────────────
39
41
  const picked = await resolveTarget(ctx, flags, ref);
42
+ // An interrupted install or update may have left this skill's folder set aside: put it
43
+ // back BEFORE reading its receipt (ENG-A4). A dry run writes nothing, so it skips this.
44
+ if (!dryRun)
45
+ sweepAndReport(ctx, tempRootsFor(picked.dir));
40
46
  // ── receipt awareness (DX3/DX5) ────────────────────────────────────────────
41
47
  const existing = readReceipt(picked.dir);
42
48
  if (existing.kind === "newer_schema") {
@@ -46,26 +52,15 @@ export async function runInstall(ctx, positionals, flags) {
46
52
  ? existing.receipt
47
53
  : null;
48
54
  const isUpdate = matching !== null;
49
- if (!isUpdate && !dryRun && flags.force !== true && dirIsNonEmpty(picked.dir)) {
50
- // The ONLY error whose remedy is --force (DX3).
51
- const what = existing.kind === "ok" ? `a different skill (${existing.receipt.ref})` : "files";
52
- throw new CliError(`${picked.dir} already exists and contains ${what} — pass --force to replace it`, EXIT.IO, { footer: "existing_dir" });
53
- }
54
- if (isUpdate && matching.version_id === resolve.version_id && flags.force !== true && !dryRun) {
55
- ctx.io.out(json
56
- ? JSON.stringify({
57
- installed: false,
58
- up_to_date: true,
59
- update: true,
60
- ref: refString,
61
- version: matching.version,
62
- version_id: matching.version_id,
63
- target: picked.id,
64
- path: picked.dir,
65
- })
66
- : `"${terminalLine(resolve.title, SERVER_LABEL_MAX) || refString}" is already up to date (v${matching.version}) at ${picked.dir} — use --force to reinstall.`);
67
- return;
68
- }
55
+ const force = flags.force === true;
56
+ const occupied = dirIsNonEmpty(picked.dir);
57
+ const existingTarget = matching
58
+ ? { kind: "update", receipt: matching }
59
+ : !occupied
60
+ ? { kind: "none" }
61
+ : existing.kind === "ok"
62
+ ? { kind: "foreign", receipt: existing.receipt }
63
+ : { kind: "unmanaged" };
69
64
  // ⚠️ SANITIZE THE FRAGMENTS, NOT THE LINES. The summary pads its labels into
70
65
  // columns (" version: v3"), and `terminalLine` collapses whitespace runs — one
71
66
  // pass over a finished line would flatten every one of them.
@@ -82,6 +77,56 @@ export async function runInstall(ctx, positionals, flags) {
82
77
  // files are about to be written. A crafted directory name could erase it, which
83
78
  // defeats confirm-before-write itself.
84
79
  const safeDir = terminalLine(picked.dir, SERVER_PATH_MAX);
80
+ // ── local changes the receipt can see (I1, DX-A1): before the confirm prompt and
81
+ // before the metered install request. Reads only, so a dry run reports them too.
82
+ const localChanges = matching
83
+ ? receiptSideChanges(picked.dir, matching, unlistedEntries(picked.dir, matching, defaultPathKey(ctx.platform)))
84
+ : [];
85
+ if (!isUpdate && !dryRun && !force && occupied) {
86
+ // The ONLY error whose remedy is --force (DX3). What --force does differs by what is
87
+ // there (DX-A1): another skill's folder is replaced and listed; files with no receipt
88
+ // (what an uninstall that kept your files leaves) are kept.
89
+ throw new CliError(existing.kind === "ok"
90
+ ? `${picked.dir} already holds a different skill (${terminalLine(existing.receipt.ref, SERVER_LABEL_MAX)}) — pass --force to replace it (the CLI lists what it removes)`
91
+ : `${picked.dir} already exists and contains files — pass --force to install into it; your files are kept unless the new version ships the same path`, EXIT.IO, { footer: "existing_dir" });
92
+ }
93
+ if (isUpdate && matching.version_id === resolve.version_id && !force && !dryRun) {
94
+ if (json) {
95
+ ctx.io.out(JSON.stringify({
96
+ installed: false,
97
+ up_to_date: true,
98
+ update: true,
99
+ ref: refString,
100
+ version: matching.version,
101
+ version_id: matching.version_id,
102
+ target: picked.id,
103
+ path: picked.dir,
104
+ ...(localChanges.length > 0 ? { changes: changesJson(localChanges) } : {}),
105
+ }));
106
+ return;
107
+ }
108
+ if (localChanges.length === 0) {
109
+ ctx.io.out(`"${safeTitle}" is already up to date (v${ver(matching.version)}) at ${safeDir} — use --force to reinstall.`);
110
+ return;
111
+ }
112
+ // DX-A1: a member with edits is NOT sent straight to the flag that replaces them.
113
+ const k = localChanges.length;
114
+ ctx.io.out(`"${safeTitle}" is up to date (v${ver(matching.version)}) at ${safeDir}, with ${k} local ${k === 1 ? "change" : "changes"}:`);
115
+ for (const row of changeRows(localChanges, null))
116
+ ctx.io.out(row);
117
+ ctx.io.out("--force would replace them and keep every other file you added.");
118
+ return;
119
+ }
120
+ if (isUpdate && localChanges.length > 0 && !force && !dryRun) {
121
+ throw localChangesRefusal({
122
+ verb: "install",
123
+ title: safeTitle,
124
+ from: ver(matching.version),
125
+ to: ver(resolve.version),
126
+ dir: picked.dir,
127
+ changes: localChanges,
128
+ });
129
+ }
85
130
  // ── summary block (D-UX11 confirm-before-write) ────────────────────────────
86
131
  const summary = [];
87
132
  if (isUpdate) {
@@ -113,11 +158,18 @@ export async function runInstall(ctx, positionals, flags) {
113
158
  target: picked.id,
114
159
  path: picked.dir,
115
160
  update: isUpdate,
161
+ ...(localChanges.length > 0 ? { changes: changesJson(localChanges) } : {}),
116
162
  }));
117
163
  return;
118
164
  }
119
165
  for (const line of summary)
120
166
  ctx.io.out(line);
167
+ if (localChanges.length > 0 && !force && matching?.version_id !== resolve.version_id) {
168
+ const k = localChanges.length;
169
+ ctx.io.out(`${k} local ${k === 1 ? "change" : "changes"} would block this install:`);
170
+ for (const row of changeRows(localChanges, ver(resolve.version)))
171
+ ctx.io.out(row);
172
+ }
121
173
  ctx.io.out("");
122
174
  ctx.io.out(c.dim("Dry run — nothing was installed (the file list is fetched and verified at install)."));
123
175
  return;
@@ -136,31 +188,44 @@ export async function runInstall(ctx, positionals, flags) {
136
188
  }
137
189
  }
138
190
  // ── two-phase install ──────────────────────────────────────────────────────
139
- const respRaw = await api.request("POST", `/api/v1/cli/skills/${resolve.skill_id}/install`, { version_id: resolve.version_id });
140
- const resp = checkInstallResponse(respRaw);
141
- if (!json) {
142
- ctx.io.out(`Downloading ${resp.manifest.length} files…`);
143
- const shown = resp.manifest.slice(0, 8);
144
- // ⚠️ DISPLAY ONLY. `e.path` names the file `performInstall` writes and the sha256
145
- // it verifies against, so the value itself must stay byte-identical — sanitizing
146
- // it at the source would change what the integrity check runs on. Note these are
147
- // printed BEFORE `assertSafeManifest` validates them (checkInstallResponse →
148
- // print → performInstall → validate), so a path that is about to be REFUSED is
149
- // rendered here; that ordering is what makes sanitizing this line necessary.
150
- for (const e of shown) {
151
- ctx.io.out(` ${terminalLine(e.path, SERVER_PATH_MAX)} ${c.dim(`(${formatBytes(e.bytes)})`)}`);
152
- }
153
- if (resp.manifest.length > shown.length) {
154
- ctx.io.out(c.dim(` +${resp.manifest.length - shown.length} more`));
155
- }
156
- }
157
- const receipt = await performInstall(ctx, api, {
191
+ // The same POST serves I9's one re-sign: `record_skill_install` is idempotent per
192
+ // member and skill, so a second call refreshes the links and records nothing new.
193
+ const postInstall = async () => checkInstallResponse(await api.request("POST", `/api/v1/cli/skills/${resolve.skill_id}/install`, {
194
+ version_id: resolve.version_id,
195
+ }));
196
+ const resp = await postInstall();
197
+ const outcome = await performInstall(ctx, api, {
158
198
  resp,
159
199
  targetDir: picked.dir,
160
200
  targetId: picked.id,
161
201
  refString,
162
- prior: matching ?? undefined,
202
+ existing: existingTarget,
203
+ force,
204
+ verb: "install",
205
+ title: safeTitle,
206
+ resign: postInstall,
207
+ onDownload: json
208
+ ? undefined
209
+ : (r) => {
210
+ ctx.io.out(`Downloading ${r.manifest.length} files…`);
211
+ const shown = r.manifest.slice(0, 8);
212
+ // ⚠️ DISPLAY ONLY. `e.path` names the file `performInstall` writes and the
213
+ // sha256 it verifies against, so the value itself must stay byte-identical —
214
+ // sanitizing it at the source would change what the integrity check runs on.
215
+ // These are printed BEFORE `assertSafeManifest` validates them, so a path that
216
+ // is about to be REFUSED is rendered here; that ordering is what makes
217
+ // sanitizing this line necessary.
218
+ for (const e of shown) {
219
+ ctx.io.out(` ${terminalLine(e.path, SERVER_PATH_MAX)} ${c.dim(`(${formatBytes(e.bytes)})`)}`);
220
+ }
221
+ if (r.manifest.length > shown.length) {
222
+ ctx.io.out(c.dim(` +${r.manifest.length - shown.length} more`));
223
+ }
224
+ },
163
225
  });
226
+ const { receipt } = outcome;
227
+ for (const w of outcome.warnings)
228
+ ctx.io.err(`${c.yellow("!")} ${w}`);
164
229
  if (json) {
165
230
  ctx.io.out(JSON.stringify({
166
231
  installed: true,
@@ -171,11 +236,19 @@ export async function runInstall(ctx, positionals, flags) {
171
236
  target: picked.id,
172
237
  path: picked.dir,
173
238
  files: receipt.files.length,
239
+ kept_files: outcome.kept,
240
+ ...(force ? { replaced: outcome.replaced.map(({ path, reason }) => ({ path, reason })) } : {}),
241
+ ...(outcome.removed.length > 0 ? { removed: outcome.removed } : {}),
174
242
  }));
175
243
  return;
176
244
  }
177
245
  ctx.io.out("");
178
- ctx.io.out(`${c.green("✓")} ${isUpdate ? "Updated" : "Installed"} "${terminalLine(resp.skill.title, SERVER_LABEL_MAX)}" v${terminalLine(String(resp.skill.version), SERVER_LABEL_MAX)} → ${terminalLine(picked.dir, SERVER_PATH_MAX)}`);
246
+ ctx.io.out(`${c.green("✓")} ${isUpdate ? "Updated" : "Installed"} "${terminalLine(resp.skill.title, SERVER_LABEL_MAX)}" v${terminalLine(String(receipt.version), SERVER_LABEL_MAX)} → ${terminalLine(picked.dir, SERVER_PATH_MAX)}`);
247
+ const kept = keptLine(outcome.kept);
248
+ if (kept)
249
+ ctx.io.out(kept);
250
+ for (const line of forceLines(outcome.replaced, outcome.removed))
251
+ ctx.io.out(line);
179
252
  ctx.io.out(nextStepLine(picked.id, resp.skill.slug));
180
253
  }
181
254
  /**