@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 +205 -0
- package/extensions/catalog.ts +7 -0
- package/package.json +42 -0
- package/src/catalog/crud.ts +143 -0
- package/src/catalog/install.ts +181 -0
- package/src/catalog/ratings.ts +32 -0
- package/src/catalog/reconcile.ts +339 -0
- package/src/catalog/source.ts +173 -0
- package/src/commands/add.ts +135 -0
- package/src/commands/definitions.ts +78 -0
- package/src/commands/diff.ts +158 -0
- package/src/commands/dispatch.ts +102 -0
- package/src/commands/init.ts +127 -0
- package/src/commands/login.ts +105 -0
- package/src/commands/profiles.ts +147 -0
- package/src/commands/remove.ts +90 -0
- package/src/commands/status.ts +142 -0
- package/src/commands/sync.ts +406 -0
- package/src/commands/toggle.ts +147 -0
- package/src/commands/types.ts +38 -0
- package/src/commands/verify.ts +107 -0
- package/src/config/io.ts +82 -0
- package/src/config/paths.ts +44 -0
- package/src/config/schema.ts +87 -0
- package/src/index.ts +94 -0
- package/src/profiles/manager.ts +159 -0
- package/src/register.ts +285 -0
- package/src/sync/auth.ts +109 -0
- package/src/sync/cache.ts +40 -0
- package/src/sync/gist.ts +253 -0
- package/src/sync/pull.ts +76 -0
- package/src/sync/push.ts +78 -0
- package/src/update/pi-update.ts +60 -0
- package/src/update/registry.ts +27 -0
- package/src/update/self-update.ts +60 -0
- package/src/update/semver.ts +38 -0
- package/src/update/types.ts +21 -0
- package/src/update/update-cache.ts +54 -0
- package/src/util/errors.ts +144 -0
- package/src/util/exec.ts +160 -0
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
|
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
|
+
}
|