pi-model-profiles 0.3.2 → 0.3.4

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/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.4] - 2026-06-16
11
+
12
+ ### Fixed
13
+ - Resolved symlinks before writing agent markdown to avoid overwriting the symlink target's parent instead of the linked file.
14
+ - Used `parseCompleteNumericScalar` for frontmatter number parsing to handle trailing non-numeric characters correctly.
15
+
16
+ ## [0.3.3] - 2026-06-01
17
+
18
+ ### Changed
19
+ - Lazy-loaded the model profiles command handler by extracting it into `src/command-handler.ts`, reducing startup work.
20
+ - Widened Pi peer dependency compatibility to include Pi 0.77.x and 0.78.x.
21
+
10
22
  ## [0.3.2] - 2026-05-26
11
23
 
12
24
  ### Changed
package/package.json CHANGED
@@ -1,66 +1,66 @@
1
- {
2
- "name": "pi-model-profiles",
3
- "version": "0.3.2",
4
- "private": false,
5
- "description": "Pi extension for saving, importing, and applying agent model frontmatter profiles.",
6
- "type": "module",
7
- "main": "./index.ts",
8
- "exports": {
9
- ".": "./index.ts"
10
- },
11
- "files": [
12
- "index.ts",
13
- "src",
14
- "README.md",
15
- "CHANGELOG.md",
16
- "config/config.example.json",
17
- "LICENSE"
18
- ],
19
- "scripts": {
20
- "build": "npx --yes -p typescript@5.9.2 tsc -p tsconfig.json",
21
- "lint": "npm run build",
22
- "test:clean": "node -e \"require('node:fs').rmSync('.test-dist', { recursive: true, force: true })\"",
23
- "prepublishOnly": "npm run test",
24
- "pretest": "npm run test:clean",
25
- "test": "npx --yes -p typescript@5.9.2 tsc --strict --skipLibCheck --module nodenext --moduleResolution nodenext --target ES2022 --outDir .test-dist src/types-shims.d.ts src/errors.ts src/types.ts src/constants.ts src/atomic-write.ts src/profile-fields.ts src/frontmatter-parser.ts src/profile-store.ts src/agent-writer.ts src/import-service.ts src/config.ts src/debug-logger.ts test/frontmatter-parser.test.ts test/import-service.test.ts test/agent-writer.test.ts test/profile-store.test.ts test/debug-logger.test.ts && node --test .test-dist/test/frontmatter-parser.test.js .test-dist/test/import-service.test.js .test-dist/test/agent-writer.test.js .test-dist/test/profile-store.test.js .test-dist/test/debug-logger.test.js",
26
- "posttest": "npm run test:clean",
27
- "check": "npm run build && npm run test",
28
- "package:dry-run": "npm pack --dry-run"
29
- },
30
- "keywords": [
31
- "pi-package",
32
- "pi",
33
- "pi-extension",
34
- "pi-coding-agent",
35
- "coding-agent",
36
- "model-profiles",
37
- "frontmatter",
38
- "agent-configuration",
39
- "profiles"
40
- ],
41
- "author": "MasuRii",
42
- "license": "MIT",
43
- "homepage": "https://github.com/MasuRii/pi-model-profiles#readme",
44
- "repository": {
45
- "type": "git",
46
- "url": "git+https://github.com/MasuRii/pi-model-profiles.git"
47
- },
48
- "bugs": {
49
- "url": "https://github.com/MasuRii/pi-model-profiles/issues"
50
- },
51
- "engines": {
52
- "node": ">=20"
53
- },
54
- "pi": {
55
- "extensions": [
56
- "./index.ts"
57
- ]
58
- },
59
- "peerDependencies": {
60
- "@earendil-works/pi-coding-agent": "^0.74.0 || ^0.75.0",
61
- "@earendil-works/pi-tui": "^0.74.0 || ^0.75.0"
62
- },
63
- "publishConfig": {
64
- "access": "public"
65
- }
66
- }
1
+ {
2
+ "name": "pi-model-profiles",
3
+ "version": "0.3.4",
4
+ "private": false,
5
+ "description": "Pi extension for saving, importing, and applying agent model frontmatter profiles.",
6
+ "type": "module",
7
+ "main": "./index.ts",
8
+ "exports": {
9
+ ".": "./index.ts"
10
+ },
11
+ "files": [
12
+ "index.ts",
13
+ "src",
14
+ "README.md",
15
+ "CHANGELOG.md",
16
+ "config/config.example.json",
17
+ "LICENSE"
18
+ ],
19
+ "scripts": {
20
+ "build": "npx --yes -p typescript@5.9.2 tsc -p tsconfig.json",
21
+ "lint": "npm run build",
22
+ "test:clean": "node -e \"require('node:fs').rmSync('.test-dist', { recursive: true, force: true })\"",
23
+ "prepublishOnly": "npm run test",
24
+ "pretest": "npm run test:clean",
25
+ "test": "npx --yes -p typescript@5.9.2 tsc --strict --skipLibCheck --module nodenext --moduleResolution nodenext --target ES2022 --outDir .test-dist src/types-shims.d.ts src/errors.ts src/types.ts src/constants.ts src/atomic-write.ts src/profile-fields.ts src/frontmatter-parser.ts src/profile-store.ts src/agent-writer.ts src/import-service.ts src/config.ts src/debug-logger.ts test/frontmatter-parser.test.ts test/import-service.test.ts test/agent-writer.test.ts test/profile-store.test.ts test/debug-logger.test.ts && node --test .test-dist/test/frontmatter-parser.test.js .test-dist/test/import-service.test.js .test-dist/test/agent-writer.test.js .test-dist/test/profile-store.test.js .test-dist/test/debug-logger.test.js",
26
+ "posttest": "npm run test:clean",
27
+ "check": "npm run build && npm run test",
28
+ "package:dry-run": "npm pack --dry-run"
29
+ },
30
+ "keywords": [
31
+ "pi-package",
32
+ "pi",
33
+ "pi-extension",
34
+ "pi-coding-agent",
35
+ "coding-agent",
36
+ "model-profiles",
37
+ "frontmatter",
38
+ "agent-configuration",
39
+ "profiles"
40
+ ],
41
+ "author": "MasuRii",
42
+ "license": "MIT",
43
+ "homepage": "https://github.com/MasuRii/pi-model-profiles#readme",
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "git+https://github.com/MasuRii/pi-model-profiles.git"
47
+ },
48
+ "bugs": {
49
+ "url": "https://github.com/MasuRii/pi-model-profiles/issues"
50
+ },
51
+ "engines": {
52
+ "node": ">=20"
53
+ },
54
+ "pi": {
55
+ "extensions": [
56
+ "./index.ts"
57
+ ]
58
+ },
59
+ "peerDependencies": {
60
+ "@earendil-works/pi-coding-agent": "^0.74.0 || ^0.75.0 || ^0.77.0 || ^0.78.0 || ^0.79.0",
61
+ "@earendil-works/pi-tui": "^0.74.0 || ^0.75.0 || ^0.77.0 || ^0.78.0 || ^0.79.0"
62
+ },
63
+ "publishConfig": {
64
+ "access": "public"
65
+ }
66
+ }
@@ -1,4 +1,4 @@
1
- import { existsSync, readdirSync, readFileSync } from "node:fs";
1
+ import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs";
2
2
  import { basename, dirname, join, resolve } from "node:path";
3
3
 
4
4
  import { AGENTS_DIR } from "./constants.js";
@@ -33,6 +33,12 @@ interface SessionManagerLike {
33
33
  getEntries(): readonly unknown[];
34
34
  }
35
35
 
36
+ interface AgentDirectoryEntry {
37
+ name: string;
38
+ isFile(): boolean;
39
+ isSymbolicLink(): boolean;
40
+ }
41
+
36
42
  type AgentScope = "user" | "project" | "both";
37
43
 
38
44
  export interface AgentSelectionOptions {
@@ -130,6 +136,22 @@ function isDirectory(path: string): boolean {
130
136
  }
131
137
  }
132
138
 
139
+ function isMarkdownAgentEntry(entry: AgentDirectoryEntry): boolean {
140
+ return entry.name.endsWith(".md") && (entry.isFile() || entry.isSymbolicLink());
141
+ }
142
+
143
+ function resolveAgentWritePath(filePath: string): string {
144
+ try {
145
+ return lstatSync(filePath).isSymbolicLink() ? realpathSync(filePath) : filePath;
146
+ } catch {
147
+ return filePath;
148
+ }
149
+ }
150
+
151
+ function writeAgentMarkdown(filePath: string, markdown: string): void {
152
+ writeFileAtomic(resolveAgentWritePath(filePath), markdown);
153
+ }
154
+
133
155
  function findNearestProjectAgentDirs(cwd: string): string[] {
134
156
  let currentDir = resolve(cwd);
135
157
 
@@ -187,15 +209,15 @@ export function scanAgentFiles(options: string | AgentSelectionOptions = AGENTS_
187
209
  const agentsByName = new Map<string, AgentFileRecord>();
188
210
 
189
211
  for (const agentsDir of sourceDirs) {
190
- let entries: Array<{ name: string; isFile(): boolean }>;
212
+ let entries: AgentDirectoryEntry[];
191
213
  try {
192
- entries = readdirSync(agentsDir, { withFileTypes: true }) as Array<{ name: string; isFile(): boolean }>;
214
+ entries = readdirSync(agentsDir, { withFileTypes: true }) as AgentDirectoryEntry[];
193
215
  } catch {
194
216
  throw new ModelProfilesError(`Unable to read agents directory '${agentsDir}'.`, "AGENTS_DIR_UNAVAILABLE");
195
217
  }
196
218
 
197
219
  for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
198
- if (!entry.isFile() || !entry.name.endsWith(".md")) {
220
+ if (!isMarkdownAgentEntry(entry)) {
199
221
  continue;
200
222
  }
201
223
 
@@ -247,7 +269,7 @@ export function applyProfileToAgentRecord(agent: AgentFileRecord, fields: Profil
247
269
  const normalizedFields = normalizeProfileFields(fields);
248
270
  const markdown = readFileSync(agent.path, "utf-8");
249
271
  const updatedMarkdown = updateMarkdownProfileFields(markdown, normalizedFields);
250
- writeFileAtomic(agent.path, updatedMarkdown);
272
+ writeAgentMarkdown(agent.path, updatedMarkdown);
251
273
  return {
252
274
  updatedPath: agent.path,
253
275
  fileName: agent.fileName,
@@ -314,7 +336,7 @@ export function applySavedProfile(profile: SavedProfile, options: string | Agent
314
336
  }
315
337
 
316
338
  for (const pending of pendingWrites) {
317
- writeFileAtomic(pending.target.path, pending.updatedMarkdown);
339
+ writeAgentMarkdown(pending.target.path, pending.updatedMarkdown);
318
340
  }
319
341
 
320
342
  return {
@@ -0,0 +1,137 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ import {
4
+ INITIAL_PROFILE_NAME,
5
+ PROFILE_NAME_SUFFIX,
6
+ PROFILE_STORE_PATH,
7
+ } from "./constants.js";
8
+ import { applySavedProfile, captureAgentSnapshots, detectActiveAgentName } from "./agent-writer.js";
9
+ import { toErrorMessage } from "./errors.js";
10
+ import { loadAndPrepareProfiles } from "./import-service.js";
11
+ import { updateProfileAndReturn } from "./profile-update-service.js";
12
+ import {
13
+ appendProfile,
14
+ createProfile,
15
+ findProfileById,
16
+ renameStoredProfile,
17
+ resolveUniqueProfileName,
18
+ saveProfilesFile,
19
+ } from "./profile-store.js";
20
+ import { removeProfileAndUpdate } from "./profile-removal-service.js";
21
+ import { openProfilesModal, type ProfileModalResult } from "./profile-modal.js";
22
+ import type { AppliedProfileOutcome, ProfilesFile } from "./types.js";
23
+
24
+ function buildCurrentProfileName(activeAgentName: string | null, data: ProfilesFile): string {
25
+ const baseName = activeAgentName ? `${activeAgentName} ${PROFILE_NAME_SUFFIX}` : INITIAL_PROFILE_NAME;
26
+ return resolveUniqueProfileName(baseName, data.profiles);
27
+ }
28
+
29
+ function notifyWarnings(ctx: ExtensionCommandContext, warnings: readonly string[]): void {
30
+ if (warnings.length === 0) {
31
+ return;
32
+ }
33
+ ctx.ui.notify(warnings.join(" "), "warning");
34
+ }
35
+
36
+ function summarizeApplyOutcome(outcome: AppliedProfileOutcome): string {
37
+ const appliedCount = outcome.appliedAgents.length;
38
+ const missingCount = outcome.missingAgents.length;
39
+ const appliedLabel = `${appliedCount} agent file${appliedCount === 1 ? "" : "s"}`;
40
+ if (missingCount === 0) {
41
+ return appliedLabel;
42
+ }
43
+ return `${appliedLabel}; ${missingCount} missing`;
44
+ }
45
+
46
+ async function reloadAfterApply(ctx: ExtensionCommandContext, outcome: AppliedProfileOutcome): Promise<void> {
47
+ const summary = summarizeApplyOutcome(outcome);
48
+ ctx.ui.notify(`Profile '${outcome.profileName}' applied across ${summary}. Reloading…`, "info");
49
+
50
+ try {
51
+ await ctx.reload();
52
+ } catch (error) {
53
+ ctx.ui.notify(
54
+ `Profile '${outcome.profileName}' applied across ${summary}, but automatic reload failed: ${toErrorMessage(error)}. Run /reload.`,
55
+ "error",
56
+ );
57
+ }
58
+ }
59
+
60
+ export async function handleModelProfilesCommand(ctx: ExtensionCommandContext): Promise<void> {
61
+ const agentOptions = { cwd: ctx.cwd, scope: "both" as const };
62
+ const prepared = loadAndPrepareProfiles(PROFILE_STORE_PATH, agentOptions);
63
+ notifyWarnings(ctx, prepared.warnings);
64
+
65
+ let data = prepared.data;
66
+ const activeAgentName = detectActiveAgentName(ctx.sessionManager, ctx.getSystemPrompt());
67
+
68
+ const result: ProfileModalResult = await openProfilesModal(ctx, data, activeAgentName, {
69
+ renameProfile: async (profileId, nextName) => {
70
+ data = renameStoredProfile(data, profileId, nextName);
71
+ saveProfilesFile(data, PROFILE_STORE_PATH);
72
+ return {
73
+ data,
74
+ message: `Renamed saved snapshot to '${nextName.trim()}'.`,
75
+ selectedProfileId: profileId,
76
+ };
77
+ },
78
+ addCurrentProfile: async () => {
79
+ const snapshot = captureAgentSnapshots(agentOptions);
80
+ notifyWarnings(ctx, snapshot.warnings);
81
+ const profile = createProfile(buildCurrentProfileName(activeAgentName, data), snapshot.agents);
82
+ data = appendProfile(data, profile);
83
+ saveProfilesFile(data, PROFILE_STORE_PATH);
84
+ return {
85
+ data,
86
+ message: `Saved current agents snapshot (${snapshot.agents.length} agents).`,
87
+ selectedProfileId: profile.id,
88
+ };
89
+ },
90
+ applyProfile: async (profileId) => {
91
+ const profile = findProfileById(data, profileId);
92
+ if (!profile) {
93
+ throw new Error(`Saved profile '${profileId}' was not found.`);
94
+ }
95
+ const applied = applySavedProfile(profile, agentOptions);
96
+ notifyWarnings(ctx, applied.warnings);
97
+ return {
98
+ ...applied,
99
+ profileName: profile.name,
100
+ };
101
+ },
102
+ removeProfile: async (profileId) => {
103
+ const profile = findProfileById(data, profileId);
104
+ if (!profile) {
105
+ throw new Error(`Saved profile '${profileId}' was not found.`);
106
+ }
107
+ const removal = removeProfileAndUpdate(data, profileId);
108
+ data = removal.data;
109
+ saveProfilesFile(data, PROFILE_STORE_PATH);
110
+ return {
111
+ data,
112
+ message: `Removed saved snapshot '${removal.result.removedProfileName}' (${removal.result.remainingCount} snapshots remaining).`,
113
+ selectedProfileId: data.profiles[0]?.id,
114
+ };
115
+ },
116
+ updateProfile: async (profileId) => {
117
+ const profile = findProfileById(data, profileId);
118
+ if (!profile) {
119
+ throw new Error(`Saved profile '${profileId}' was not found.`);
120
+ }
121
+ const update = updateProfileAndReturn(data, profileId, agentOptions);
122
+ notifyWarnings(ctx, update.result.warnings);
123
+ data = update.data;
124
+ saveProfilesFile(data, PROFILE_STORE_PATH);
125
+ return {
126
+ data,
127
+ message: `Updated '${profile.name}' with current agent state (${update.result.updatedAgents} agents).`,
128
+ selectedProfileId: profileId,
129
+ };
130
+ },
131
+ });
132
+
133
+ if (result.type === "applied") {
134
+ await reloadAfterApply(ctx, result.outcome);
135
+ return;
136
+ }
137
+ }
package/src/errors.ts CHANGED
@@ -1,16 +1,18 @@
1
- export class ModelProfilesError extends Error {
2
- readonly code: string;
3
-
4
- constructor(message: string, code = "MODEL_PROFILES_ERROR") {
5
- super(message);
6
- this.name = "ModelProfilesError";
7
- this.code = code;
8
- }
9
- }
10
-
11
- export function toErrorMessage(error: unknown): string {
12
- if (error instanceof Error && error.message.trim()) {
13
- return error.message;
14
- }
15
- return String(error);
16
- }
1
+ export const COMMAND_NAME = "model-profiles";
2
+
3
+ export class ModelProfilesError extends Error {
4
+ readonly code: string;
5
+
6
+ constructor(message: string, code = "MODEL_PROFILES_ERROR") {
7
+ super(message);
8
+ this.name = "ModelProfilesError";
9
+ this.code = code;
10
+ }
11
+ }
12
+
13
+ export function toErrorMessage(error: unknown): string {
14
+ if (error instanceof Error && error.message.trim()) {
15
+ return error.message;
16
+ }
17
+ return String(error);
18
+ }