@gaosh3n/pi-package-manager 0.1.2 → 0.1.3

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
@@ -9,6 +9,7 @@
9
9
  - `/package-manager status` to see whether updates are available
10
10
  - `/package-manager update` to run the update flow on demand
11
11
  - `/package-manager install` to install a Pi package from an entered source
12
+ - `/package-manager uninstall` to remove one or more installed Pi packages from a checkbox-style picker
12
13
  - a final result card in Pi so you can review the latest outcome
13
14
  - automatic Pi reload after a successful startup update
14
15
 
@@ -55,3 +56,13 @@ Run:
55
56
  ```
56
57
 
57
58
  Pi will prompt you for a package source such as `npm:@foo/bar` or `git:github.com/user/repo`, run the native `pi install ...` flow, and show a final result card. After a successful install, run `/reload` to activate the installed package resources.
59
+
60
+ ### Uninstall package(s)
61
+
62
+ Run:
63
+
64
+ ```text
65
+ /package-manager uninstall
66
+ ```
67
+
68
+ Pi will show a checkbox-style package picker. Use <space> to toggle package selection, then press Enter to confirm. Pi runs native `pi uninstall <source>` once per selected package and shows one final result card. After successful or partial removal, run `/reload` to deactivate removed package resources.
package/index.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  createAutoUpdateResultReport,
28
28
  createInstallResultReport,
29
29
  createReportEntryRenderer,
30
+ createUninstallResultReport,
30
31
  createStatusReport,
31
32
  formatStatusLines,
32
33
  formatUtcTimestamp,
@@ -51,9 +52,9 @@ export default function initPackageManager(
51
52
 
52
53
  pi.registerCommand("package-manager", {
53
54
  description:
54
- "Manage Pi packages (usage: /package-manager [status|update|install])",
55
+ "Manage Pi packages (usage: /package-manager [status|update|install|uninstall])",
55
56
  getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => {
56
- const items = ["status", "update", "install"].map((value) => ({
57
+ const items = ["status", "update", "install", "uninstall"].map((value) => ({
57
58
  value,
58
59
  label: value,
59
60
  }))
@@ -84,7 +85,15 @@ export default function initPackageManager(
84
85
  return
85
86
  }
86
87
 
87
- ctx.ui.notify("Usage: /package-manager [status|update|install]", "warning")
88
+ if (subcommand === "uninstall") {
89
+ await controller.handleUninstall(ctx)
90
+ return
91
+ }
92
+
93
+ ctx.ui.notify(
94
+ "Usage: /package-manager [status|update|install|uninstall]",
95
+ "warning",
96
+ )
88
97
  },
89
98
  })
90
99
  }
@@ -97,6 +106,7 @@ export {
97
106
  createAutoUpdateRecord,
98
107
  createAutoUpdateResultReport,
99
108
  createInstallResultReport,
109
+ createUninstallResultReport,
100
110
  createStatusReport,
101
111
  formatStatusLines,
102
112
  formatUtcTimestamp,
@@ -17,11 +17,13 @@ import {
17
17
  createInstallResultReport,
18
18
  createStatusErrorReport,
19
19
  createStatusReport,
20
+ createUninstallResultReport,
20
21
  getExecDisplayOutput,
21
22
  getExecFailureDetail,
22
23
  setPackageManagerWidget,
23
24
  } from "./reports.ts"
24
25
  import { defaultPackageManagerDeps, type PackageManagerDeps } from "./runtime.ts"
26
+ import { promptForPackagesToUninstall } from "./uninstall-picker.ts"
25
27
 
26
28
  export function createPackageManagerController(
27
29
  pi: Pick<ExtensionAPI, "appendEntry" | "sendUserMessage" | "exec">,
@@ -32,6 +34,7 @@ export function createPackageManagerController(
32
34
  handleStatus,
33
35
  handleUpdate,
34
36
  handleInstall,
37
+ handleUninstall,
35
38
  }
36
39
 
37
40
  async function onSessionStart(
@@ -136,7 +139,7 @@ export function createPackageManagerController(
136
139
  endedAtUtc: deps.nowIso(),
137
140
  outcome: "failed",
138
141
  packagesUpdated: 0,
139
- reason: getExecFailureDetail(result),
142
+ reason: getExecFailureDetail(result, "Package update command failed."),
140
143
  })
141
144
 
142
145
  appendAutoUpdateRecordAndReport(
@@ -221,7 +224,10 @@ export function createPackageManagerController(
221
224
  source,
222
225
  outcome: "failed",
223
226
  output,
224
- reason: getExecFailureDetail(result),
227
+ reason: getExecFailureDetail(
228
+ result,
229
+ "Package install command failed.",
230
+ ),
225
231
  }),
226
232
  )
227
233
  } catch (error) {
@@ -240,6 +246,124 @@ export function createPackageManagerController(
240
246
  }
241
247
  }
242
248
 
249
+ async function handleUninstall(ctx: ExtensionCommandContext): Promise<void> {
250
+ if (ctx.mode !== "tui") {
251
+ ctx.ui.notify("/package-manager uninstall requires TUI mode.", "warning")
252
+ return
253
+ }
254
+
255
+ const packages = await deps.listConfiguredPackages(ctx)
256
+
257
+ if (packages.length === 0) {
258
+ ctx.ui.notify("No Pi packages are available to uninstall.", "info")
259
+ return
260
+ }
261
+
262
+ const selectedSources = await promptForPackagesToUninstall(ctx, packages)
263
+
264
+ if (selectedSources === undefined) {
265
+ return
266
+ }
267
+
268
+ if (selectedSources.length === 0) {
269
+ ctx.ui.notify("Select at least one package to uninstall.", "warning")
270
+ return
271
+ }
272
+
273
+ const selectedSourceSet = new Set(selectedSources)
274
+ const sources = packages
275
+ .map((pkg) => pkg.source)
276
+ .filter((source) => selectedSourceSet.has(source))
277
+
278
+ if (sources.length === 0) {
279
+ ctx.ui.notify("Select at least one package to uninstall.", "warning")
280
+ return
281
+ }
282
+
283
+ const startedAtUtc = deps.nowIso()
284
+ const succeededSources: string[] = []
285
+ const failedSources: string[] = []
286
+ const outputSections: string[] = []
287
+
288
+ try {
289
+ for (const [index, source] of sources.entries()) {
290
+ setPackageManagerWidget(ctx, {
291
+ mode: "package-uninstalling",
292
+ current: index + 1,
293
+ total: sources.length,
294
+ source,
295
+ })
296
+
297
+ const result = await deps.runNativeUninstall(pi, ctx, source)
298
+ const output = getExecDisplayOutput(result)
299
+
300
+ if (result.code === 0) {
301
+ if (output) {
302
+ outputSections.push(`[${source}]\n${output}`)
303
+ }
304
+ succeededSources.push(source)
305
+ continue
306
+ }
307
+
308
+ failedSources.push(source)
309
+ outputSections.push(
310
+ `[${source}]\n${output ?? getExecFailureDetail(result, "Package uninstall command failed.")}`,
311
+ )
312
+ }
313
+
314
+ pi.appendEntry(
315
+ REPORT_ENTRY_TYPE,
316
+ createUninstallResultReport({
317
+ startedAtUtc,
318
+ endedAtUtc: deps.nowIso(),
319
+ sources,
320
+ outcome:
321
+ failedSources.length === 0
322
+ ? "succeeded"
323
+ : succeededSources.length > 0
324
+ ? "partial"
325
+ : "failed",
326
+ succeededSources,
327
+ failedSources,
328
+ output:
329
+ outputSections.length > 0
330
+ ? outputSections.join("\n\n")
331
+ : undefined,
332
+ reason:
333
+ failedSources.length > 0
334
+ ? `Failed to uninstall ${failedSources[0]}.`
335
+ : undefined,
336
+ }),
337
+ )
338
+ } catch (error) {
339
+ const failedSource = sources[succeededSources.length]
340
+ const errorMessage = getErrorMessage(error)
341
+
342
+ if (failedSource && !failedSources.includes(failedSource)) {
343
+ failedSources.push(failedSource)
344
+ }
345
+
346
+ outputSections.push(
347
+ failedSource ? `[${failedSource}]\n${errorMessage}` : errorMessage,
348
+ )
349
+ pi.appendEntry(
350
+ REPORT_ENTRY_TYPE,
351
+ createUninstallResultReport({
352
+ startedAtUtc,
353
+ endedAtUtc: deps.nowIso(),
354
+ sources,
355
+ outcome: succeededSources.length > 0 ? "partial" : "failed",
356
+ succeededSources,
357
+ failedSources,
358
+ output: outputSections.join("\n\n"),
359
+ reason: errorMessage,
360
+ }),
361
+ )
362
+ } finally {
363
+ clearPackageManagerWidget(ctx)
364
+ }
365
+ }
366
+
243
367
  function appendSkippedResult(startedAtUtc: string, reason: string): void {
244
368
  const record = createAutoUpdateRecord({
245
369
  startedAtUtc,
package/internal/model.ts CHANGED
@@ -6,9 +6,11 @@ export const RELOAD_COUNTDOWN_SECONDS = 5
6
6
  export const OUTPUT_PREVIEW_LINE_COUNT = 8
7
7
  export const UPDATE_COMMAND = ["update", "--extensions"] as const
8
8
  export const INSTALL_COMMAND = ["install"] as const
9
+ export const UNINSTALL_COMMAND = ["uninstall"] as const
9
10
 
10
11
  export type AutoUpdateOutcome = "succeeded" | "failed" | "skipped"
11
12
  export type InstallOutcome = "succeeded" | "failed"
13
+ export type UninstallOutcome = "succeeded" | "partial" | "failed"
12
14
  export type ReportTone = "info" | "success" | "warning" | "error"
13
15
 
14
16
  export type WidgetState =
@@ -17,6 +19,12 @@ export type WidgetState =
17
19
  | { mode: "installing"; packages: number }
18
20
  | { mode: "countdown"; secondsRemaining: number }
19
21
  | { mode: "package-installing"; source: string }
22
+ | {
23
+ mode: "package-uninstalling"
24
+ current: number
25
+ total: number
26
+ source: string
27
+ }
20
28
 
21
29
  export interface AutoUpdateRecord {
22
30
  startedAtUtc: string
@@ -43,3 +51,9 @@ export interface PackageStatusSnapshot {
43
51
  availableUpdates: string[]
44
52
  lastAutoUpdate?: AutoUpdateRecord
45
53
  }
54
+
55
+ export interface ConfiguredPackageOption {
56
+ source: string
57
+ scope: "user" | "project"
58
+ filtered: boolean
59
+ }
@@ -14,6 +14,7 @@ import {
14
14
  type InstallOutcome,
15
15
  type PackageManagerReport,
16
16
  type PackageStatusSnapshot,
17
+ type UninstallOutcome,
17
18
  type WidgetState,
18
19
  } from "./model.ts"
19
20
 
@@ -246,6 +247,53 @@ export function createInstallResultReport(input: {
246
247
  }
247
248
  }
248
249
 
250
+ export function createUninstallResultReport(input: {
251
+ startedAtUtc: string
252
+ endedAtUtc: string
253
+ sources: string[]
254
+ outcome: UninstallOutcome
255
+ succeededSources: string[]
256
+ failedSources: string[]
257
+ output?: string
258
+ reason?: string
259
+ }): PackageManagerReport {
260
+ return {
261
+ title: PACKAGE_MANAGER_TITLE,
262
+ headline:
263
+ input.outcome === "succeeded"
264
+ ? "Pi package uninstall completed."
265
+ : input.outcome === "partial"
266
+ ? "Pi package uninstall partially completed."
267
+ : "Pi package uninstall failed.",
268
+ tone:
269
+ input.outcome === "succeeded"
270
+ ? "success"
271
+ : input.outcome === "partial"
272
+ ? "warning"
273
+ : "error",
274
+ lines: [
275
+ `Start: ${formatUtcTimestamp(input.startedAtUtc)}`,
276
+ `End: ${formatUtcTimestamp(input.endedAtUtc)}`,
277
+ `Result: ${input.outcome}`,
278
+ `Packages selected: ${input.sources.length}`,
279
+ ...input.sources.map((source) => `Package source: ${source}`),
280
+ `Packages removed: ${input.succeededSources.length}`,
281
+ ...(input.failedSources.length > 0
282
+ ? [`Packages failed: ${input.failedSources.length}`]
283
+ : ["Run /reload to deactivate removed package resources."]),
284
+ ...(input.reason ? [`Latest failure detail: ${input.reason}`] : []),
285
+ ],
286
+ lineTone: "dim",
287
+ output: input.output,
288
+ outputLabel:
289
+ input.outcome === "succeeded" ? "Uninstall output:" : "Error detail:",
290
+ outputDescription:
291
+ input.outcome === "succeeded" ? "uninstall output" : "error detail",
292
+ outputTone: "dim",
293
+ hideOutputWhenCollapsed: true,
294
+ }
295
+ }
296
+
249
297
  export function createAutomaticUpdateWidgetLines(state: WidgetState): string[] {
250
298
  if (state.mode === "status-checking") {
251
299
  return ["Pi package status in progress.", "Checking for package updates..."]
@@ -272,6 +320,13 @@ export function createAutomaticUpdateWidgetLines(state: WidgetState): string[] {
272
320
  ]
273
321
  }
274
322
 
323
+ if (state.mode === "package-uninstalling") {
324
+ return [
325
+ "Pi package uninstall in progress.",
326
+ `Removing ${state.current}/${state.total}: ${state.source}`,
327
+ ]
328
+ }
329
+
275
330
  return [
276
331
  formatAutomaticUpdateHeadline("succeeded"),
277
332
  `Reloading in ${state.secondsRemaining} second${state.secondsRemaining === 1 ? "" : "s"} to activate updated package resources.`,
@@ -349,10 +404,13 @@ export function getExecDisplayOutput(result: ExecResult): string | undefined {
349
404
  return sections.length > 0 ? sections.join("\n\n") : undefined
350
405
  }
351
406
 
352
- export function getExecFailureDetail(result: ExecResult): string {
407
+ export function getExecFailureDetail(
408
+ result: ExecResult,
409
+ fallback = "Package command failed.",
410
+ ): string {
353
411
  const stderr = result.stderr.trim()
354
412
  const stdout = result.stdout.trim()
355
- const detail = stderr || stdout || "Package update command failed."
413
+ const detail = stderr || stdout || fallback
356
414
 
357
415
  return detail.length > 400 ? `${detail.slice(0, 397)}...` : detail
358
416
  }
@@ -8,13 +8,19 @@ import {
8
8
  type ExtensionContext,
9
9
  } from "@earendil-works/pi-coding-agent"
10
10
 
11
- import { INSTALL_COMMAND, UPDATE_COMMAND } from "./model.ts"
11
+ import {
12
+ INSTALL_COMMAND,
13
+ UNINSTALL_COMMAND,
14
+ UPDATE_COMMAND,
15
+ type ConfiguredPackageOption,
16
+ } from "./model.ts"
12
17
 
13
18
  export interface PackageManagerDeps {
14
19
  nowIso(): string
15
20
  sleep(milliseconds: number): Promise<void>
16
21
  isOffline(): boolean
17
22
  checkForAvailableUpdates(ctx: ExtensionContext): Promise<string[]>
23
+ listConfiguredPackages(ctx: ExtensionContext): Promise<ConfiguredPackageOption[]>
18
24
  runNativeUpdate(
19
25
  pi: Pick<ExtensionAPI, "exec">,
20
26
  ctx: ExtensionCommandContext,
@@ -24,6 +30,11 @@ export interface PackageManagerDeps {
24
30
  ctx: ExtensionCommandContext,
25
31
  source: string,
26
32
  ): Promise<ExecResult>
33
+ runNativeUninstall(
34
+ pi: Pick<ExtensionAPI, "exec">,
35
+ ctx: ExtensionCommandContext,
36
+ source: string,
37
+ ): Promise<ExecResult>
27
38
  }
28
39
 
29
40
  export const defaultPackageManagerDeps: PackageManagerDeps = {
@@ -35,21 +46,25 @@ export const defaultPackageManagerDeps: PackageManagerDeps = {
35
46
  },
36
47
  isOffline: () => Boolean(process.env.PI_OFFLINE),
37
48
  async checkForAvailableUpdates(ctx: ExtensionContext): Promise<string[]> {
38
- const agentDir = getAgentDir()
39
- const settingsManager = SettingsManager.create(ctx.cwd, agentDir, {
40
- projectTrusted: ctx.isProjectTrusted(),
41
- })
42
- const packageManager = new DefaultPackageManager({
43
- cwd: ctx.cwd,
44
- agentDir,
45
- settingsManager,
46
- })
49
+ const packageManager = createDefaultPackageManager(ctx)
47
50
  const updates = await packageManager.checkForAvailableUpdates()
48
51
 
49
52
  return updates
50
53
  .map((update) => update.displayName)
51
54
  .sort((left, right) => left.localeCompare(right))
52
55
  },
56
+ async listConfiguredPackages(
57
+ ctx: ExtensionContext,
58
+ ): Promise<ConfiguredPackageOption[]> {
59
+ return createDefaultPackageManager(ctx)
60
+ .listConfiguredPackages()
61
+ .map((pkg) => ({
62
+ source: pkg.source,
63
+ scope: pkg.scope,
64
+ filtered: pkg.filtered,
65
+ }))
66
+ .sort((left, right) => left.source.localeCompare(right.source))
67
+ },
53
68
  runNativeUpdate(pi: Pick<ExtensionAPI, "exec">, ctx: ExtensionCommandContext) {
54
69
  return pi.exec("pi", [...UPDATE_COMMAND], {
55
70
  cwd: ctx.cwd,
@@ -66,4 +81,27 @@ export const defaultPackageManagerDeps: PackageManagerDeps = {
66
81
  signal: ctx.signal,
67
82
  })
68
83
  },
84
+ runNativeUninstall(
85
+ pi: Pick<ExtensionAPI, "exec">,
86
+ ctx: ExtensionCommandContext,
87
+ source: string,
88
+ ) {
89
+ return pi.exec("pi", [...UNINSTALL_COMMAND, source], {
90
+ cwd: ctx.cwd,
91
+ signal: ctx.signal,
92
+ })
93
+ },
94
+ }
95
+
96
+ function createDefaultPackageManager(ctx: ExtensionContext): DefaultPackageManager {
97
+ const agentDir = getAgentDir()
98
+ const settingsManager = SettingsManager.create(ctx.cwd, agentDir, {
99
+ projectTrusted: ctx.isProjectTrusted(),
100
+ })
101
+
102
+ return new DefaultPackageManager({
103
+ cwd: ctx.cwd,
104
+ agentDir,
105
+ settingsManager,
106
+ })
69
107
  }
@@ -0,0 +1,144 @@
1
+ import {
2
+ DynamicBorder,
3
+ type ExtensionCommandContext,
4
+ type Theme,
5
+ } from "@earendil-works/pi-coding-agent"
6
+ import {
7
+ Container,
8
+ Key,
9
+ SelectList,
10
+ Text,
11
+ type Component,
12
+ type SelectItem,
13
+ type TUI,
14
+ matchesKey,
15
+ } from "@earendil-works/pi-tui"
16
+
17
+ import type { ConfiguredPackageOption } from "./model.ts"
18
+
19
+ export async function promptForPackagesToUninstall(
20
+ ctx: Pick<ExtensionCommandContext, "ui">,
21
+ packages: ConfiguredPackageOption[],
22
+ ): Promise<string[] | undefined> {
23
+ return ctx.ui.custom<string[] | undefined>(
24
+ (tui, theme, _keybindings, done) => {
25
+ const picker = new PackageUninstallPicker(tui, theme, packages)
26
+ picker.done = done
27
+ return picker
28
+ },
29
+ {
30
+ overlay: true,
31
+ overlayOptions: {
32
+ width: "70%",
33
+ minWidth: 48,
34
+ maxHeight: "80%",
35
+ },
36
+ },
37
+ )
38
+ }
39
+
40
+ class PackageUninstallPicker implements Component {
41
+ private readonly container = new Container()
42
+ private readonly title = new Text("", 1, 0)
43
+ private readonly summary = new Text("", 1, 0)
44
+ private readonly footer = new Text("", 1, 0)
45
+ private readonly checkedSources = new Set<string>()
46
+ private readonly items: SelectItem[]
47
+ private readonly selectList: SelectList
48
+ private readonly tui: TUI
49
+ private readonly theme: Theme
50
+
51
+ constructor(tui: TUI, theme: Theme, packages: ConfiguredPackageOption[]) {
52
+ this.tui = tui
53
+ this.theme = theme
54
+ this.items = packages.map((pkg) => ({
55
+ value: pkg.source,
56
+ label: formatPackageOptionLabel(pkg.source, false),
57
+ }))
58
+ this.selectList = new SelectList(this.items, Math.min(this.items.length, 10), {
59
+ selectedPrefix: (text) => this.theme.fg("accent", text),
60
+ selectedText: (text) => this.theme.fg("accent", text),
61
+ description: (text) => this.theme.fg("muted", text),
62
+ scrollInfo: (text) => this.theme.fg("dim", text),
63
+ noMatch: (text) => this.theme.fg("warning", text),
64
+ })
65
+
66
+ this.selectList.onSelect = () => {
67
+ this.done?.(Array.from(this.checkedSources))
68
+ }
69
+ this.selectList.onCancel = () => {
70
+ this.done?.(undefined)
71
+ }
72
+
73
+ this.container.addChild(
74
+ new DynamicBorder((text: string) => this.theme.fg("accent", text)),
75
+ )
76
+ this.container.addChild(this.title)
77
+ this.container.addChild(this.summary)
78
+ this.container.addChild(this.selectList)
79
+ this.container.addChild(this.footer)
80
+ this.container.addChild(
81
+ new DynamicBorder((text: string) => this.theme.fg("accent", text)),
82
+ )
83
+
84
+ this.refreshText()
85
+ }
86
+
87
+ done?: (value: string[] | undefined) => void
88
+
89
+ handleInput(data: string): void {
90
+ if (matchesKey(data, Key.space)) {
91
+ this.toggleSelectedItem()
92
+ this.tui.requestRender()
93
+ return
94
+ }
95
+
96
+ this.selectList.handleInput(data)
97
+ this.tui.requestRender()
98
+ }
99
+
100
+ invalidate(): void {
101
+ this.container.invalidate()
102
+ this.refreshText()
103
+ }
104
+
105
+ render(width: number): string[] {
106
+ return this.container.render(width)
107
+ }
108
+
109
+ private refreshText(): void {
110
+ this.title.setText(
111
+ this.theme.fg("accent", this.theme.bold("Select Pi Packages to Uninstall")),
112
+ )
113
+ this.summary.setText(
114
+ this.theme.fg(
115
+ "dim",
116
+ `${this.checkedSources.size} selected • enter confirm • esc cancel`,
117
+ ),
118
+ )
119
+ this.footer.setText(this.theme.fg("dim", "↑↓ navigate • space toggle checkbox"))
120
+ }
121
+
122
+ private toggleSelectedItem(): void {
123
+ const selectedItem = this.selectList.getSelectedItem()
124
+
125
+ if (!selectedItem) {
126
+ return
127
+ }
128
+
129
+ if (this.checkedSources.has(selectedItem.value)) {
130
+ this.checkedSources.delete(selectedItem.value)
131
+ selectedItem.label = formatPackageOptionLabel(selectedItem.value, false)
132
+ } else {
133
+ this.checkedSources.add(selectedItem.value)
134
+ selectedItem.label = formatPackageOptionLabel(selectedItem.value, true)
135
+ }
136
+
137
+ this.selectList.invalidate()
138
+ this.refreshText()
139
+ }
140
+ }
141
+
142
+ function formatPackageOptionLabel(source: string, checked: boolean): string {
143
+ return `${checked ? "[x]" : "[ ]"} ${source}`
144
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaosh3n/pi-package-manager",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Package Manager for Pi",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.23.0",