@timurci/pi-profiles 0.1.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/LICENSE +7 -0
- package/README.md +116 -0
- package/extensions/profiles.ts +412 -0
- package/package.json +25 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright 2026 Timur Çakmakoğlu
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# pi-profiles
|
|
2
|
+
|
|
3
|
+
Reusable pi configuration profiles — list, load, and capture from any project.
|
|
4
|
+
|
|
5
|
+
This package lets you save and reuse your `.pi/` setup (extensions, skills, settings, prompts) as a named profile and switch between profiles per-project via slash commands.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
**From npm** (recommended):
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pi install npm:@timurci/pi-profiles
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
**From GitHub**:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pi install git:github.com/timurci/pi-profiles
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Pin a specific version:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pi install npm:@timurci/pi-profiles@0.1.0
|
|
25
|
+
pi install git:github.com/timurci/pi-profiles@v0.1.0
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
Three slash commands are registered when the extension is loaded:
|
|
31
|
+
|
|
32
|
+
| Command | Description |
|
|
33
|
+
| --- | --- |
|
|
34
|
+
| `/profiles` | List all available profiles |
|
|
35
|
+
| `/profile <name>` | Load a profile (prompts to choose if name omitted) |
|
|
36
|
+
| `/profile-from-project` | Capture the current `./.pi` as a new profile |
|
|
37
|
+
|
|
38
|
+
### `/profile <name>`
|
|
39
|
+
|
|
40
|
+
Creates a symlink at `./.pi` pointing to `~/.pi/profiles/<name>`, then
|
|
41
|
+
triggers a `/reload` so the profile's extensions, skills, etc. take
|
|
42
|
+
effect immediately.
|
|
43
|
+
|
|
44
|
+
If `./.pi` already exists:
|
|
45
|
+
|
|
46
|
+
- **Symlink to another profile** — asked to confirm before replacement
|
|
47
|
+
- **Real file or directory** — asked to confirm before being removed and replaced
|
|
48
|
+
- **Already pointing to the requested profile** — no-op
|
|
49
|
+
|
|
50
|
+
Tab completion is available for the profile argument.
|
|
51
|
+
|
|
52
|
+
### `/profile-from-project`
|
|
53
|
+
|
|
54
|
+
Copies `./.pi` into `~/.pi/profiles/<new-id>/`. If a profile with the
|
|
55
|
+
same id already exists, you'll be warned and asked to confirm the
|
|
56
|
+
overwrite.
|
|
57
|
+
|
|
58
|
+
If `./.pi` has a `PROFILE.md`, its `name` and `description` are reused
|
|
59
|
+
and no prompts are shown.
|
|
60
|
+
|
|
61
|
+
If `./.pi` does **not** have a `PROFILE.md`, the command prompts for:
|
|
62
|
+
|
|
63
|
+
1. **Name** — empty input falls back to the cwd basename.
|
|
64
|
+
2. **Description** — required; the prompt re-asks if you submit an empty value.
|
|
65
|
+
|
|
66
|
+
A `PROFILE.md` is then generated in the new profile directory using
|
|
67
|
+
the values you provided.
|
|
68
|
+
|
|
69
|
+
## Config
|
|
70
|
+
|
|
71
|
+
Optional: `~/.pi/agent/extensions/profiles.json`
|
|
72
|
+
|
|
73
|
+
```json
|
|
74
|
+
{
|
|
75
|
+
"profilesDir": "/custom/path/to/profiles"
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
If `profilesDir` is omitted, defaults to `~/.pi/profiles`. Run
|
|
80
|
+
`/reload` after editing.
|
|
81
|
+
|
|
82
|
+
## Profile format
|
|
83
|
+
|
|
84
|
+
Each profile is a directory under `profilesDir` containing a `PROFILE.md`
|
|
85
|
+
file with YAML frontmatter (same convention as `SKILL.md`):
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
~/.pi/profiles/
|
|
89
|
+
└── python-dev/
|
|
90
|
+
├── PROFILE.md
|
|
91
|
+
├── skills/
|
|
92
|
+
│ └── ...
|
|
93
|
+
└── extensions/
|
|
94
|
+
└── ...
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`PROFILE.md`:
|
|
98
|
+
|
|
99
|
+
```markdown
|
|
100
|
+
---
|
|
101
|
+
name: python-dev
|
|
102
|
+
description: Python development environment with ML/data tooling.
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
# Python Profile
|
|
106
|
+
|
|
107
|
+
Add profile documentation here.
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Only `name` and `description` are read by this extension. The rest of
|
|
111
|
+
the file is freeform.
|
|
112
|
+
|
|
113
|
+
## Security
|
|
114
|
+
|
|
115
|
+
This extension runs with your full user permissions, creates symlinks in your current working directory, and copies files from `./.pi` to your home directory. The [pi security model](https://github.com/earendil-works/pi-mono/blob/main/docs/security.md) is explicit that extensions and third-party packages are full-trust — do not install profiles from sources you have not reviewed.
|
|
116
|
+
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Profiles extension for pi.
|
|
3
|
+
*
|
|
4
|
+
* Manages reusable "profiles" - bundles of pi configuration (extensions,
|
|
5
|
+
* skills, settings, prompts) stored under `~/.pi/profiles/<name>/`.
|
|
6
|
+
*
|
|
7
|
+
* Each profile is a directory containing a `PROFILE.md` file with YAML
|
|
8
|
+
* frontmatter (same convention as `SKILL.md`):
|
|
9
|
+
*
|
|
10
|
+
* ---
|
|
11
|
+
* name: my-profile
|
|
12
|
+
* description: What this profile does and when to use it.
|
|
13
|
+
* ---
|
|
14
|
+
*
|
|
15
|
+
* Loading a profile creates a symlink from the project's `./.pi` to the
|
|
16
|
+
* profile directory, then reloads pi so the new resources take effect
|
|
17
|
+
* immediately (no need to run `/reload` manually).
|
|
18
|
+
*
|
|
19
|
+
* Commands:
|
|
20
|
+
* /profiles - list all available profiles
|
|
21
|
+
* /profile [name] - load a profile (prompts to choose if name omitted)
|
|
22
|
+
* /profile-from-project - capture ./pi as a new profile in the profiles dir
|
|
23
|
+
*
|
|
24
|
+
* Config:
|
|
25
|
+
* ~/.pi/agent/extensions/profiles.json
|
|
26
|
+
*
|
|
27
|
+
* Example:
|
|
28
|
+
* ```json
|
|
29
|
+
* {
|
|
30
|
+
* "profilesDir": "/custom/path/to/profiles"
|
|
31
|
+
* }
|
|
32
|
+
* ```
|
|
33
|
+
*
|
|
34
|
+
* If `profilesDir` is omitted, defaults to `~/.pi/profiles`.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { homedir } from "node:os";
|
|
38
|
+
import {
|
|
39
|
+
cpSync,
|
|
40
|
+
existsSync,
|
|
41
|
+
lstatSync,
|
|
42
|
+
mkdirSync,
|
|
43
|
+
readFileSync,
|
|
44
|
+
readdirSync,
|
|
45
|
+
readlinkSync,
|
|
46
|
+
rmSync,
|
|
47
|
+
symlinkSync,
|
|
48
|
+
unlinkSync,
|
|
49
|
+
writeFileSync,
|
|
50
|
+
} from "node:fs";
|
|
51
|
+
import { basename, join } from "node:path";
|
|
52
|
+
import {
|
|
53
|
+
getAgentDir,
|
|
54
|
+
parseFrontmatter,
|
|
55
|
+
type ExtensionAPI,
|
|
56
|
+
type ExtensionCommandContext,
|
|
57
|
+
} from "@earendil-works/pi-coding-agent";
|
|
58
|
+
|
|
59
|
+
const PROFILE_FILE = "PROFILE.md";
|
|
60
|
+
const CONFIG_DIR_NAME = ".pi";
|
|
61
|
+
const YES = "Yes";
|
|
62
|
+
const NO = "No";
|
|
63
|
+
|
|
64
|
+
interface ProfilesConfig {
|
|
65
|
+
/** Override the default profiles directory (~/.pi/profiles). */
|
|
66
|
+
profilesDir?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface Profile {
|
|
70
|
+
/** Directory name; used as the lookup key. */
|
|
71
|
+
id: string;
|
|
72
|
+
/** Display name from frontmatter `name`, or `id` as fallback. */
|
|
73
|
+
name: string;
|
|
74
|
+
/** Description from frontmatter `description`, possibly empty. */
|
|
75
|
+
description: string;
|
|
76
|
+
/** Absolute path to the profile directory. */
|
|
77
|
+
dir: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function lstatSafe(path: string) {
|
|
81
|
+
return lstatSync(path, { throwIfNoEntry: false });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function loadConfig(): ProfilesConfig {
|
|
85
|
+
const configPath = join(getAgentDir(), "extensions", "profiles.json");
|
|
86
|
+
if (!existsSync(configPath)) return {};
|
|
87
|
+
try {
|
|
88
|
+
return JSON.parse(readFileSync(configPath, "utf-8")) as ProfilesConfig;
|
|
89
|
+
} catch (err) {
|
|
90
|
+
console.error(`profiles extension: failed to parse ${configPath}:`, err);
|
|
91
|
+
return {};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function listProfiles(profilesDir: string): Profile[] {
|
|
96
|
+
if (!lstatSafe(profilesDir)) return [];
|
|
97
|
+
|
|
98
|
+
const profiles: Profile[] = [];
|
|
99
|
+
for (const entry of readdirSync(profilesDir, { withFileTypes: true })) {
|
|
100
|
+
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
|
|
101
|
+
|
|
102
|
+
const dir = join(profilesDir, entry.name);
|
|
103
|
+
const file = join(dir, PROFILE_FILE);
|
|
104
|
+
if (!lstatSafe(file)) continue;
|
|
105
|
+
|
|
106
|
+
let name = entry.name;
|
|
107
|
+
let description = "";
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const content = readFileSync(file, "utf8");
|
|
111
|
+
const { frontmatter } = parseFrontmatter<{ name?: string; description?: string }>(content);
|
|
112
|
+
if (typeof frontmatter.name === "string" && frontmatter.name.trim()) {
|
|
113
|
+
name = frontmatter.name.trim();
|
|
114
|
+
}
|
|
115
|
+
if (typeof frontmatter.description === "string") {
|
|
116
|
+
description = frontmatter.description.trim();
|
|
117
|
+
}
|
|
118
|
+
} catch {
|
|
119
|
+
// Invalid/missing frontmatter is not fatal; fall back to defaults.
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
profiles.push({ id: entry.name, name, description, dir });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
profiles.sort((a, b) => a.name.localeCompare(b.name));
|
|
126
|
+
return profiles;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function findProfile(query: string, profilesDir: string): Profile | undefined {
|
|
130
|
+
const q = query.trim();
|
|
131
|
+
const profiles = listProfiles(profilesDir);
|
|
132
|
+
return (
|
|
133
|
+
profiles.find((p) => p.id === q) ??
|
|
134
|
+
profiles.find((p) => p.name === q) ??
|
|
135
|
+
profiles.find((p) => p.name.toLowerCase() === q.toLowerCase())
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function formatLine(p: Profile): string {
|
|
140
|
+
return p.description ? `${p.name} — ${p.description}` : p.name;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function slugify(input: string): string {
|
|
144
|
+
return input
|
|
145
|
+
.toLowerCase()
|
|
146
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
147
|
+
.replace(/^-+|-+$/g, "");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function doCreateFromProject(
|
|
151
|
+
profilesDir: string,
|
|
152
|
+
ctx: ExtensionCommandContext,
|
|
153
|
+
): Promise<void> {
|
|
154
|
+
const sourceDir = join(ctx.cwd, CONFIG_DIR_NAME);
|
|
155
|
+
const sourceStat = lstatSafe(sourceDir);
|
|
156
|
+
|
|
157
|
+
if (!sourceStat) {
|
|
158
|
+
ctx.ui.notify(
|
|
159
|
+
`No ./${CONFIG_DIR_NAME} directory in this project. Nothing to capture.`,
|
|
160
|
+
"error",
|
|
161
|
+
);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (sourceStat.isSymbolicLink()) {
|
|
165
|
+
ctx.ui.notify(
|
|
166
|
+
`./${CONFIG_DIR_NAME} is a symlink (likely pointing to an existing profile).\n` +
|
|
167
|
+
`Remove the symlink first to capture a standalone profile.`,
|
|
168
|
+
"error",
|
|
169
|
+
);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (!sourceStat.isDirectory()) {
|
|
173
|
+
ctx.ui.notify(`./${CONFIG_DIR_NAME} is not a directory.`, "error");
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const sourceProfileFile = join(sourceDir, PROFILE_FILE);
|
|
178
|
+
const cwdName = basename(ctx.cwd);
|
|
179
|
+
let name: string;
|
|
180
|
+
let description: string;
|
|
181
|
+
let needWriteProfile = false;
|
|
182
|
+
|
|
183
|
+
if (lstatSafe(sourceProfileFile)) {
|
|
184
|
+
// Reuse the existing PROFILE.md from the project.
|
|
185
|
+
try {
|
|
186
|
+
const content = readFileSync(sourceProfileFile, "utf8");
|
|
187
|
+
const { frontmatter } = parseFrontmatter<{ name?: string; description?: string }>(content);
|
|
188
|
+
name =
|
|
189
|
+
typeof frontmatter.name === "string" && frontmatter.name.trim()
|
|
190
|
+
? frontmatter.name.trim()
|
|
191
|
+
: cwdName;
|
|
192
|
+
description =
|
|
193
|
+
typeof frontmatter.description === "string" ? frontmatter.description.trim() : "";
|
|
194
|
+
} catch {
|
|
195
|
+
name = cwdName;
|
|
196
|
+
description = "";
|
|
197
|
+
}
|
|
198
|
+
} else {
|
|
199
|
+
// No PROFILE.md in the project; prompt the user.
|
|
200
|
+
const nameInput = await ctx.ui.input(
|
|
201
|
+
`Profile name (default: ${slugify(cwdName) || "<name>"})`,
|
|
202
|
+
cwdName,
|
|
203
|
+
);
|
|
204
|
+
if (nameInput === undefined) {
|
|
205
|
+
ctx.ui.notify("Cancelled.", "info");
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
// Empty name falls back to the default (cwd basename).
|
|
209
|
+
const nameTrimmed = nameInput.trim();
|
|
210
|
+
name = nameTrimmed || cwdName;
|
|
211
|
+
|
|
212
|
+
// Description is required; re-prompt on empty until the user provides
|
|
213
|
+
// one or cancels the whole command.
|
|
214
|
+
while (true) {
|
|
215
|
+
const descInput = await ctx.ui.input(
|
|
216
|
+
"Profile description (what is this profile for and when to use it)",
|
|
217
|
+
"What this profile is for.",
|
|
218
|
+
);
|
|
219
|
+
if (descInput === undefined) {
|
|
220
|
+
ctx.ui.notify("Cancelled.", "info");
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const descTrimmed = descInput.trim();
|
|
224
|
+
if (descTrimmed) {
|
|
225
|
+
description = descTrimmed;
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
ctx.ui.notify("Description cannot be empty. Please describe the profile.", "warning");
|
|
229
|
+
}
|
|
230
|
+
needWriteProfile = true;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const id = slugify(name);
|
|
234
|
+
if (!id) {
|
|
235
|
+
ctx.ui.notify(
|
|
236
|
+
`Profile name "${name}" doesn't yield a valid directory name. Use letters, numbers, or hyphens.`,
|
|
237
|
+
"error",
|
|
238
|
+
);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const targetDir = join(profilesDir, id);
|
|
242
|
+
|
|
243
|
+
if (lstatSafe(targetDir)) {
|
|
244
|
+
ctx.ui.notify(
|
|
245
|
+
`Profile "${id}" already exists at ${targetDir}.`,
|
|
246
|
+
"warning",
|
|
247
|
+
);
|
|
248
|
+
const choice = await ctx.ui.select(
|
|
249
|
+
`Overwrite the existing profile "${id}" with the contents of ./${CONFIG_DIR_NAME}?`,
|
|
250
|
+
[YES, NO],
|
|
251
|
+
);
|
|
252
|
+
if (choice !== YES) {
|
|
253
|
+
ctx.ui.notify("Cancelled.", "info");
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
rmSync(targetDir, { recursive: true, force: true });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
mkdirSync(profilesDir, { recursive: true });
|
|
260
|
+
cpSync(sourceDir, targetDir, { recursive: true });
|
|
261
|
+
|
|
262
|
+
if (needWriteProfile) {
|
|
263
|
+
const profileContent =
|
|
264
|
+
`---\n` +
|
|
265
|
+
`name: ${name}\n` +
|
|
266
|
+
`description: ${description}\n` +
|
|
267
|
+
`---\n\n` +
|
|
268
|
+
`# ${name}\n\n` +
|
|
269
|
+
`Add profile documentation here.\n`;
|
|
270
|
+
writeFileSync(join(targetDir, PROFILE_FILE), profileContent);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
ctx.ui.notify(
|
|
274
|
+
`Created profile "${id}" at ${targetDir}.\n\n` +
|
|
275
|
+
`Use \`/profile ${id}\` to load it.`,
|
|
276
|
+
"info",
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function doLoad(profile: Profile, profilesDir: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
281
|
+
const linkPath = join(ctx.cwd, CONFIG_DIR_NAME);
|
|
282
|
+
const existing = lstatSafe(linkPath);
|
|
283
|
+
|
|
284
|
+
if (existing) {
|
|
285
|
+
if (existing.isSymbolicLink()) {
|
|
286
|
+
const target = readlinkSync(linkPath);
|
|
287
|
+
if (target === profile.dir) {
|
|
288
|
+
ctx.ui.notify(`Profile "${profile.name}" is already loaded.`, "info");
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const choice = await ctx.ui.select(
|
|
292
|
+
`./${CONFIG_DIR_NAME} currently points to:\n ${target}\n\nReplace it with profile "${profile.name}"?`,
|
|
293
|
+
[YES, NO],
|
|
294
|
+
);
|
|
295
|
+
if (choice !== YES) {
|
|
296
|
+
ctx.ui.notify("Cancelled.", "info");
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
unlinkSync(linkPath);
|
|
300
|
+
} else {
|
|
301
|
+
// Real file or directory - never touch without explicit consent.
|
|
302
|
+
const choice = await ctx.ui.select(
|
|
303
|
+
`./${CONFIG_DIR_NAME} already exists in this directory and is not a symlink.\n\n` +
|
|
304
|
+
`Replace it with a symlink to profile "${profile.name}"?\n` +
|
|
305
|
+
`(The existing ./${CONFIG_DIR_NAME} will be removed.)`,
|
|
306
|
+
[YES, NO],
|
|
307
|
+
);
|
|
308
|
+
if (choice !== YES) {
|
|
309
|
+
ctx.ui.notify(
|
|
310
|
+
`Cancelled. Move or rename ./${CONFIG_DIR_NAME} first if you want to switch profiles.`,
|
|
311
|
+
"info",
|
|
312
|
+
);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
rmSync(linkPath, { recursive: true, force: true });
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
symlinkSync(profile.dir, linkPath, "dir");
|
|
321
|
+
} catch (err) {
|
|
322
|
+
ctx.ui.notify(`Failed to create symlink: ${(err as Error).message}`, "error");
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
ctx.ui.notify(
|
|
327
|
+
`Loaded profile "${profile.name}".\n ./${CONFIG_DIR_NAME} -> ${profile.dir}\nReloading pi...`,
|
|
328
|
+
"info",
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
// Reload so the profile's extensions/skills/etc. take effect immediately.
|
|
332
|
+
// The reload re-discovers resources; the symlink makes the profile's
|
|
333
|
+
// contents appear at ./pi, so they get picked up on this reload.
|
|
334
|
+
await ctx.reload();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export default function profilesExtension(pi: ExtensionAPI) {
|
|
338
|
+
const config = loadConfig();
|
|
339
|
+
const profilesDir = config.profilesDir || join(homedir(), ".pi", "profiles");
|
|
340
|
+
|
|
341
|
+
pi.registerCommand("profiles", {
|
|
342
|
+
description: "List available profiles from ~/.pi/profiles",
|
|
343
|
+
handler: async (_args, ctx) => {
|
|
344
|
+
const profiles = listProfiles(profilesDir);
|
|
345
|
+
|
|
346
|
+
if (profiles.length === 0) {
|
|
347
|
+
ctx.ui.notify(
|
|
348
|
+
`No profiles found in ${profilesDir}.\n` +
|
|
349
|
+
`Create a subdirectory containing a ${PROFILE_FILE} file ` +
|
|
350
|
+
`(YAML frontmatter: name, description).`,
|
|
351
|
+
"warning",
|
|
352
|
+
);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const lines = profiles.map(formatLine);
|
|
357
|
+
const choice = await ctx.ui.select(
|
|
358
|
+
`Profiles (${profiles.length}) — select to load`,
|
|
359
|
+
lines,
|
|
360
|
+
);
|
|
361
|
+
if (!choice) return;
|
|
362
|
+
|
|
363
|
+
const idx = lines.indexOf(choice);
|
|
364
|
+
const profile = profiles[idx];
|
|
365
|
+
if (profile) await doLoad(profile, profilesDir, ctx);
|
|
366
|
+
},
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
pi.registerCommand("profile", {
|
|
370
|
+
description: "Load a profile: /profile <name> — symlinks ./.pi to ~/.pi/profiles/<name>",
|
|
371
|
+
getArgumentCompletions: (prefix) => {
|
|
372
|
+
const matches = listProfiles(profilesDir).filter(
|
|
373
|
+
(p) => p.id.startsWith(prefix) || p.name.toLowerCase().startsWith(prefix.toLowerCase()),
|
|
374
|
+
);
|
|
375
|
+
return matches.length > 0 ? matches.map((p) => ({ value: p.id, label: p.name })) : null;
|
|
376
|
+
},
|
|
377
|
+
handler: async (args, ctx) => {
|
|
378
|
+
const query = args.trim();
|
|
379
|
+
|
|
380
|
+
if (!query) {
|
|
381
|
+
const profiles = listProfiles(profilesDir);
|
|
382
|
+
if (profiles.length === 0) {
|
|
383
|
+
ctx.ui.notify(`No profiles found in ${profilesDir}.`, "warning");
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
const lines = profiles.map(formatLine);
|
|
387
|
+
const choice = await ctx.ui.select("Load which profile?", lines);
|
|
388
|
+
if (!choice) return;
|
|
389
|
+
const idx = lines.indexOf(choice);
|
|
390
|
+
const profile = profiles[idx];
|
|
391
|
+
if (profile) await doLoad(profile, profilesDir, ctx);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const profile = findProfile(query, profilesDir);
|
|
396
|
+
if (!profile) {
|
|
397
|
+
ctx.ui.notify(`Profile not found: ${query}`, "error");
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
await doLoad(profile, profilesDir, ctx);
|
|
402
|
+
},
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
pi.registerCommand("profile-from-project", {
|
|
406
|
+
description:
|
|
407
|
+
"Capture ./pi as a new profile: /profile-from-project — copies the project's .pi to the profiles directory",
|
|
408
|
+
handler: async (_args, ctx) => {
|
|
409
|
+
await doCreateFromProject(profilesDir, ctx);
|
|
410
|
+
},
|
|
411
|
+
});
|
|
412
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@timurci/pi-profiles",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Reusable pi configuration profiles for projects",
|
|
5
|
+
"keywords": ["pi-package"],
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": {
|
|
8
|
+
"name": "Timur Çakmakoğlu",
|
|
9
|
+
"email": "timurcakmakoglu@gmail.com"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "https://github.com/timurci/pi-profiles"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/timurci/pi-profiles",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/timurci/pi-profiles/issues"
|
|
18
|
+
},
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
21
|
+
},
|
|
22
|
+
"pi": {
|
|
23
|
+
"extensions": ["./extensions"]
|
|
24
|
+
}
|
|
25
|
+
}
|