@pi-stef/catalog 0.4.0 → 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.
package/README.md CHANGED
@@ -4,14 +4,6 @@ Declarative package manager for the [pi](https://pi.dev) coding agent. Manage yo
4
4
 
5
5
  ## Installation
6
6
 
7
- From the monorepo root (while developing):
8
-
9
- ```bash
10
- pi install packages/catalog
11
- ```
12
-
13
- Once published to npm:
14
-
15
7
  ```bash
16
8
  pi install npm:@pi-stef/catalog
17
9
  ```
@@ -40,37 +32,48 @@ All commands are invoked as `/ct <subcommand>` inside pi, or via the shorthand `
40
32
  |---|---|---|---|
41
33
  | `sync` | — | Full sync cycle: pull → reconcile → execute → push | `--dry-run`, `--force`, `--no-push`, `--profile=<name>` |
42
34
  | `init` | — | Initialize catalog from installed packages or a gist | `--from-gist=<id>` |
43
- | `add` | `a` | Add a package to the catalog and install it | `--rating=<r>`, `-r <r>`, `--type=<t>`, `-s <t>` |
44
- | `remove` | `rm` | Remove a package from the catalog | |
45
- | `toggle` | — | Cycle a package's rating: core useful → debatable → disabled → core | — |
46
- | `enable` | — | Re-enable a disabled package (restores previous rating) | — |
47
- | `disable` | — | Disable a package (preserves rating for later restore) | — |
35
+ | `add` | `a` | Add a package to the catalog and install it | `--type=<t>`, `-s <t>`, `--scope=@pi-stef` |
36
+ | `remove` | `rm` | Remove a package from the catalog | `--yes`, `--scope=@pi-stef` |
37
+ | `toggle` | — | Toggle a package's enabled state (enabled disabled) | — |
38
+ | `enable` | — | Enable a disabled package | — |
39
+ | `disable` | — | Disable a package and uninstall it | — |
40
+ | `update` | `up` | Update packages to latest versions | `--all` |
48
41
  | `push` | — | Push local catalog + lock to GitHub Gist | `--dry-run`, `--profile=<name>` |
49
42
  | `pull` | — | Pull remote catalog from gist and reconcile | `--dry-run`, `--profile=<name>` |
50
43
  | `login` | — | Authenticate with GitHub via `gh` CLI | — |
51
- | `status` | — | Show catalog status, package counts, gist URL, last sync | — |
44
+ | `status` | — | Show catalog status with package listing | — |
52
45
  | `diff` | — | Show diff between local and remote catalog | — |
53
- | `verify` | — | Verify catalog integrity (sources, ratings, duplicates) | — |
46
+ | `verify` | — | Verify catalog integrity | — |
54
47
  | `profiles` | — | List all profiles with active indicator | — |
55
48
  | `profile` | — | Show or switch active profile | — |
49
+ | `reset` | — | Uninstall all @pi-stef packages and delete config | `--yes` |
56
50
 
57
51
  ### Adding Packages
58
52
 
59
53
  ```bash
60
- # Add from a git source (prompts for type if not specified)
61
- /ct add my-skill git:github.com/user/repo#packages/my-skill
62
-
63
- # Add with explicit rating and type
64
- /ct add my-skill git:github.com/user/repo#packages/my-skill --rating=useful --type=skill
54
+ # Add from a git source (name auto-derived)
55
+ /ct add git:github.com/user/repo#packages/my-skill
65
56
 
66
57
  # Add an npm package
67
- /ct add lodash npm:lodash
58
+ /ct add npm:lodash
59
+
60
+ # Add all @pi-stef packages at once
61
+ /ct add --scope=@pi-stef
68
62
  ```
69
63
 
70
64
  ### Removing Packages
71
65
 
72
66
  ```bash
73
67
  /ct remove my-skill
68
+ /ct remove --scope=@pi-stef
69
+ ```
70
+
71
+ ### Enabling and Disabling
72
+
73
+ ```bash
74
+ /ct enable my-skill # Enable a disabled package
75
+ /ct disable my-skill # Disable a package (uninstalls it)
76
+ /ct toggle my-skill # Toggle enabled ↔ disabled
74
77
  ```
75
78
 
76
79
  ## `cat.yaml` Format
@@ -85,18 +88,14 @@ meta:
85
88
  packages:
86
89
  superpowers-adapter:
87
90
  source: "git:github.com/sfiorini/pi-stef#packages/superpowers-adapter"
88
- rating: core
89
91
  type: skill
90
92
  team:
91
93
  source: "git:github.com/sfiorini/pi-stef#packages/team"
92
- rating: core
93
94
  type: skill
94
95
  atlassian:
95
96
  source: "git:github.com/sfiorini/pi-stef#packages/atlassian"
96
- rating: useful
97
97
  type: skill
98
98
  enabled: false
99
- previousRating: useful
100
99
  ```
101
100
 
102
101
  ### Package Fields
@@ -104,11 +103,9 @@ packages:
104
103
  | Field | Required | Description |
105
104
  |---|---|---|
106
105
  | `source` | ✓ | Package source URL (`npm:…` or `git:…`) |
107
- | `rating` | ✓ | One of: `core`, `useful`, `debatable`, `disabled` |
108
106
  | `type` | — | `skill` or `pi-native` |
109
107
  | `profile` | — | Profile name this package belongs to |
110
108
  | `enabled` | — | `true` (default) or `false` |
111
- | `previousRating` | — | Rating before disable; restored by `ct enable` |
112
109
 
113
110
  ### Examples
114
111
 
@@ -117,7 +114,6 @@ packages:
117
114
  packages:
118
115
  lodash:
119
116
  source: "npm:lodash"
120
- rating: useful
121
117
  ```
122
118
 
123
119
  **Git source:**
@@ -125,17 +121,19 @@ packages:
125
121
  packages:
126
122
  my-extension:
127
123
  source: "git:github.com/user/repo#packages/my-extension"
128
- rating: core
129
124
  type: pi-native
130
125
  ```
131
126
 
132
- **Git source with subpath:**
133
- ```yaml
134
- packages:
135
- my-skill:
136
- source: "git:github.com/user/repo#skills/my-skill"
137
- rating: core
138
- type: skill
127
+ ## Setup Detection
128
+
129
+ Packages can include a `.pi-setup.json` file declaring requirements (environment variables, config files, CLI tools). After install or update, the catalog checks these requirements and warns if anything is missing.
130
+
131
+ ```json
132
+ {
133
+ "env": ["API_TOKEN"],
134
+ "files": ["config.json"],
135
+ "cli": ["docker"]
136
+ }
139
137
  ```
140
138
 
141
139
  ## Profiles
@@ -150,19 +148,16 @@ meta:
150
148
  packages:
151
149
  superpowers-adapter:
152
150
  source: "git:github.com/sfiorini/pi-stef#packages/superpowers-adapter"
153
- rating: core
154
151
 
155
152
  profiles:
156
153
  work:
157
154
  packages:
158
155
  atlassian:
159
156
  source: "git:github.com/sfiorini/pi-stef#packages/atlassian"
160
- rating: core
161
157
  personal:
162
158
  packages:
163
159
  figma:
164
160
  source: "git:github.com/sfiorini/pi-stef#packages/figma"
165
- rating: useful
166
161
  ```
167
162
 
168
163
  **Profile commands:**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-stef/catalog",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Pi extension for managing skill/package catalogs.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,6 +1,5 @@
1
1
  import type { CatalogYaml, CatalogPackage } from "../config/schema.js";
2
- import type { RatingValue } from "./ratings.js";
3
- import { isValidSource, isDisabled, nextRating } from "./ratings.js";
2
+ import { isValidSource } from "./ratings.js";
4
3
 
5
4
  // ---------------------------------------------------------------------------
6
5
  // Immutable helpers
@@ -28,7 +27,6 @@ export function addPackage(
28
27
  catalog: CatalogYaml,
29
28
  name: string,
30
29
  source: string,
31
- rating: RatingValue,
32
30
  type?: "skill" | "pi-native",
33
31
  ): CatalogYaml {
34
32
  if (catalog.packages[name]) {
@@ -41,7 +39,7 @@ export function addPackage(
41
39
  );
42
40
  }
43
41
 
44
- const entry: CatalogPackage = { source, rating };
42
+ const entry: CatalogPackage = { source };
45
43
  if (type !== undefined) {
46
44
  entry.type = type;
47
45
  }
@@ -70,8 +68,7 @@ export function removePackage(
70
68
  }
71
69
 
72
70
  /**
73
- * Toggle a package's rating through the cycle:
74
- * core → useful → debatable → disabled → core
71
+ * Toggle a package's enabled state: enabled ↔ disabled.
75
72
  *
76
73
  * @throws if the package is not found
77
74
  */
@@ -87,13 +84,13 @@ export function togglePackage(
87
84
  const next = cloneCatalog(catalog);
88
85
  next.packages[name] = {
89
86
  ...entry,
90
- rating: nextRating(entry.rating),
87
+ enabled: entry.enabled === false ? true : false,
91
88
  };
92
89
  return next;
93
90
  }
94
91
 
95
92
  /**
96
- * Enable a disabled package, restoring its previous rating (or "core").
93
+ * Enable a disabled package.
97
94
  * No-op when the package is already enabled.
98
95
  *
99
96
  * @throws if the package is not found
@@ -108,19 +105,17 @@ export function enablePackage(
108
105
  }
109
106
 
110
107
  // No-op if already enabled
111
- if (!isDisabled(entry.rating)) {
108
+ if (entry.enabled !== false) {
112
109
  return catalog;
113
110
  }
114
111
 
115
- const restored = entry.previousRating ?? "core";
116
112
  const next = cloneCatalog(catalog);
117
- const { previousRating: _, ...clean } = entry;
118
- next.packages[name] = { ...clean, rating: restored };
113
+ next.packages[name] = { ...entry, enabled: true };
119
114
  return next;
120
115
  }
121
116
 
122
117
  /**
123
- * Disable a package, saving its current rating for later restoration.
118
+ * Disable a package.
124
119
  *
125
120
  * @throws if the package is not found
126
121
  */
@@ -134,10 +129,6 @@ export function disablePackage(
134
129
  }
135
130
 
136
131
  const next = cloneCatalog(catalog);
137
- next.packages[name] = {
138
- ...entry,
139
- previousRating: entry.rating,
140
- rating: "disabled",
141
- };
132
+ next.packages[name] = { ...entry, enabled: false };
142
133
  return next;
143
134
  }
@@ -31,6 +31,8 @@ export interface InstalledPackage {
31
31
  name: string;
32
32
  /** Installed version if discoverable, otherwise undefined. */
33
33
  version: string | undefined;
34
+ /** Absolute path to the installed package directory, if discoverable. */
35
+ installDir?: string;
34
36
  }
35
37
 
36
38
  export type InstalledMap = Record<string, InstalledPackage>;
@@ -135,10 +137,14 @@ function readPackagesFromSettings(
135
137
  const parsed = parseSource(rawSource);
136
138
 
137
139
  let version: string | undefined;
140
+ let installDir: string | undefined;
138
141
  if (parsed.type === "npm") {
139
142
  version = readNpmVersion(home, parsed.npmName!);
143
+ installDir = path.join(npmNodeModulesDir(home), parsed.npmName!);
140
144
  } else if (parsed.type === "local") {
141
145
  version = readLocalVersion(home, rawSource);
146
+ const settingsDir = path.join(home, ".pi", "agent");
147
+ installDir = path.resolve(settingsDir, rawSource);
142
148
  }
143
149
 
144
150
  const key = parsed.type === "local" ? rawSource : parsed.name;
@@ -147,6 +153,7 @@ function readPackagesFromSettings(
147
153
  source: rawSource,
148
154
  name: parsed.name,
149
155
  version,
156
+ installDir,
150
157
  };
151
158
  }
152
159
 
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Migration: rating → enabled boolean.
3
+ *
4
+ * Operates on raw YAML output (before Zod validation) so that the `rating`
5
+ * field — which Zod would strip — is still available for conversion.
6
+ *
7
+ * Rules:
8
+ * - rating "disabled" → enabled: false
9
+ * - any other rating → enabled: true (or omit, since Zod defaults to true)
10
+ * - Remove `rating` and `previousRating` fields
11
+ */
12
+
13
+ interface RawPackage {
14
+ source: string;
15
+ rating?: string;
16
+ previousRating?: string;
17
+ enabled?: boolean;
18
+ type?: string;
19
+ profile?: string;
20
+ [key: string]: unknown;
21
+ }
22
+
23
+ interface RawCatalog {
24
+ meta: Record<string, unknown>;
25
+ packages: Record<string, RawPackage>;
26
+ profiles?: Record<string, unknown>;
27
+ [key: string]: unknown;
28
+ }
29
+
30
+ /**
31
+ * Migrate a raw YAML catalog object from rating-based to enabled-based.
32
+ *
33
+ * Returns the mutated object (in-place) for chaining. If the catalog has
34
+ * no `packages` key or is not an object, returns it unchanged.
35
+ */
36
+ export function migrateRatingToEnabledRaw(raw: unknown): unknown {
37
+ if (!raw || typeof raw !== "object") return raw;
38
+ const catalog = raw as RawCatalog;
39
+ if (!catalog.packages || typeof catalog.packages !== "object") return raw;
40
+
41
+ for (const [_name, pkg] of Object.entries(catalog.packages)) {
42
+ if (!pkg || typeof pkg !== "object") continue;
43
+
44
+ if ("rating" in pkg) {
45
+ const rating = pkg.rating;
46
+ if (rating === "disabled") {
47
+ pkg.enabled = false;
48
+ }
49
+ // For non-disabled ratings, don't set enabled — Zod defaults to true
50
+ delete pkg.rating;
51
+ }
52
+
53
+ if ("previousRating" in pkg) {
54
+ delete pkg.previousRating;
55
+ }
56
+ }
57
+
58
+ return raw;
59
+ }
@@ -1,19 +1,3 @@
1
- /** The ordered rating cycle: core → useful → debatable → disabled → core */
2
- export const RATING_CYCLE = ["core", "useful", "debatable", "disabled"] as const;
3
-
4
- export type RatingValue = (typeof RATING_CYCLE)[number];
5
-
6
- /** Returns the next rating in the cycle. */
7
- export function nextRating(rating: RatingValue): RatingValue {
8
- const idx = RATING_CYCLE.indexOf(rating);
9
- return RATING_CYCLE[(idx + 1) % RATING_CYCLE.length];
10
- }
11
-
12
- /** Returns true when the rating is "disabled". */
13
- export function isDisabled(rating: RatingValue): boolean {
14
- return rating === "disabled";
15
- }
16
-
17
1
  /**
18
2
  * Validates a source string.
19
3
  * Accepted formats:
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Setup detection for packages that need additional configuration.
3
+ *
4
+ * Packages can include a `.pi-setup.json` file in their install directory
5
+ * declaring requirements:
6
+ * - `env`: required environment variables
7
+ * - `files`: required config files (relative to config dir ~/.pi/sf/<pkg>/)
8
+ * - `cli`: required CLI tools (checked via `which`)
9
+ */
10
+
11
+ import fs from "node:fs";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+ import { spawnSync } from "node:child_process";
15
+ import { z } from "zod";
16
+ import { parseSource } from "./source.js";
17
+ import { npmNodeModulesDir } from "../config/paths.js";
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Schema
21
+ // ---------------------------------------------------------------------------
22
+
23
+ export const SetupCheckSchema = z.object({
24
+ /** Required environment variables. */
25
+ env: z.array(z.string()).optional(),
26
+ /** Required config files (relative to config dir). */
27
+ files: z.array(z.string()).optional(),
28
+ /** Required CLI tools (checked via `which`). */
29
+ cli: z.array(z.string()).optional(),
30
+ });
31
+
32
+ export type SetupCheck = z.infer<typeof SetupCheckSchema>;
33
+
34
+ export interface SetupStatus {
35
+ /** Whether all requirements are met. */
36
+ ok: boolean;
37
+ /** Missing environment variables. */
38
+ missingEnv: string[];
39
+ /** Missing config files. */
40
+ missingFiles: string[];
41
+ /** Missing CLI tools. */
42
+ missingCli: string[];
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // checkSetup
47
+ // ---------------------------------------------------------------------------
48
+
49
+ /**
50
+ * Check setup requirements for a package.
51
+ *
52
+ * @param installDir - The package's installed directory (where .pi-setup.json lives)
53
+ * @param configDir - The package's config directory (~/.pi/sf/<pkg>/)
54
+ * @returns Setup status with missing requirements, or undefined if no .pi-setup.json
55
+ */
56
+ export function checkSetup(
57
+ installDir: string,
58
+ configDir: string,
59
+ ): SetupStatus | undefined {
60
+ const setupPath = path.join(installDir, ".pi-setup.json");
61
+
62
+ if (!fs.existsSync(setupPath)) {
63
+ return undefined;
64
+ }
65
+
66
+ let raw: unknown;
67
+ try {
68
+ const content = fs.readFileSync(setupPath, "utf-8");
69
+ raw = JSON.parse(content);
70
+ } catch {
71
+ console.warn(`Warning: malformed .pi-setup.json in ${installDir}`);
72
+ return undefined;
73
+ }
74
+
75
+ const parsed = SetupCheckSchema.safeParse(raw);
76
+ if (!parsed.success) {
77
+ console.warn(`Warning: invalid .pi-setup.json in ${installDir}: ${parsed.error.message}`);
78
+ return undefined;
79
+ }
80
+
81
+ const check = parsed.data;
82
+ const missingEnv: string[] = [];
83
+ const missingFiles: string[] = [];
84
+ const missingCli: string[] = [];
85
+
86
+ // Check environment variables
87
+ if (check.env) {
88
+ for (const varName of check.env) {
89
+ if (!process.env[varName]) {
90
+ missingEnv.push(varName);
91
+ }
92
+ }
93
+ }
94
+
95
+ // Check config files
96
+ if (check.files) {
97
+ for (const filePath of check.files) {
98
+ const fullPath = path.join(configDir, filePath);
99
+ if (!fs.existsSync(fullPath)) {
100
+ missingFiles.push(filePath);
101
+ }
102
+ }
103
+ }
104
+
105
+ // Check CLI tools
106
+ if (check.cli) {
107
+ for (const tool of check.cli) {
108
+ const result = spawnSync("which", [tool], {
109
+ stdio: "pipe",
110
+ timeout: 5000,
111
+ });
112
+ if (result.status !== 0) {
113
+ missingCli.push(tool);
114
+ }
115
+ }
116
+ }
117
+
118
+ const ok =
119
+ missingEnv.length === 0 &&
120
+ missingFiles.length === 0 &&
121
+ missingCli.length === 0;
122
+
123
+ return { ok, missingEnv, missingFiles, missingCli };
124
+ }
125
+
126
+ /**
127
+ * Format a setup status as a human-readable message.
128
+ */
129
+ export function formatSetupStatus(status: SetupStatus): string {
130
+ const parts: string[] = [];
131
+ if (status.missingEnv.length > 0) {
132
+ parts.push(`Missing env: ${status.missingEnv.join(", ")}`);
133
+ }
134
+ if (status.missingFiles.length > 0) {
135
+ parts.push(`Missing files: ${status.missingFiles.join(", ")}`);
136
+ }
137
+ if (status.missingCli.length > 0) {
138
+ parts.push(`Missing CLI: ${status.missingCli.join(", ")}`);
139
+ }
140
+ return parts.join("; ");
141
+ }
142
+
143
+ /**
144
+ * Check setup requirements for a package by source string.
145
+ *
146
+ * Derives install dir from source type and config dir from home.
147
+ * Returns undefined if no .pi-setup.json exists.
148
+ */
149
+ export function checkSetupForSource(
150
+ source: string,
151
+ home?: string,
152
+ ): SetupStatus | undefined {
153
+ const parsed = parseSource(source);
154
+ const resolvedHome = home ?? os.homedir();
155
+
156
+ let installDir: string | undefined;
157
+ if (parsed.type === "npm") {
158
+ installDir = path.join(npmNodeModulesDir(resolvedHome), parsed.npmName!);
159
+ } else if (parsed.type === "local") {
160
+ const settingsDir = path.join(resolvedHome, ".pi", "agent");
161
+ installDir = path.resolve(settingsDir, source);
162
+ } else {
163
+ // git sources — no known install dir
164
+ return undefined;
165
+ }
166
+
167
+ const configDir = path.join(resolvedHome, ".pi", "sf", parsed.name);
168
+ return checkSetup(installDir, configDir);
169
+ }
@@ -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,10 +10,10 @@
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";
16
15
  import { sourceToKey } from "../catalog/source.js";
16
+ import { checkSetupForSource, formatSetupStatus } from "../catalog/setup.js";
17
17
  import { PI_STEF_PACKAGES } from "../catalog/packages.js";
18
18
  import { readCatalog, writeCatalog } from "../config/io.js";
19
19
  import { piInstall } from "../util/exec.js";
@@ -36,25 +36,6 @@ export interface AddCtx extends CommandCtx {
36
36
  // Helpers
37
37
  // ---------------------------------------------------------------------------
38
38
 
39
- const VALID_RATINGS: RatingValue[] = ["core", "useful", "debatable"];
40
-
41
- function isValidRating(value: string): value is RatingValue {
42
- return VALID_RATINGS.includes(value as RatingValue);
43
- }
44
-
45
- function resolveRating(flags: Record<string, true | string>): RatingValue {
46
- const raw =
47
- "r" in flags
48
- ? flags["r"]
49
- : "rating" in flags
50
- ? flags["rating"]
51
- : undefined;
52
-
53
- if (raw === true || raw === undefined) return "core";
54
- if (typeof raw === "string" && isValidRating(raw)) return raw;
55
- return "core";
56
- }
57
-
58
39
  function resolveType(
59
40
  flags: Record<string, true | string>,
60
41
  ): "skill" | "pi-native" | undefined {
@@ -77,10 +58,10 @@ function resolveType(
77
58
  /**
78
59
  * Execute the `ct add` subcommand.
79
60
  *
80
- * New syntax (preferred): `ct add <source> [--rating ...] [--type ...]`
61
+ * New syntax (preferred): `ct add <source> [--type ...]`
81
62
  * — name is auto-derived from source via `sourceToKey()`.
82
63
  *
83
- * Legacy syntax (deprecated): `ct add <name> <source> [--rating ...] [--type ...]`
64
+ * Legacy syntax (deprecated): `ct add <name> <source> [--type ...]`
84
65
  * — still accepted but emits a deprecation warning.
85
66
  *
86
67
  * Reads the catalog, validates inputs, prompts for type if needed,
@@ -98,7 +79,6 @@ export async function addCommand(args: CommandArgs, ctx: AddCtx): Promise<void>
98
79
  }
99
80
 
100
81
  const catalog = readCatalog(ctx.home);
101
- const rating = resolveRating(flags);
102
82
  let added = 0;
103
83
  let skipped = 0;
104
84
  let currentCatalog = catalog;
@@ -113,7 +93,7 @@ export async function addCommand(args: CommandArgs, ctx: AddCtx): Promise<void>
113
93
  }
114
94
 
115
95
  try {
116
- currentCatalog = addPackage(currentCatalog, pkg, npmSource, rating);
96
+ currentCatalog = addPackage(currentCatalog, pkg, npmSource);
117
97
  added++;
118
98
  } catch (err: unknown) {
119
99
  // Unexpected validation error — warn but continue
@@ -130,21 +110,37 @@ export async function addCommand(args: CommandArgs, ctx: AddCtx): Promise<void>
130
110
  }
131
111
 
132
112
  // Install all added packages
113
+ const setupWarnings: string[] = [];
133
114
  if (added > 0) {
134
115
  for (const pkg of PI_STEF_PACKAGES) {
135
116
  if (currentCatalog.packages[pkg]?.source === `npm:${pkg}`) {
117
+ ctx.ui.setWorkingMessage?.(`Installing ${pkg}...`);
136
118
  try {
137
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
+ }
138
126
  } catch {
139
127
  ctx.ui.notify(`Warning: install of "${pkg}" failed`, "warning");
140
128
  }
141
129
  }
142
130
  }
131
+ ctx.ui.setWorkingMessage?.();
143
132
  }
144
133
 
145
- ctx.ui.notify(
134
+ const parts: string[] = [
146
135
  `Scope @pi-stef: added ${added}, skipped ${skipped} (already in catalog)`,
147
- "info",
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",
148
144
  );
149
145
  return;
150
146
  }
@@ -165,13 +161,12 @@ export async function addCommand(args: CommandArgs, ctx: AddCtx): Promise<void>
165
161
  name = sourceToKey(source);
166
162
  } else {
167
163
  ctx.ui.notify(
168
- "Usage: ct add <source> [--rating <core|useful|debatable>] [--type <skill|pi-native>]",
164
+ "Usage: ct add <source> [--type <skill|pi-native>]",
169
165
  "error",
170
166
  );
171
167
  return;
172
168
  }
173
169
 
174
- const rating = resolveRating(flags);
175
170
  let type = resolveType(flags);
176
171
 
177
172
  // --- Read catalog ---------------------------------------------------------
@@ -192,7 +187,7 @@ export async function addCommand(args: CommandArgs, ctx: AddCtx): Promise<void>
192
187
 
193
188
  // --- Add package ----------------------------------------------------------
194
189
  try {
195
- const updated = addPackage(catalog, name, source, rating, type);
190
+ const updated = addPackage(catalog, name, source, type);
196
191
  writeCatalog(updated, ctx.home);
197
192
  } catch (err: unknown) {
198
193
  const message = err instanceof Error ? err.message : String(err);
@@ -203,6 +198,7 @@ export async function addCommand(args: CommandArgs, ctx: AddCtx): Promise<void>
203
198
  ctx.ui.notify(`Added "${name}" to catalog`, "info");
204
199
 
205
200
  // --- Run pi install -------------------------------------------------------
201
+ ctx.ui.setWorkingMessage?.(`Installing ${name}...`);
206
202
  try {
207
203
  await piInstall(source);
208
204
  } catch {
@@ -211,4 +207,14 @@ export async function addCommand(args: CommandArgs, ctx: AddCtx): Promise<void>
211
207
  "warning",
212
208
  );
213
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
+ }
214
220
  }
@@ -30,7 +30,7 @@ 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
34
  { name: "update", aliases: ["up"], description: "Update packages to latest versions" },
35
35
  { name: "disable", description: "Disable a package" },
36
36
  { name: "enable", description: "Enable a package" },
@@ -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);
@@ -97,6 +97,7 @@ export async function removeCommand(
97
97
  let uninstalled = 0;
98
98
  let failed = 0;
99
99
  for (const name of piStefNames) {
100
+ ctx.ui.setWorkingMessage?.(`Uninstalling ${name} (${uninstalled + 1}/${piStefNames.length})...`);
100
101
  try {
101
102
  await piUninstall(sources[name]);
102
103
  uninstalled++;
@@ -105,6 +106,7 @@ export async function removeCommand(
105
106
  failed++;
106
107
  }
107
108
  }
109
+ ctx.ui.setWorkingMessage?.();
108
110
 
109
111
  ctx.ui.notify(
110
112
  `Scope @pi-stef: removed ${piStefNames.length}, uninstalled ${uninstalled}${failed > 0 ? ` (${failed} uninstall failed)` : ""}`,
@@ -163,6 +165,7 @@ export async function removeCommand(
163
165
  ctx.ui.notify(`Removed "${name}" from catalog`, "info");
164
166
 
165
167
  // --- Run pi uninstall -----------------------------------------------------
168
+ ctx.ui.setWorkingMessage?.(`Uninstalling ${name}...`);
166
169
  try {
167
170
  await piUninstall(source);
168
171
  } catch {
@@ -171,4 +174,5 @@ export async function removeCommand(
171
174
  "warning",
172
175
  );
173
176
  }
177
+ ctx.ui.setWorkingMessage?.();
174
178
  }
@@ -91,6 +91,7 @@ export async function resetCommand(
91
91
  let failed = 0;
92
92
 
93
93
  for (const name of piStefNames) {
94
+ ctx.ui.setWorkingMessage?.(`Uninstalling ${name} (${uninstalled + 1}/${piStefNames.length})...`);
94
95
  try {
95
96
  await piUninstall(packages[name].source);
96
97
  uninstalled++;
@@ -99,6 +100,7 @@ export async function resetCommand(
99
100
  failed++;
100
101
  }
101
102
  }
103
+ ctx.ui.setWorkingMessage?.();
102
104
 
103
105
  // --- Delete config files --------------------------------------------------
104
106
  const dir = catalogDir(ctx.home);
@@ -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
  }
@@ -120,6 +120,7 @@ export async function syncCommand(
120
120
  // --- 2. Pull remote catalog (into memory only) ---------------------------
121
121
  let remoteCatalog = false;
122
122
  let pulledData: { catalog: CatalogYaml; lock: LockFile } | undefined;
123
+ ctx.ui.setWorkingMessage?.("Pulling remote catalog...");
123
124
  try {
124
125
  pulledData = await pullCatalog(profile, ctx.home);
125
126
  remoteCatalog = true;
@@ -129,6 +130,7 @@ export async function syncCommand(
129
130
  ctx.ui.notify(`Pull failed: ${message}`, "warning");
130
131
  summary.errors.push(message);
131
132
  }
133
+ ctx.ui.setWorkingMessage?.();
132
134
 
133
135
  // --- 3. Reconcile --------------------------------------------------------
134
136
  // Use pulled catalog if available, otherwise read from disk
@@ -172,6 +174,12 @@ export async function syncCommand(
172
174
  }
173
175
  }
174
176
 
177
+ // Track whether the rebuilt lock (from buildSyncedLock) differs from the
178
+ // remote lock. This catches the case where `pi update` bumped an installed
179
+ // version but the catalog source string hasn't changed, so the pre-pull
180
+ // lock comparison wouldn't detect it.
181
+ let rebuiltLockDiffers = false;
182
+
175
183
  const installed = scanInstalled(ctx.home);
176
184
 
177
185
  // Build catalog entries for reconcile
@@ -220,7 +228,9 @@ export async function syncCommand(
220
228
  writeLock(pulledData.lock, ctx.home);
221
229
  }
222
230
 
231
+ ctx.ui.setWorkingMessage?.("Executing actions...");
223
232
  const result = await executeActions(plan, { home: ctx.home });
233
+ ctx.ui.setWorkingMessage?.();
224
234
 
225
235
  for (const { error } of result.errors) {
226
236
  ctx.ui.notify(`Action error: ${error.message}`, "warning");
@@ -234,6 +244,28 @@ export async function syncCommand(
234
244
  // Always write a populated lock so "last sync" is accurate
235
245
  const syncedLock = buildSyncedLock(catalog, installed);
236
246
  writeLock(syncedLock, ctx.home);
247
+
248
+ // Compare rebuilt lock versions against remote to detect version drift
249
+ // from external `pi update` calls that bumped installed versions without
250
+ // changing the catalog source string.
251
+ if (pulledData) {
252
+ const remoteLock = pulledData.lock;
253
+ for (const [key, rebuiltEntry] of Object.entries(syncedLock.packages)) {
254
+ const remoteEntry = remoteLock.packages[key];
255
+ if (!remoteEntry || remoteEntry.version !== rebuiltEntry.version) {
256
+ rebuiltLockDiffers = true;
257
+ break;
258
+ }
259
+ }
260
+ if (!rebuiltLockDiffers) {
261
+ for (const key of Object.keys(remoteLock.packages)) {
262
+ if (!(key in syncedLock.packages)) {
263
+ rebuiltLockDiffers = true;
264
+ break;
265
+ }
266
+ }
267
+ }
268
+ }
237
269
  }
238
270
 
239
271
  // --- 5. Push if changed --------------------------------------------------
@@ -251,7 +283,8 @@ export async function syncCommand(
251
283
  const hasGist = readCachedGistId(ctx.home) !== undefined;
252
284
  const localHasPackages = Object.keys(catalog.packages).length > 0;
253
285
 
254
- if (force || summary.actionCount > 0 || hasLocalOnlyPackages || hasLocalLockChanges || (!hasGist && localHasPackages)) {
286
+ if (force || summary.actionCount > 0 || hasLocalOnlyPackages || hasLocalLockChanges || rebuiltLockDiffers || (!hasGist && localHasPackages)) {
287
+ ctx.ui.setWorkingMessage?.("Pushing to gist...");
255
288
  try {
256
289
  const updatedCatalog = readCatalog(ctx.home);
257
290
  const updatedLock = readLock(ctx.home);
@@ -268,6 +301,7 @@ export async function syncCommand(
268
301
  ctx.ui.notify(`Push failed: ${message}`, "error");
269
302
  summary.errors.push(message);
270
303
  }
304
+ ctx.ui.setWorkingMessage?.();
271
305
  }
272
306
 
273
307
  // --- 6. Report summary ---------------------------------------------------
@@ -282,7 +316,7 @@ export async function syncCommand(
282
316
  }
283
317
  }
284
318
 
285
- if (summary.actionCount === 0 && summary.errors.length === 0 && !force && !hasLocalOnlyPackages && !hasLocalLockChanges) {
319
+ if (summary.actionCount === 0 && summary.errors.length === 0 && !force && !hasLocalOnlyPackages && !hasLocalLockChanges && !rebuiltLockDiffers) {
286
320
  ctx.ui.notify("Catalog already up to date.", "info");
287
321
  return;
288
322
  }
@@ -298,6 +332,9 @@ export async function syncCommand(
298
332
  if (hasLocalLockChanges) {
299
333
  parts.push("Pushed local version updates.");
300
334
  }
335
+ if (rebuiltLockDiffers) {
336
+ parts.push("Rebuilt lock (version drift detected).");
337
+ }
301
338
  if (plan.installs.length > 0) {
302
339
  parts.push(`${plan.installs.length} install(s): ${plan.installs.map((a) => a.key).join(", ")}`);
303
340
  }
@@ -1,12 +1,9 @@
1
1
  /**
2
2
  * `ct toggle`, `ct enable`, and `ct disable` subcommand implementations.
3
3
  *
4
- * - `ct toggle <name>` cycles a package's rating through the cycle:
5
- * core useful debatable disabled core
6
- * - `ct enable <name>` sets a disabled package back to its previous rating
7
- * (or "core" if no previous rating stored). No-op when already enabled.
8
- * - `ct disable <name>` sets rating to disabled, saves the previous rating,
9
- * and runs `pi uninstall` to remove the package.
4
+ * - `ct toggle <name>` toggles a package's enabled state (enabled ↔ disabled)
5
+ * - `ct enable <name>` enables a disabled package. No-op when already enabled.
6
+ * - `ct disable <name>` disables a package and runs `pi uninstall`.
10
7
  *
11
8
  * All commands read/write `cat.yaml` via `readCatalog` / `writeCatalog`
12
9
  * and provide user feedback through `ctx.ui.notify`.
@@ -31,7 +28,7 @@ export type ToggleCtx = CommandCtx;
31
28
  /**
32
29
  * Execute the `ct toggle` subcommand.
33
30
  *
34
- * Cycles the package's rating through: core useful → debatable → disabled → core.
31
+ * Toggles the package's enabled state: enabled disabled.
35
32
  */
36
33
  export async function toggleCommand(
37
34
  args: CommandArgs,
@@ -49,8 +46,9 @@ export async function toggleCommand(
49
46
  try {
50
47
  const updated = togglePackage(catalog, name);
51
48
  writeCatalog(updated, ctx.home);
49
+ const isEnabled = updated.packages[name].enabled !== false;
52
50
  ctx.ui.notify(
53
- `Toggled "${name}" to ${updated.packages[name].rating}`,
51
+ `Toggled "${name}" now ${isEnabled ? "enabled" : "disabled"}`,
54
52
  "info",
55
53
  );
56
54
  } catch (err: unknown) {
@@ -66,8 +64,7 @@ export async function toggleCommand(
66
64
  /**
67
65
  * Execute the `ct enable` subcommand.
68
66
  *
69
- * Restores a disabled package to its previous rating (or "core").
70
- * No-op when the package is already enabled.
67
+ * Enables a disabled package. No-op when the package is already enabled.
71
68
  */
72
69
  export async function enableCommand(
73
70
  args: CommandArgs,
@@ -92,10 +89,7 @@ export async function enableCommand(
92
89
  }
93
90
 
94
91
  writeCatalog(updated, ctx.home);
95
- ctx.ui.notify(
96
- `Enabled "${name}" (rating: ${updated.packages[name].rating})`,
97
- "info",
98
- );
92
+ ctx.ui.notify(`Enabled "${name}"`, "info");
99
93
  } catch (err: unknown) {
100
94
  const message = err instanceof Error ? err.message : String(err);
101
95
  ctx.ui.notify(message, "error");
@@ -109,8 +103,7 @@ export async function enableCommand(
109
103
  /**
110
104
  * Execute the `ct disable` subcommand.
111
105
  *
112
- * Sets the package rating to "disabled", saves the previous rating for later
113
- * restoration, and runs `pi uninstall` to remove the package.
106
+ * Disables a package and runs `pi uninstall` to remove it.
114
107
  */
115
108
  export async function disableCommand(
116
109
  args: CommandArgs,
@@ -136,6 +129,7 @@ export async function disableCommand(
136
129
  }
137
130
 
138
131
  // Run pi uninstall after disabling
132
+ ctx.ui.setWorkingMessage?.(`Uninstalling ${name}...`);
139
133
  try {
140
134
  await piUninstall(name);
141
135
  } catch {
@@ -144,4 +138,5 @@ export async function disableCommand(
144
138
  "warning",
145
139
  );
146
140
  }
141
+ ctx.ui.setWorkingMessage?.();
147
142
  }
@@ -32,6 +32,8 @@ export interface CommandArgs {
32
32
  export interface CommandCtx {
33
33
  ui: {
34
34
  notify: (msg: string, type?: "error" | "info" | "warning") => void;
35
+ /** Show a temporary working message (e.g. "Adding..."). Pass undefined or no arg to clear. */
36
+ setWorkingMessage?: (msg?: string) => void;
35
37
  };
36
38
  /** Home directory override (for testing). */
37
39
  home?: string;
@@ -13,6 +13,7 @@
13
13
  import type { CommandArgs, CommandCtx } from "./types.js";
14
14
  import { readCatalog } from "../config/io.js";
15
15
  import { piUpdate } from "../util/exec.js";
16
+ import { checkSetupForSource, formatSetupStatus } from "../catalog/setup.js";
16
17
 
17
18
  // ---------------------------------------------------------------------------
18
19
  // updateCommand
@@ -48,12 +49,23 @@ export async function updateCommand(
48
49
  return;
49
50
  }
50
51
 
52
+ ctx.ui.setWorkingMessage?.(`Updating ${name}...`);
51
53
  try {
52
54
  await piUpdate(entry.source);
53
55
  ctx.ui.notify(`Updated "${name}"`, "info");
54
56
  } catch {
55
57
  ctx.ui.notify(`Warning: update of "${name}" failed`, "warning");
56
58
  }
59
+ ctx.ui.setWorkingMessage?.();
60
+
61
+ // Check setup requirements after update
62
+ const setup = checkSetupForSource(entry.source, ctx.home);
63
+ if (setup && !setup.ok) {
64
+ ctx.ui.notify(
65
+ `Setup incomplete for "${name}": ${formatSetupStatus(setup)}`,
66
+ "warning",
67
+ );
68
+ }
57
69
  return;
58
70
  }
59
71
 
@@ -66,20 +78,36 @@ export async function updateCommand(
66
78
 
67
79
  let updated = 0;
68
80
  let failed = 0;
81
+ const setupWarnings: string[] = [];
69
82
 
70
83
  for (const pkgName of names) {
71
84
  const entry = packages[pkgName];
85
+ ctx.ui.setWorkingMessage?.(`Updating ${pkgName} (${updated + 1}/${names.length})...`);
72
86
  try {
73
87
  await piUpdate(entry.source);
74
88
  updated++;
89
+
90
+ // Check setup after successful update
91
+ const setup = checkSetupForSource(entry.source, ctx.home);
92
+ if (setup && !setup.ok) {
93
+ setupWarnings.push(`${pkgName}: ${formatSetupStatus(setup)}`);
94
+ }
75
95
  } catch {
76
96
  ctx.ui.notify(`Warning: update of "${pkgName}" failed`, "warning");
77
97
  failed++;
78
98
  }
79
99
  }
100
+ ctx.ui.setWorkingMessage?.();
80
101
 
81
- ctx.ui.notify(
102
+ const parts: string[] = [
82
103
  `Updated ${updated}/${names.length} packages${failed > 0 ? ` (${failed} failed)` : ""}`,
83
- failed > 0 ? "warning" : "info",
104
+ ];
105
+ if (setupWarnings.length > 0) {
106
+ parts.push(`Setup incomplete:\n ${setupWarnings.join("\n ")}`);
107
+ }
108
+
109
+ ctx.ui.notify(
110
+ parts.join("\n"),
111
+ failed > 0 || setupWarnings.length > 0 ? "warning" : "info",
84
112
  );
85
113
  }
package/src/config/io.ts CHANGED
@@ -4,6 +4,7 @@ import yaml from "js-yaml";
4
4
  import { catalogFile, lockFile, ensureCatalogDir } from "./paths.js";
5
5
  import { CatalogYamlSchema, LockFileSchema } from "./schema.js";
6
6
  import type { CatalogYaml, LockFile } from "./schema.js";
7
+ import { migrateRatingToEnabledRaw } from "../catalog/migrate.js";
7
8
 
8
9
  // ---------------------------------------------------------------------------
9
10
  // Empty defaults
@@ -37,6 +38,8 @@ export function readCatalog(home?: string): CatalogYaml {
37
38
  }
38
39
 
39
40
  const parsed = yaml.load(raw);
41
+ // Migrate rating → enabled before Zod validation strips the unknown field
42
+ migrateRatingToEnabledRaw(parsed);
40
43
  return CatalogYamlSchema.parse(parsed);
41
44
  }
42
45
 
@@ -7,23 +7,16 @@ import { z } from "zod";
7
7
  /** Type discriminator for a catalog package entry. */
8
8
  export const PackageType = z.enum(["skill", "pi-native"]);
9
9
 
10
- /** Rating values for catalog packages. */
11
- export const Rating = z.enum(["core", "useful", "debatable", "disabled"]);
12
-
13
10
  /** A single package entry inside cat.yaml. */
14
11
  export const CatalogPackageSchema = z.object({
15
12
  /** Where to fetch the package from (URL, path, etc.). */
16
13
  source: z.string().min(1),
17
- /** User-assigned rating. */
18
- rating: Rating,
19
14
  /** Optional type discriminator. */
20
15
  type: PackageType.optional(),
21
16
  /** Optional profile name this package belongs to. */
22
17
  profile: z.string().optional(),
23
18
  /** Whether the package is active. Defaults to true when absent. */
24
19
  enabled: z.boolean().optional(),
25
- /** Previous rating before disable; used by enablePackage to restore. */
26
- previousRating: Rating.optional(),
27
20
  });
28
21
 
29
22
  /** The meta section at the top of cat.yaml. */
@@ -84,4 +77,3 @@ export type CatalogPackage = z.infer<typeof CatalogPackageSchema>;
84
77
  export type Profile = z.infer<typeof ProfileSchema>;
85
78
  export type LockFile = z.infer<typeof LockFileSchema>;
86
79
  export type LockPackage = z.infer<typeof LockPackageSchema>;
87
- export type RatingValue = z.infer<typeof Rating>;
package/src/register.ts CHANGED
@@ -224,13 +224,11 @@ export function registerCatalog(pi: ExtensionAPI): void {
224
224
  ],
225
225
  parameters: Type.Object({
226
226
  source: Type.String({ description: "Package source (npm:… or git:…)" }),
227
- rating: Type.Optional(Type.String({ description: "Initial rating (core, useful, debatable)" })),
228
227
  scope: Type.Optional(Type.String({ description: "Batch scope: '@pi-stef' to add all @pi-stef packages" })),
229
228
  }),
230
229
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
231
230
  try {
232
231
  const flags: Record<string, true | string> = {};
233
- if (params.rating) flags.rating = params.rating;
234
232
  if (params.scope) flags.scope = params.scope;
235
233
  const args: CommandArgs = {
236
234
  positional: params.scope ? [] : [params.source],
@@ -277,10 +275,10 @@ export function registerCatalog(pi: ExtensionAPI): void {
277
275
  name: "ct_toggle",
278
276
  label: "Catalog Toggle",
279
277
  description:
280
- "Toggle a package's rating through the cycle: core → useful → debatable → disabled → core.",
281
- promptSnippet: "Toggle a package's catalog rating",
278
+ "Toggle a package's enabled state (enabled disabled).",
279
+ promptSnippet: "Toggle a package's enabled state",
282
280
  promptGuidelines: [
283
- "Use ct_toggle when the user wants to cycle a package's rating.",
281
+ "Use ct_toggle when the user wants to enable or disable a package.",
284
282
  ],
285
283
  parameters: Type.Object({
286
284
  name: Type.String({ description: "Package name to toggle" }),
package/src/sync/pull.ts CHANGED
@@ -2,6 +2,7 @@ import yaml from "js-yaml";
2
2
 
3
3
  import { CatalogYamlSchema, LockFileSchema } from "../config/schema.js";
4
4
  import type { CatalogYaml, LockFile } from "../config/schema.js";
5
+ import { migrateRatingToEnabledRaw } from "../catalog/migrate.js";
5
6
  import { readGist, findGistByDescription } from "./gist.js";
6
7
  import { readCachedGistId, writeCachedGistId } from "./cache.js";
7
8
 
@@ -62,6 +63,7 @@ export async function pullCatalog(
62
63
  const lockJsonContent = gist.files["catalog.lock.json"]?.content ?? "";
63
64
 
64
65
  const parsedYaml = yaml.load(catYamlContent);
66
+ migrateRatingToEnabledRaw(parsedYaml);
65
67
  const catalog: CatalogYaml = CatalogYamlSchema.parse(parsedYaml);
66
68
 
67
69
  const parsedLock = JSON.parse(lockJsonContent);