@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.
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.3.5",
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
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Hardcoded list of @pi-stef packages for scope-based batch operations.
3
+ *
4
+ * Used by `ct add --scope @pi-stef` and `ct remove --scope @pi-stef`
5
+ * to identify which packages belong to the @pi-stef ecosystem.
6
+ *
7
+ * NOTE: `@pi-stef/catalog` is intentionally excluded — it manages the
8
+ * other packages and should not be batch-operated on itself.
9
+ */
10
+
11
+ import { extractNpmName } from "./source.js";
12
+
13
+ /** The catalog package itself (excluded from batch operations). */
14
+ export const CATALOG_PACKAGE_NAME = "@pi-stef/catalog";
15
+
16
+ /**
17
+ * All @pi-stef packages except the catalog itself.
18
+ *
19
+ * This list is used for `--scope @pi-stef` batch operations.
20
+ */
21
+ export const PI_STEF_PACKAGES: readonly string[] = [
22
+ "@pi-stef/agent-workflows",
23
+ "@pi-stef/atlassian",
24
+ "@pi-stef/figma",
25
+ "@pi-stef/paths",
26
+ "@pi-stef/team",
27
+ "@pi-stef/web",
28
+ ] as const;
29
+
30
+ /**
31
+ * Returns true if the given package name is a @pi-stef package
32
+ * (excluding the catalog itself).
33
+ */
34
+ export function isPiStefPackage(name: string): boolean {
35
+ return PI_STEF_PACKAGES.includes(name);
36
+ }
37
+
38
+ /**
39
+ * Returns true if the source string refers to a @pi-stef package.
40
+ *
41
+ * Handles both `npm:@scope/pkg@version` and bare package name formats.
42
+ * Explicitly excludes `@pi-stef/catalog` — it manages the others
43
+ * and should not be included in batch scope operations.
44
+ */
45
+ export function isPiStefSource(source: string): boolean {
46
+ // npm: prefixed source — extract the package name
47
+ if (source.startsWith("npm:")) {
48
+ const pkgName = extractNpmName(source.slice(4));
49
+ // Never include the catalog package itself in batch operations
50
+ if (pkgName === CATALOG_PACKAGE_NAME) return false;
51
+ return PI_STEF_PACKAGES.includes(pkgName);
52
+ }
53
+
54
+ // Non-npm source — check if it's a bare package name
55
+ return PI_STEF_PACKAGES.includes(source);
56
+ }
@@ -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
+ }