@pi-stef/catalog 0.3.5 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
  * `ct add` subcommand implementation.
3
3
  *
4
4
  * Adds a new package to the catalog. Supports:
5
- * - Full args: `ct add <name> <source> [--rating <r>] [--type <t>]`
5
+ * - Full args: `ct add <source> [--type <t>]`
6
6
  * - Git source without `--type`: prompts for type via `ctx.ui.select()`
7
7
  * - After adding, runs `pi install` to install the package
8
8
  *
@@ -10,9 +10,11 @@
10
10
  * and `writeCatalog` / `readCatalog` for persistence.
11
11
  */
12
12
 
13
- import type { RatingValue } from "../catalog/ratings.js";
14
13
  import type { CommandArgs, CommandCtx } from "./types.js";
15
14
  import { addPackage } from "../catalog/crud.js";
15
+ import { sourceToKey } from "../catalog/source.js";
16
+ import { checkSetupForSource, formatSetupStatus } from "../catalog/setup.js";
17
+ import { PI_STEF_PACKAGES } from "../catalog/packages.js";
16
18
  import { readCatalog, writeCatalog } from "../config/io.js";
17
19
  import { piInstall } from "../util/exec.js";
18
20
 
@@ -34,25 +36,6 @@ export interface AddCtx extends CommandCtx {
34
36
  // Helpers
35
37
  // ---------------------------------------------------------------------------
36
38
 
37
- const VALID_RATINGS: RatingValue[] = ["core", "useful", "debatable"];
38
-
39
- function isValidRating(value: string): value is RatingValue {
40
- return VALID_RATINGS.includes(value as RatingValue);
41
- }
42
-
43
- function resolveRating(flags: Record<string, true | string>): RatingValue {
44
- const raw =
45
- "r" in flags
46
- ? flags["r"]
47
- : "rating" in flags
48
- ? flags["rating"]
49
- : undefined;
50
-
51
- if (raw === true || raw === undefined) return "core";
52
- if (typeof raw === "string" && isValidRating(raw)) return raw;
53
- return "core";
54
- }
55
-
56
39
  function resolveType(
57
40
  flags: Record<string, true | string>,
58
41
  ): "skill" | "pi-native" | undefined {
@@ -75,24 +58,115 @@ function resolveType(
75
58
  /**
76
59
  * Execute the `ct add` subcommand.
77
60
  *
61
+ * New syntax (preferred): `ct add <source> [--type ...]`
62
+ * — name is auto-derived from source via `sourceToKey()`.
63
+ *
64
+ * Legacy syntax (deprecated): `ct add <name> <source> [--type ...]`
65
+ * — still accepted but emits a deprecation warning.
66
+ *
78
67
  * Reads the catalog, validates inputs, prompts for type if needed,
79
68
  * adds the package, writes the catalog, and runs `pi install`.
80
69
  */
81
70
  export async function addCommand(args: CommandArgs, ctx: AddCtx): Promise<void> {
82
71
  const { positional, flags } = args;
83
- const name = positional[0];
84
- const source = positional[1];
85
72
 
86
- // --- Validate required args -----------------------------------------------
87
- if (!name || !source) {
73
+ // --- Handle --scope batch mode ---------------------------------------------
74
+ if ("scope" in flags) {
75
+ const scope = flags["scope"];
76
+ if (scope !== "@pi-stef") {
77
+ ctx.ui.notify(`Unsupported scope: "${scope}". Use --scope @pi-stef.`, "error");
78
+ return;
79
+ }
80
+
81
+ const catalog = readCatalog(ctx.home);
82
+ let added = 0;
83
+ let skipped = 0;
84
+ let currentCatalog = catalog;
85
+
86
+ for (const pkg of PI_STEF_PACKAGES) {
87
+ const npmSource = `npm:${pkg}`;
88
+
89
+ // Skip if already in catalog
90
+ if (currentCatalog.packages[pkg]) {
91
+ skipped++;
92
+ continue;
93
+ }
94
+
95
+ try {
96
+ currentCatalog = addPackage(currentCatalog, pkg, npmSource);
97
+ added++;
98
+ } catch (err: unknown) {
99
+ // Unexpected validation error — warn but continue
100
+ ctx.ui.notify(
101
+ `Warning: failed to add "${pkg}": ${err instanceof Error ? err.message : String(err)}`,
102
+ "warning",
103
+ );
104
+ skipped++;
105
+ }
106
+ }
107
+
108
+ if (added > 0) {
109
+ writeCatalog(currentCatalog, ctx.home);
110
+ }
111
+
112
+ // Install all added packages
113
+ const setupWarnings: string[] = [];
114
+ if (added > 0) {
115
+ for (const pkg of PI_STEF_PACKAGES) {
116
+ if (currentCatalog.packages[pkg]?.source === `npm:${pkg}`) {
117
+ ctx.ui.setWorkingMessage?.(`Installing ${pkg}...`);
118
+ try {
119
+ await piInstall(`npm:${pkg}`);
120
+
121
+ // Check setup after successful install
122
+ const setup = checkSetupForSource(`npm:${pkg}`, ctx.home);
123
+ if (setup && !setup.ok) {
124
+ setupWarnings.push(`${pkg}: ${formatSetupStatus(setup)}`);
125
+ }
126
+ } catch {
127
+ ctx.ui.notify(`Warning: install of "${pkg}" failed`, "warning");
128
+ }
129
+ }
130
+ }
131
+ ctx.ui.setWorkingMessage?.();
132
+ }
133
+
134
+ const parts: string[] = [
135
+ `Scope @pi-stef: added ${added}, skipped ${skipped} (already in catalog)`,
136
+ ];
137
+ if (setupWarnings.length > 0) {
138
+ parts.push(`Setup incomplete:\n ${setupWarnings.join("\n ")}`);
139
+ }
140
+
141
+ ctx.ui.notify(
142
+ parts.join("\n"),
143
+ setupWarnings.length > 0 ? "warning" : "info",
144
+ );
145
+ return;
146
+ }
147
+
148
+ // --- Handle legacy 2-arg syntax: ct add <name> <source> -------------------
149
+ let name: string;
150
+ let source: string;
151
+
152
+ if (positional.length >= 2) {
153
+ name = positional[0];
154
+ source = positional[1];
88
155
  ctx.ui.notify(
89
- "Usage: ct add <name> <source> [--rating <core|useful|debatable>] [--type <skill|pi-native>]",
156
+ `"ct add <name> <source>" is legacy. Use "ct add <source>" name is auto-derived.`,
157
+ "warning",
158
+ );
159
+ } else if (positional.length === 1) {
160
+ source = positional[0];
161
+ name = sourceToKey(source);
162
+ } else {
163
+ ctx.ui.notify(
164
+ "Usage: ct add <source> [--type <skill|pi-native>]",
90
165
  "error",
91
166
  );
92
167
  return;
93
168
  }
94
169
 
95
- const rating = resolveRating(flags);
96
170
  let type = resolveType(flags);
97
171
 
98
172
  // --- Read catalog ---------------------------------------------------------
@@ -113,7 +187,7 @@ export async function addCommand(args: CommandArgs, ctx: AddCtx): Promise<void>
113
187
 
114
188
  // --- Add package ----------------------------------------------------------
115
189
  try {
116
- const updated = addPackage(catalog, name, source, rating, type);
190
+ const updated = addPackage(catalog, name, source, type);
117
191
  writeCatalog(updated, ctx.home);
118
192
  } catch (err: unknown) {
119
193
  const message = err instanceof Error ? err.message : String(err);
@@ -124,6 +198,7 @@ export async function addCommand(args: CommandArgs, ctx: AddCtx): Promise<void>
124
198
  ctx.ui.notify(`Added "${name}" to catalog`, "info");
125
199
 
126
200
  // --- Run pi install -------------------------------------------------------
201
+ ctx.ui.setWorkingMessage?.(`Installing ${name}...`);
127
202
  try {
128
203
  await piInstall(source);
129
204
  } catch {
@@ -132,4 +207,14 @@ export async function addCommand(args: CommandArgs, ctx: AddCtx): Promise<void>
132
207
  "warning",
133
208
  );
134
209
  }
210
+ ctx.ui.setWorkingMessage?.();
211
+
212
+ // --- Check setup requirements ---------------------------------------------
213
+ const setup = checkSetupForSource(source, ctx.home);
214
+ if (setup && !setup.ok) {
215
+ ctx.ui.notify(
216
+ `Setup incomplete for "${name}": ${formatSetupStatus(setup)}`,
217
+ "warning",
218
+ );
219
+ }
135
220
  }
@@ -30,7 +30,8 @@ export const SUBCOMMAND_DEFS: readonly SubcommandDef[] = [
30
30
  { name: "init", description: "Initialize a new catalog" },
31
31
  { name: "add", aliases: ["a"], description: "Add a package to the catalog" },
32
32
  { name: "remove", aliases: ["rm"], description: "Remove a package from the catalog" },
33
- { name: "toggle", description: "Toggle a package's rating" },
33
+ { name: "toggle", description: "Toggle a package's enabled state" },
34
+ { name: "update", aliases: ["up"], description: "Update packages to latest versions" },
34
35
  { name: "disable", description: "Disable a package" },
35
36
  { name: "enable", description: "Enable a package" },
36
37
  { name: "push", description: "Push catalog to remote gist" },
@@ -41,6 +42,7 @@ export const SUBCOMMAND_DEFS: readonly SubcommandDef[] = [
41
42
  { name: "verify", description: "Verify catalog integrity" },
42
43
  { name: "profiles", description: "List available profiles" },
43
44
  { name: "profile", description: "Show or switch active profile" },
45
+ { name: "reset", description: "Reset catalog: uninstall all @pi-stef packages and delete config" },
44
46
  ] as const;
45
47
 
46
48
  // ---------------------------------------------------------------------------
@@ -8,6 +8,7 @@
8
8
  import yaml from "js-yaml";
9
9
 
10
10
  import { scanInstalled } from "../catalog/install.js";
11
+ import { migrateRatingToEnabledRaw } from "../catalog/migrate.js";
11
12
  import { CatalogYamlSchema } from "../config/schema.js";
12
13
  import type { CatalogYaml } from "../config/schema.js";
13
14
  import type { CommandArgs, CommandCtx } from "./types.js";
@@ -29,7 +30,7 @@ export type InitContext = CommandCtx;
29
30
  * Initialize a new catalog.
30
31
  *
31
32
  * - Without flags: scans installed packages and generates a catalog with
32
- * `rating: 'core'` for every discovered package.
33
+ * every discovered package enabled.
33
34
  * - With `--from-gist=<id>`: fetches the gist, reads its `cat.yaml` file,
34
35
  * validates it, and writes it as the local catalog.
35
36
  */
@@ -70,7 +71,6 @@ function initFromScan(ctx: InitContext): void {
70
71
  for (const [name, pkg] of Object.entries(installed)) {
71
72
  packages[name] = {
72
73
  source: pkg.source,
73
- rating: "core",
74
74
  };
75
75
  }
76
76
 
@@ -115,6 +115,7 @@ async function initFromGist(gistId: string, ctx: InitContext): Promise<void> {
115
115
 
116
116
  // Validate and write
117
117
  const parsed = yaml.load(gistContent);
118
+ migrateRatingToEnabledRaw(parsed);
118
119
  const catalog = CatalogYamlSchema.parse(parsed);
119
120
 
120
121
  writeCatalog(catalog, ctx.home);
@@ -12,6 +12,7 @@
12
12
 
13
13
  import type { CommandArgs, CommandCtx } from "./types.js";
14
14
  import { removePackage } from "../catalog/crud.js";
15
+ import { isPiStefSource } from "../catalog/packages.js";
15
16
  import { readCatalog, writeCatalog, readLock, writeLock } from "../config/io.js";
16
17
  import { piUninstall } from "../util/exec.js";
17
18
 
@@ -41,6 +42,79 @@ export async function removeCommand(
41
42
  ctx: RemoveCtx,
42
43
  ): Promise<void> {
43
44
  const { positional, flags } = args;
45
+
46
+ // --- Handle --scope batch mode ---------------------------------------------
47
+ if ("scope" in flags) {
48
+ const scope = flags["scope"];
49
+ if (scope !== "@pi-stef") {
50
+ ctx.ui.notify(`Unsupported scope: "${scope}". Use --scope @pi-stef.`, "error");
51
+ return;
52
+ }
53
+
54
+ const catalog = readCatalog(ctx.home);
55
+ const lock = readLock(ctx.home);
56
+
57
+ // isPiStefSource returns false for @pi-stef/catalog by design (see packages.ts)
58
+ const piStefNames = Object.keys(catalog.packages).filter(
59
+ (name) => isPiStefSource(catalog.packages[name].source),
60
+ );
61
+
62
+ if (piStefNames.length === 0) {
63
+ ctx.ui.notify("No @pi-stef packages found in catalog", "info");
64
+ return;
65
+ }
66
+
67
+ // Confirmation
68
+ const skipConfirm = "yes" in flags || "y" in flags;
69
+ if (!skipConfirm && ctx.ui.confirm) {
70
+ const confirmed = await ctx.ui.confirm(
71
+ `Remove ${piStefNames.length} @pi-stef packages from catalog?`,
72
+ );
73
+ if (!confirmed) {
74
+ ctx.ui.notify("Removal cancelled", "info");
75
+ return;
76
+ }
77
+ }
78
+
79
+ // Capture sources before removing
80
+ const sources: Record<string, string> = {};
81
+ for (const name of piStefNames) {
82
+ sources[name] = catalog.packages[name].source;
83
+ }
84
+
85
+ // Remove all from catalog and lock file
86
+ for (const name of piStefNames) {
87
+ delete catalog.packages[name];
88
+ if (lock.packages[name]) {
89
+ delete lock.packages[name];
90
+ }
91
+ }
92
+
93
+ writeCatalog(catalog, ctx.home);
94
+ writeLock(lock, ctx.home);
95
+
96
+ // Uninstall all
97
+ let uninstalled = 0;
98
+ let failed = 0;
99
+ for (const name of piStefNames) {
100
+ ctx.ui.setWorkingMessage?.(`Uninstalling ${name} (${uninstalled + 1}/${piStefNames.length})...`);
101
+ try {
102
+ await piUninstall(sources[name]);
103
+ uninstalled++;
104
+ } catch {
105
+ ctx.ui.notify(`Warning: uninstall of "${name}" failed`, "warning");
106
+ failed++;
107
+ }
108
+ }
109
+ ctx.ui.setWorkingMessage?.();
110
+
111
+ ctx.ui.notify(
112
+ `Scope @pi-stef: removed ${piStefNames.length}, uninstalled ${uninstalled}${failed > 0 ? ` (${failed} uninstall failed)` : ""}`,
113
+ failed > 0 ? "warning" : "info",
114
+ );
115
+ return;
116
+ }
117
+
44
118
  const name = positional[0];
45
119
 
46
120
  // --- Validate required args -----------------------------------------------
@@ -91,6 +165,7 @@ export async function removeCommand(
91
165
  ctx.ui.notify(`Removed "${name}" from catalog`, "info");
92
166
 
93
167
  // --- Run pi uninstall -----------------------------------------------------
168
+ ctx.ui.setWorkingMessage?.(`Uninstalling ${name}...`);
94
169
  try {
95
170
  await piUninstall(source);
96
171
  } catch {
@@ -99,4 +174,5 @@ export async function removeCommand(
99
174
  "warning",
100
175
  );
101
176
  }
177
+ ctx.ui.setWorkingMessage?.();
102
178
  }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * `ct reset` subcommand implementation.
3
+ *
4
+ * Full nuke: uninstalls all @pi-stef packages, deletes gist refs,
5
+ * deletes config files (cat.yaml, catalog.lock.json, .gist),
6
+ * and removes the empty catalog directory.
7
+ *
8
+ * Usage:
9
+ * - `ct reset` — prompts for confirmation
10
+ * - `ct reset --yes` — skips confirmation
11
+ *
12
+ * This is a destructive operation. The catalog can be re-initialized
13
+ * with `ct init` after resetting.
14
+ */
15
+
16
+ import fs from "node:fs";
17
+ import type { CommandArgs, CommandCtx } from "./types.js";
18
+ import { isPiStefSource } from "../catalog/packages.js";
19
+ import { catalogDir, catalogFile, lockFile } from "../config/paths.js";
20
+ import { piUninstall } from "../util/exec.js";
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Types
24
+ // ---------------------------------------------------------------------------
25
+
26
+ /** Context for `resetCommand`, extending the base with `confirm`. */
27
+ export interface ResetCtx extends CommandCtx {
28
+ ui: CommandCtx["ui"] & {
29
+ confirm?: (message: string) => Promise<boolean>;
30
+ };
31
+ }
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // resetCommand
35
+ // ---------------------------------------------------------------------------
36
+
37
+ /**
38
+ * Execute the `ct reset` subcommand.
39
+ *
40
+ * 1. Check cat.yaml exists
41
+ * 2. Confirm (skippable with --yes)
42
+ * 3. Find @pi-stef packages (isPiStefSource excludes catalog)
43
+ * 4. pi uninstall each
44
+ * 5. Delete config files with fs.rmSync({ recursive: true, force: true })
45
+ * 6. Remove empty catalog directory
46
+ */
47
+ export async function resetCommand(
48
+ args: CommandArgs,
49
+ ctx: ResetCtx,
50
+ ): Promise<void> {
51
+ const { flags } = args;
52
+
53
+ // --- Check cat.yaml exists ------------------------------------------------
54
+ const catPath = catalogFile(ctx.home);
55
+ if (!fs.existsSync(catPath)) {
56
+ ctx.ui.notify("No catalog found. Run `ct init` first.", "error");
57
+ return;
58
+ }
59
+
60
+ // --- Confirmation ----------------------------------------------------------
61
+ const skipConfirm = "yes" in flags || "y" in flags;
62
+ if (!skipConfirm && ctx.ui.confirm) {
63
+ const confirmed = await ctx.ui.confirm(
64
+ "This will uninstall all @pi-stef packages and delete all catalog config. Continue?",
65
+ );
66
+ if (!confirmed) {
67
+ ctx.ui.notify("Reset cancelled", "info");
68
+ return;
69
+ }
70
+ }
71
+
72
+ // --- Find @pi-stef packages -----------------------------------------------
73
+ // Read catalog directly (raw YAML) to avoid schema validation on corrupt files
74
+ let packages: Record<string, { source: string }> = {};
75
+ try {
76
+ const yaml = await import("js-yaml");
77
+ const content = fs.readFileSync(catPath, "utf8");
78
+ const parsed = yaml.load(content) as { packages?: Record<string, { source: string }> };
79
+ packages = parsed?.packages ?? {};
80
+ } catch {
81
+ // If YAML is corrupt, we still want to delete config files
82
+ ctx.ui.notify("Warning: could not parse cat.yaml — skipping uninstall step", "warning");
83
+ }
84
+
85
+ const piStefNames = Object.keys(packages).filter(
86
+ (name) => isPiStefSource(packages[name].source),
87
+ );
88
+
89
+ // --- Uninstall @pi-stef packages ------------------------------------------
90
+ let uninstalled = 0;
91
+ let failed = 0;
92
+
93
+ for (const name of piStefNames) {
94
+ ctx.ui.setWorkingMessage?.(`Uninstalling ${name} (${uninstalled + 1}/${piStefNames.length})...`);
95
+ try {
96
+ await piUninstall(packages[name].source);
97
+ uninstalled++;
98
+ } catch {
99
+ ctx.ui.notify(`Warning: uninstall of "${name}" failed`, "warning");
100
+ failed++;
101
+ }
102
+ }
103
+ ctx.ui.setWorkingMessage?.();
104
+
105
+ // --- Delete config files --------------------------------------------------
106
+ const dir = catalogDir(ctx.home);
107
+ const gistPath = `${dir}/.gist`;
108
+
109
+ // Delete individual files first, then the directory
110
+ for (const filePath of [catPath, lockFile(ctx.home), gistPath]) {
111
+ try {
112
+ fs.rmSync(filePath, { force: true });
113
+ } catch {
114
+ // Ignore errors — file may not exist
115
+ }
116
+ }
117
+
118
+ // Remove the catalog directory itself
119
+ try {
120
+ fs.rmSync(dir, { recursive: true, force: true });
121
+ } catch {
122
+ // Ignore errors — directory may not exist or be non-empty
123
+ }
124
+
125
+ // --- Report ----------------------------------------------------------------
126
+ const parts: string[] = [];
127
+ if (piStefNames.length > 0) {
128
+ parts.push(`uninstalled ${uninstalled}/${piStefNames.length} packages`);
129
+ }
130
+ parts.push("deleted config files");
131
+
132
+ ctx.ui.notify(
133
+ `Reset complete: ${parts.join(", ")}${failed > 0 ? ` (${failed} uninstall failed)` : ""}`,
134
+ failed > 0 ? "warning" : "info",
135
+ );
136
+ }
@@ -1,14 +1,16 @@
1
1
  /**
2
2
  * `ct status` subcommand implementation.
3
3
  *
4
- * Shows catalog status: profile, package counts by rating,
5
- * installed/missing/orphan counts, gist URL, and last sync time.
4
+ * Shows catalog status: profile, package counts (enabled/disabled),
5
+ * installed/missing/orphan counts, gist URL, last sync time,
6
+ * and individual package listing with setup status.
6
7
  */
7
8
 
8
9
  import type { CommandArgs, CommandCtx } from "./types.js";
9
10
  import { readCatalog, readLock } from "../config/io.js";
10
11
  import { scanInstalled } from "../catalog/install.js";
11
12
  import { readCachedGistId } from "../sync/cache.js";
13
+ import { checkSetupForSource } from "../catalog/setup.js";
12
14
 
13
15
  // ---------------------------------------------------------------------------
14
16
  // Types
@@ -17,30 +19,6 @@ import { readCachedGistId } from "../sync/cache.js";
17
19
  /** Context for `statusCommand`. Uses the base `CommandCtx`. */
18
20
  export type StatusCtx = CommandCtx;
19
21
 
20
- // ---------------------------------------------------------------------------
21
- // Helpers
22
- // ---------------------------------------------------------------------------
23
-
24
- interface RatingCounts {
25
- core: number;
26
- useful: number;
27
- debatable: number;
28
- disabled: number;
29
- }
30
-
31
- function countByRating(
32
- packages: Record<string, { rating: string; enabled?: boolean }>,
33
- ): RatingCounts {
34
- const counts: RatingCounts = { core: 0, useful: 0, debatable: 0, disabled: 0 };
35
- for (const pkg of Object.values(packages)) {
36
- const r = pkg.rating as keyof RatingCounts;
37
- if (r in counts) {
38
- counts[r]++;
39
- }
40
- }
41
- return counts;
42
- }
43
-
44
22
  // ---------------------------------------------------------------------------
45
23
  // statusCommand
46
24
  // ---------------------------------------------------------------------------
@@ -49,7 +27,7 @@ function countByRating(
49
27
  * Execute the `ct status` subcommand.
50
28
  *
51
29
  * Reads catalog, lock, gist cache, and installed packages to build
52
- * a comprehensive status summary.
30
+ * a comprehensive status summary with individual package listing.
53
31
  */
54
32
  export async function statusCommand(
55
33
  args: CommandArgs,
@@ -67,24 +45,33 @@ export async function statusCommand(
67
45
  const packages = catalog.packages;
68
46
  const totalPackages = Object.keys(packages).length;
69
47
 
70
- // --- Package counts by rating ---
71
- const ratingCounts = countByRating(packages);
48
+ // --- Enabled / disabled counts ---
49
+ let enabledCount = 0;
50
+ let disabledCount = 0;
51
+ for (const pkg of Object.values(packages)) {
52
+ if (pkg.enabled === false) {
53
+ disabledCount++;
54
+ } else {
55
+ enabledCount++;
56
+ }
57
+ }
72
58
 
73
59
  // --- Installed / missing / orphan ---
60
+ // Build a lookup map for installed packages by source
61
+ const installedBySource = new Map<string, { name: string; version?: string }>();
62
+ for (const inst of Object.values(installed)) {
63
+ installedBySource.set(inst.source, { name: inst.name, version: inst.version });
64
+ }
65
+
74
66
  const catalogSources = new Set<string>();
75
67
  for (const pkg of Object.values(packages)) {
76
68
  catalogSources.add(pkg.source);
77
69
  }
78
70
 
79
- const installedSources = new Set<string>();
80
- for (const inst of Object.values(installed)) {
81
- installedSources.add(inst.source);
82
- }
83
-
84
71
  // Count how many catalog packages are actually installed
85
72
  let installedCount = 0;
86
73
  for (const pkg of Object.values(packages)) {
87
- if (installedSources.has(pkg.source)) {
74
+ if (installedBySource.has(pkg.source)) {
88
75
  installedCount++;
89
76
  }
90
77
  }
@@ -116,7 +103,7 @@ export async function statusCommand(
116
103
 
117
104
  // Package counts
118
105
  lines.push(
119
- `Packages: ${totalPackages} total (core: ${ratingCounts.core}, useful: ${ratingCounts.useful}, debatable: ${ratingCounts.debatable}, disabled: ${ratingCounts.disabled})`,
106
+ `Packages: ${totalPackages} total (${enabledCount} enabled, ${disabledCount} disabled)`,
120
107
  );
121
108
 
122
109
  // Installed/missing/orphan
@@ -138,5 +125,53 @@ export async function statusCommand(
138
125
  lines.push("Last sync: never synced");
139
126
  }
140
127
 
128
+ // --- Individual package listing ---
129
+ if (totalPackages > 0) {
130
+ lines.push("");
131
+ lines.push("Packages:");
132
+
133
+ for (const [name, pkg] of Object.entries(packages)) {
134
+ const isDisabled = pkg.enabled === false;
135
+ const isInstalled = installedBySource.has(pkg.source);
136
+ const inst = installedBySource.get(pkg.source);
137
+
138
+ // Status indicator
139
+ let status: string;
140
+ if (isDisabled) {
141
+ status = "disabled";
142
+ } else if (isInstalled) {
143
+ status = "installed";
144
+ } else {
145
+ status = "missing";
146
+ }
147
+
148
+ // Version info
149
+ const versionStr = inst?.version ? ` v${inst.version}` : "";
150
+
151
+ // Setup status
152
+ let setupStr = "";
153
+ if (isInstalled && !isDisabled) {
154
+ const setup = checkSetupForSource(pkg.source, ctx.home);
155
+ if (setup && !setup.ok) {
156
+ setupStr = " ⚠ setup incomplete";
157
+ }
158
+ }
159
+
160
+ lines.push(` ${name} [${status}]${versionStr}${setupStr}`);
161
+ }
162
+ }
163
+
164
+ // --- Orphans ---
165
+ if (orphanCount > 0) {
166
+ lines.push("");
167
+ lines.push("Orphans:");
168
+ for (const inst of Object.values(installed)) {
169
+ if (!catalogSources.has(inst.source)) {
170
+ const versionStr = inst.version ? ` v${inst.version}` : "";
171
+ lines.push(` ${inst.name} [orphan]${versionStr}`);
172
+ }
173
+ }
174
+ }
175
+
141
176
  ctx.ui.notify(lines.join("\n"), "info");
142
177
  }