@pi-stef/catalog 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,205 @@
1
+ # @pi-stef/catalog
2
+
3
+ Declarative package manager for the [pi](https://pi.dev) coding agent. Manage your skills and extensions from a single `cat.yaml` file, sync across machines via GitHub Gist.
4
+
5
+ ## Installation
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
+ ```bash
16
+ pi install npm:@pi-stef/catalog
17
+ ```
18
+
19
+ ## Quick Start
20
+
21
+ ```bash
22
+ # 1. Authenticate with GitHub (requires gh CLI)
23
+ /ct login
24
+
25
+ # 2. Initialize catalog from installed packages (or import from a gist)
26
+ /ct init
27
+ # or: /ct init --from-gist=<gist-id>
28
+
29
+ # 3. Sync — install missing, remove orphaned, push changes to gist
30
+ /ct sync
31
+ ```
32
+
33
+ After `ct login`, your GitHub token is cached for future sync operations.
34
+
35
+ ## Command Reference
36
+
37
+ All commands are invoked as `/ct <subcommand>` inside pi, or via the shorthand `/ct-<subcommand>`.
38
+
39
+ | Subcommand | Alias | Description | Flags |
40
+ |---|---|---|---|
41
+ | `sync` | — | Full sync cycle: pull → reconcile → execute → push | `--dry-run`, `--force`, `--no-push`, `--profile=<name>` |
42
+ | `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) | — |
48
+ | `push` | — | Push local catalog + lock to GitHub Gist | `--dry-run`, `--profile=<name>` |
49
+ | `pull` | — | Pull remote catalog from gist and reconcile | `--dry-run`, `--profile=<name>` |
50
+ | `login` | — | Authenticate with GitHub via `gh` CLI | — |
51
+ | `status` | — | Show catalog status, package counts, gist URL, last sync | — |
52
+ | `diff` | — | Show diff between local and remote catalog | — |
53
+ | `verify` | — | Verify catalog integrity (sources, ratings, duplicates) | — |
54
+ | `profiles` | — | List all profiles with active indicator | — |
55
+ | `profile` | — | Show or switch active profile | — |
56
+
57
+ ### Adding Packages
58
+
59
+ ```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
65
+
66
+ # Add an npm package
67
+ /ct add lodash npm:lodash
68
+ ```
69
+
70
+ ### Removing Packages
71
+
72
+ ```bash
73
+ /ct remove my-skill
74
+ ```
75
+
76
+ ## `cat.yaml` Format
77
+
78
+ The catalog is stored in `cat.yaml`. Example:
79
+
80
+ ```yaml
81
+ meta:
82
+ pi_version: "0.70.0"
83
+ activeProfile: default
84
+
85
+ packages:
86
+ superpowers-adapter:
87
+ source: "git:github.com/sfiorini/pi-stef#packages/superpowers-adapter"
88
+ rating: core
89
+ type: skill
90
+ team:
91
+ source: "git:github.com/sfiorini/pi-stef#packages/team"
92
+ rating: core
93
+ type: skill
94
+ atlassian:
95
+ source: "git:github.com/sfiorini/pi-stef#packages/atlassian"
96
+ rating: useful
97
+ type: skill
98
+ enabled: false
99
+ previousRating: useful
100
+ ```
101
+
102
+ ### Package Fields
103
+
104
+ | Field | Required | Description |
105
+ |---|---|---|
106
+ | `source` | ✓ | Package source URL (`npm:…` or `git:…`) |
107
+ | `rating` | ✓ | One of: `core`, `useful`, `debatable`, `disabled` |
108
+ | `type` | — | `skill` or `pi-native` |
109
+ | `profile` | — | Profile name this package belongs to |
110
+ | `enabled` | — | `true` (default) or `false` |
111
+ | `previousRating` | — | Rating before disable; restored by `ct enable` |
112
+
113
+ ### Examples
114
+
115
+ **NPM source:**
116
+ ```yaml
117
+ packages:
118
+ lodash:
119
+ source: "npm:lodash"
120
+ rating: useful
121
+ ```
122
+
123
+ **Git source:**
124
+ ```yaml
125
+ packages:
126
+ my-extension:
127
+ source: "git:github.com/user/repo#packages/my-extension"
128
+ rating: core
129
+ type: pi-native
130
+ ```
131
+
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
139
+ ```
140
+
141
+ ## Profiles
142
+
143
+ Profiles let you maintain different package sets for different machines or contexts (e.g., work vs. personal).
144
+
145
+ ```yaml
146
+ meta:
147
+ pi_version: "0.70.0"
148
+ activeProfile: work
149
+
150
+ packages:
151
+ superpowers-adapter:
152
+ source: "git:github.com/sfiorini/pi-stef#packages/superpowers-adapter"
153
+ rating: core
154
+
155
+ profiles:
156
+ work:
157
+ packages:
158
+ atlassian:
159
+ source: "git:github.com/sfiorini/pi-stef#packages/atlassian"
160
+ rating: core
161
+ personal:
162
+ packages:
163
+ figma:
164
+ source: "git:github.com/sfiorini/pi-stef#packages/figma"
165
+ rating: useful
166
+ ```
167
+
168
+ **Profile commands:**
169
+ - `/ct profiles` — list all profiles (shows active with a marker)
170
+ - `/ct profile <name>` — switch active profile
171
+ - `--profile=<name>` flag on `sync`, `push`, `pull` — operate on a specific profile
172
+
173
+ The `default` profile always exists and uses the base `packages` section. Profile packages override base packages with the same key.
174
+
175
+ ## Configuration
176
+
177
+ ### File Locations
178
+
179
+ | File | Path | Purpose |
180
+ |---|---|---|
181
+ | Catalog | `~/.pi/sf/catalog/cat.yaml` | Declarative package manifest |
182
+ | Lock file | `~/.pi/sf/catalog/catalog.lock.json` | Installed versions and hashes |
183
+ | Gist cache | `~/.pi/sf/catalog/` | Cached gist ID for sync |
184
+
185
+ ### GitHub Gist Setup
186
+
187
+ Sync uses GitHub Gists for cloud storage. Prerequisites:
188
+
189
+ 1. Install the [GitHub CLI (`gh`)](https://cli.github.com/)
190
+ 2. Authenticate: `gh auth login`
191
+ 3. Run `/ct login` inside pi to verify and cache your token
192
+
193
+ On first `ct push` or `ct sync`, a secret gist is created automatically.
194
+
195
+ ## Development
196
+
197
+ ```bash
198
+ pnpm install # Install dependencies
199
+ pnpm -F @pi-stef/catalog test # Run tests
200
+ pnpm -F @pi-stef/catalog typecheck # Type check
201
+ ```
202
+
203
+ ## License
204
+
205
+ MIT
@@ -0,0 +1,7 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { registerCatalog } from "../src/register.js";
4
+
5
+ export default function catalogExtension(pi: ExtensionAPI): void {
6
+ registerCatalog(pi);
7
+ }
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@pi-stef/catalog",
3
+ "version": "0.2.2",
4
+ "description": "Pi extension for managing skill/package catalogs.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "files": [
8
+ "src/",
9
+ "extensions/"
10
+ ],
11
+ "exports": {
12
+ ".": "./src/index.ts",
13
+ "./package.json": "./package.json"
14
+ },
15
+ "keywords": [
16
+ "pi-package",
17
+ "pi-extension",
18
+ "catalog"
19
+ ],
20
+ "dependencies": {
21
+ "@octokit/rest": "^21.0.0",
22
+ "js-yaml": "^4.1.0",
23
+ "@sinclair/typebox": "*",
24
+ "zod": "^3.25.62",
25
+ "@pi-stef/paths": "0.2.2"
26
+ },
27
+ "peerDependencies": {
28
+ "@earendil-works/pi-coding-agent": "*"
29
+ },
30
+ "pi": {
31
+ "extensions": [
32
+ "./extensions"
33
+ ]
34
+ },
35
+ "devDependencies": {
36
+ "@types/js-yaml": "^4.0.9"
37
+ },
38
+ "scripts": {
39
+ "test": "vitest run",
40
+ "typecheck": "tsc --noEmit -p tsconfig.json"
41
+ }
42
+ }
@@ -0,0 +1,143 @@
1
+ import type { CatalogYaml, CatalogPackage } from "../config/schema.js";
2
+ import type { RatingValue } from "./ratings.js";
3
+ import { isValidSource, isDisabled, nextRating } from "./ratings.js";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Immutable helpers
7
+ // ---------------------------------------------------------------------------
8
+
9
+ /** Shallow-clone the catalog and deep-clone the packages record. */
10
+ function cloneCatalog(catalog: CatalogYaml): CatalogYaml {
11
+ return {
12
+ ...catalog,
13
+ packages: { ...catalog.packages },
14
+ };
15
+ }
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // CRUD operations
19
+ // ---------------------------------------------------------------------------
20
+
21
+ /**
22
+ * Add a new package to the catalog.
23
+ *
24
+ * @throws if a package with the same name already exists
25
+ * @throws if the source format is invalid
26
+ */
27
+ export function addPackage(
28
+ catalog: CatalogYaml,
29
+ name: string,
30
+ source: string,
31
+ rating: RatingValue,
32
+ type?: "skill" | "pi-native",
33
+ ): CatalogYaml {
34
+ if (catalog.packages[name]) {
35
+ throw new Error(`Package "${name}" already exists`);
36
+ }
37
+
38
+ if (!isValidSource(source)) {
39
+ throw new Error(
40
+ `Invalid source "${source}": must start with "npm:" or "git:"`,
41
+ );
42
+ }
43
+
44
+ const entry: CatalogPackage = { source, rating };
45
+ if (type !== undefined) {
46
+ entry.type = type;
47
+ }
48
+
49
+ const next = cloneCatalog(catalog);
50
+ next.packages[name] = entry;
51
+ return next;
52
+ }
53
+
54
+ /**
55
+ * Remove a package from the catalog.
56
+ *
57
+ * @throws if the package is not found
58
+ */
59
+ export function removePackage(
60
+ catalog: CatalogYaml,
61
+ name: string,
62
+ ): CatalogYaml {
63
+ if (!catalog.packages[name]) {
64
+ throw new Error(`Package "${name}" not found`);
65
+ }
66
+
67
+ const next = cloneCatalog(catalog);
68
+ delete next.packages[name];
69
+ return next;
70
+ }
71
+
72
+ /**
73
+ * Toggle a package's rating through the cycle:
74
+ * core → useful → debatable → disabled → core
75
+ *
76
+ * @throws if the package is not found
77
+ */
78
+ export function togglePackage(
79
+ catalog: CatalogYaml,
80
+ name: string,
81
+ ): CatalogYaml {
82
+ const entry = catalog.packages[name];
83
+ if (!entry) {
84
+ throw new Error(`Package "${name}" not found`);
85
+ }
86
+
87
+ const next = cloneCatalog(catalog);
88
+ next.packages[name] = {
89
+ ...entry,
90
+ rating: nextRating(entry.rating),
91
+ };
92
+ return next;
93
+ }
94
+
95
+ /**
96
+ * Enable a disabled package, restoring its previous rating (or "core").
97
+ * No-op when the package is already enabled.
98
+ *
99
+ * @throws if the package is not found
100
+ */
101
+ export function enablePackage(
102
+ catalog: CatalogYaml,
103
+ name: string,
104
+ ): CatalogYaml {
105
+ const entry = catalog.packages[name];
106
+ if (!entry) {
107
+ throw new Error(`Package "${name}" not found`);
108
+ }
109
+
110
+ // No-op if already enabled
111
+ if (!isDisabled(entry.rating)) {
112
+ return catalog;
113
+ }
114
+
115
+ const restored = entry.previousRating ?? "core";
116
+ const next = cloneCatalog(catalog);
117
+ const { previousRating: _, ...clean } = entry;
118
+ next.packages[name] = { ...clean, rating: restored };
119
+ return next;
120
+ }
121
+
122
+ /**
123
+ * Disable a package, saving its current rating for later restoration.
124
+ *
125
+ * @throws if the package is not found
126
+ */
127
+ export function disablePackage(
128
+ catalog: CatalogYaml,
129
+ name: string,
130
+ ): CatalogYaml {
131
+ const entry = catalog.packages[name];
132
+ if (!entry) {
133
+ throw new Error(`Package "${name}" not found`);
134
+ }
135
+
136
+ const next = cloneCatalog(catalog);
137
+ next.packages[name] = {
138
+ ...entry,
139
+ previousRating: entry.rating,
140
+ rating: "disabled",
141
+ };
142
+ return next;
143
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Scanning installed pi packages from settings.json.
3
+ *
4
+ * S-302: scanInstalled reads ~/.pi/agent/settings.json and optionally
5
+ * <cwd>/.pi/settings.json (project variant), parses the `packages`
6
+ * array from each, and returns a merged map keyed by package name
7
+ * with source, name, and version info. Project settings take
8
+ * precedence over global for the same package key.
9
+ */
10
+
11
+ import path from "node:path";
12
+ import os from "node:os";
13
+ import fs from "node:fs";
14
+
15
+ import { parseSource } from "./source.js";
16
+ import { npmNodeModulesDir } from "../config/paths.js";
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Types
20
+ // ---------------------------------------------------------------------------
21
+
22
+ export interface InstalledPackage {
23
+ /** The raw source string as it appears in settings.json. */
24
+ source: string;
25
+ /**
26
+ * Human-readable / identity name derived from the source.
27
+ * For npm: the npm package name (e.g. "@foo/bar").
28
+ * For git: host/path (e.g. "github.com/user/repo").
29
+ * For local: directory basename or the path itself.
30
+ */
31
+ name: string;
32
+ /** Installed version if discoverable, otherwise undefined. */
33
+ version: string | undefined;
34
+ }
35
+
36
+ export type InstalledMap = Record<string, InstalledPackage>;
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Version resolution
40
+ // ---------------------------------------------------------------------------
41
+
42
+ /**
43
+ * Attempt to read the version from an installed npm package's package.json.
44
+ * Returns undefined if the file doesn't exist or can't be read.
45
+ */
46
+ function readNpmVersion(
47
+ home: string,
48
+ npmName: string,
49
+ ): string | undefined {
50
+ const pkgJsonPath = path.join(
51
+ npmNodeModulesDir(home),
52
+ npmName,
53
+ "package.json",
54
+ );
55
+ try {
56
+ const raw = fs.readFileSync(pkgJsonPath, "utf-8");
57
+ const parsed = JSON.parse(raw);
58
+ return typeof parsed.version === "string" ? parsed.version : undefined;
59
+ } catch {
60
+ return undefined;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Attempt to read the version from a local path package's package.json.
66
+ * Returns undefined if the file doesn't exist or can't be read.
67
+ */
68
+ function readLocalVersion(home: string, localPath: string): string | undefined {
69
+ // Resolve relative paths against home (settings dir)
70
+ const settingsDir = path.join(home, ".pi", "agent");
71
+ const resolved = path.resolve(settingsDir, localPath);
72
+ const pkgJsonPath = path.join(resolved, "package.json");
73
+ try {
74
+ const raw = fs.readFileSync(pkgJsonPath, "utf-8");
75
+ const parsed = JSON.parse(raw);
76
+ return typeof parsed.version === "string" ? parsed.version : undefined;
77
+ } catch {
78
+ return undefined;
79
+ }
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // scanInstalled
84
+ // ---------------------------------------------------------------------------
85
+
86
+ /**
87
+ * Read packages from a settings.json file and return a map of installed packages.
88
+ * Returns empty map if the file doesn't exist or is malformed.
89
+ */
90
+ function readPackagesFromSettings(
91
+ settingsPath: string,
92
+ home: string,
93
+ ): InstalledMap {
94
+ let settingsJson: string;
95
+ try {
96
+ settingsJson = fs.readFileSync(settingsPath, "utf-8");
97
+ } catch (err: unknown) {
98
+ if (err instanceof Error && (err as NodeJS.ErrnoException).code === "ENOENT") {
99
+ return {};
100
+ }
101
+ throw err;
102
+ }
103
+
104
+ let settings: Record<string, unknown>;
105
+ try {
106
+ settings = JSON.parse(settingsJson);
107
+ } catch (parseErr: unknown) {
108
+ throw new Error(
109
+ `Malformed JSON in ${settingsPath}: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`,
110
+ );
111
+ }
112
+
113
+ const packages = settings.packages;
114
+ if (!Array.isArray(packages)) {
115
+ return {};
116
+ }
117
+
118
+ const result: InstalledMap = {};
119
+
120
+ for (const entry of packages) {
121
+ let rawSource: string;
122
+ if (typeof entry === "string") {
123
+ rawSource = entry;
124
+ } else if (
125
+ entry != null &&
126
+ typeof entry === "object" &&
127
+ "source" in entry &&
128
+ typeof (entry as { source: unknown }).source === "string"
129
+ ) {
130
+ rawSource = (entry as { source: string }).source;
131
+ } else {
132
+ continue;
133
+ }
134
+
135
+ const parsed = parseSource(rawSource);
136
+
137
+ let version: string | undefined;
138
+ if (parsed.type === "npm") {
139
+ version = readNpmVersion(home, parsed.npmName!);
140
+ } else if (parsed.type === "local") {
141
+ version = readLocalVersion(home, rawSource);
142
+ }
143
+
144
+ const key = parsed.type === "local" ? rawSource : parsed.name;
145
+
146
+ result[key] = {
147
+ source: rawSource,
148
+ name: parsed.name,
149
+ version,
150
+ };
151
+ }
152
+
153
+ return result;
154
+ }
155
+
156
+ /**
157
+ * Discover currently installed pi packages by reading
158
+ * `~/.pi/agent/settings.json` and optionally
159
+ * `<cwd>/.pi/settings.json`.
160
+ *
161
+ * When `cwd` is provided, project settings are merged on top of
162
+ * global settings — project packages take precedence for the same key.
163
+ *
164
+ * Returns a map keyed by package identity name, each entry containing
165
+ * the raw source, derived name, and version (if discoverable).
166
+ */
167
+ export function scanInstalled(home?: string, cwd?: string): InstalledMap {
168
+ const resolvedHome = home ?? os.homedir();
169
+ const globalPath = path.join(resolvedHome, ".pi", "agent", "settings.json");
170
+
171
+ const result = readPackagesFromSettings(globalPath, resolvedHome);
172
+
173
+ if (cwd) {
174
+ const projectPath = path.join(cwd, ".pi", "settings.json");
175
+ const projectPackages = readPackagesFromSettings(projectPath, resolvedHome);
176
+ // Project packages override global for the same key
177
+ Object.assign(result, projectPackages);
178
+ }
179
+
180
+ return result;
181
+ }
@@ -0,0 +1,32 @@
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
+ /**
18
+ * Validates a source string.
19
+ * Accepted formats:
20
+ * - npm:<package-name>
21
+ * - git:<url>
22
+ * - git:<url>#<subpath>
23
+ * - local paths (relative or absolute)
24
+ */
25
+ export function isValidSource(source: string): boolean {
26
+ if (!source) return false;
27
+ if (/^npm:/.test(source)) return true;
28
+ if (/^git:/.test(source)) return true;
29
+ // Local paths (relative or absolute) are also valid
30
+ if (source.startsWith("./") || source.startsWith("../") || source.startsWith("/")) return true;
31
+ return false;
32
+ }