pi-model-profiles 0.2.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/CHANGELOG.md +34 -0
- package/LICENSE +21 -0
- package/README.md +162 -0
- package/config/config.example.json +10 -0
- package/index.ts +3 -0
- package/package.json +66 -0
- package/src/agent-writer.ts +331 -0
- package/src/atomic-write.ts +28 -0
- package/src/config.ts +250 -0
- package/src/constants.ts +99 -0
- package/src/debug-logger.ts +351 -0
- package/src/errors.ts +16 -0
- package/src/frontmatter-parser.ts +249 -0
- package/src/import-service.ts +60 -0
- package/src/index.ts +158 -0
- package/src/modal-theme.ts +334 -0
- package/src/pi-api-utils.ts +56 -0
- package/src/profile-fields.ts +83 -0
- package/src/profile-modal.ts +1175 -0
- package/src/profile-removal-service.ts +106 -0
- package/src/profile-sort-service.ts +105 -0
- package/src/profile-store.ts +418 -0
- package/src/profile-update-service.ts +134 -0
- package/src/types-shims.d.ts +121 -0
- package/src/types.ts +104 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
No unreleased changes.
|
|
11
|
+
|
|
12
|
+
## [0.2.0] - 2026-04-26
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- Phase 6: Git & publishing preparation.
|
|
16
|
+
- NPM package metadata, README, CHANGELOG, LICENSE, and package ignore rules.
|
|
17
|
+
- Profile update, removal, persisted sorting, configuration, and file-gated debug logging.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- Confirmation prompts now accept typed input before update or removal actions run.
|
|
21
|
+
- Sort menu keyboard handling now works regardless of focused pane and closes without exiting the modal.
|
|
22
|
+
- Profile update and removal command handlers now avoid duplicate scans and duplicate removal events.
|
|
23
|
+
|
|
24
|
+
## [0.1.0] - 2026-04-25
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
- Initial extension structure
|
|
28
|
+
- Core profile management functionality
|
|
29
|
+
- Frontmatter parser implementation
|
|
30
|
+
- Profile store with atomic writes
|
|
31
|
+
- Import service for external profiles
|
|
32
|
+
- Agent writer for profile application
|
|
33
|
+
- Type definitions and error handling
|
|
34
|
+
- Test suite for core functionality
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MasuRii
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# pi-model-profiles
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/pi-model-profiles) [](LICENSE)
|
|
4
|
+
|
|
5
|
+
<img width="1389" height="768" alt="image" src="https://github.com/user-attachments/assets/7170fed2-018f-4719-a147-3bd7967456bc" />
|
|
6
|
+
|
|
7
|
+
`pi-model-profiles` is a Pi extension for saving, updating, deleting, sorting, and applying whole-agent model frontmatter snapshots.
|
|
8
|
+
|
|
9
|
+
- **Command:** `/model-profiles`
|
|
10
|
+
- **npm:** https://www.npmjs.com/package/pi-model-profiles
|
|
11
|
+
- **GitHub:** https://github.com/MasuRii/pi-model-profiles
|
|
12
|
+
|
|
13
|
+
## Features
|
|
14
|
+
|
|
15
|
+
- Save the current user/project agent model frontmatter as a reusable snapshot.
|
|
16
|
+
- Apply a saved snapshot across matching agent markdown files with atomic writes.
|
|
17
|
+
- Rename, update, remove, and sort saved snapshots from the interactive modal.
|
|
18
|
+
- Preserve profile data in `profiles.json` with schema-versioned migration support.
|
|
19
|
+
- Write optional debug logs only to the extension-local `debug/` directory when enabled.
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
### npm package
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pi install npm:pi-model-profiles
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Git repository
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pi install git:github.com/MasuRii/pi-model-profiles
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Local extension folder
|
|
36
|
+
|
|
37
|
+
Place this folder in one of Pi's extension discovery paths:
|
|
38
|
+
|
|
39
|
+
| Scope | Path |
|
|
40
|
+
|-------|------|
|
|
41
|
+
| Global default | `~/.pi/agent/extensions/pi-model-profiles` (respects `PI_CODING_AGENT_DIR`) |
|
|
42
|
+
| Project | `.pi/extensions/pi-model-profiles` |
|
|
43
|
+
|
|
44
|
+
Pi discovers the extension through the root `index.ts` entry listed in `package.json`.
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
Run the command in interactive TUI mode:
|
|
49
|
+
|
|
50
|
+
```text
|
|
51
|
+
/model-profiles
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Modal shortcuts:
|
|
55
|
+
|
|
56
|
+
| Shortcut | Action |
|
|
57
|
+
|----------|--------|
|
|
58
|
+
| `↑` / `↓` | Move through snapshots |
|
|
59
|
+
| `Tab` / `→` | Switch between snapshots and details panes |
|
|
60
|
+
| `Enter` | Apply selected snapshot |
|
|
61
|
+
| `s` | Save current agent state as a new snapshot |
|
|
62
|
+
| `r` | Rename selected snapshot |
|
|
63
|
+
| `Ctrl+U` | Update selected snapshot from current agent state |
|
|
64
|
+
| `Delete` / `Ctrl+D` | Remove selected snapshot after confirmation |
|
|
65
|
+
| `Ctrl+S` | Open sort menu |
|
|
66
|
+
| `Esc` | Close modal, cancel input, or close sort menu |
|
|
67
|
+
|
|
68
|
+
## Configuration
|
|
69
|
+
|
|
70
|
+
Runtime configuration lives in `config.json` at the extension root. The extension creates the file automatically with defaults on first load if it does not already exist.
|
|
71
|
+
|
|
72
|
+
A starter template is included at `config/config.example.json`. Copy it to `config.json` for local customization, or let the extension create `config.json` with defaults on first load.
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
{
|
|
76
|
+
"debug": false,
|
|
77
|
+
"profiles": {
|
|
78
|
+
"autoSave": true,
|
|
79
|
+
"maxProfiles": 100
|
|
80
|
+
},
|
|
81
|
+
"sorting": {
|
|
82
|
+
"defaultSort": "date-desc"
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
| Key | Type | Default | Purpose |
|
|
88
|
+
|-----|------|---------|---------|
|
|
89
|
+
| `debug` | `boolean` | `false` | Enables debug logging under `debug/` directory |
|
|
90
|
+
| `profiles.autoSave` | `boolean` | `true` | Reserved profile persistence setting |
|
|
91
|
+
| `profiles.maxProfiles` | `number` | `100` | Reserved maximum profile retention setting |
|
|
92
|
+
| `sorting.defaultSort` | `name-asc \| name-desc \| date-asc \| date-desc` | `date-desc` | Default snapshot sort order |
|
|
93
|
+
|
|
94
|
+
## Profile Storage
|
|
95
|
+
|
|
96
|
+
Profile data is persisted in `profiles.json` at the extension root and should not be edited while Pi is running.
|
|
97
|
+
|
|
98
|
+
```json
|
|
99
|
+
{
|
|
100
|
+
"version": 2,
|
|
101
|
+
"importedAt": "2026-04-26T00:00:00.000Z",
|
|
102
|
+
"profiles": [
|
|
103
|
+
{
|
|
104
|
+
"id": "profile-id",
|
|
105
|
+
"name": "Current agents snapshot",
|
|
106
|
+
"createdAt": "2026-04-26T00:00:00.000Z",
|
|
107
|
+
"updatedAt": "2026-04-26T00:00:00.000Z",
|
|
108
|
+
"agents": [
|
|
109
|
+
{
|
|
110
|
+
"fileName": "code.md",
|
|
111
|
+
"agentName": "code",
|
|
112
|
+
"fields": {
|
|
113
|
+
"model": "provider/model",
|
|
114
|
+
"temperature": 0.2,
|
|
115
|
+
"reasoningEffort": "medium"
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
]
|
|
119
|
+
}
|
|
120
|
+
]
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Debug Logging
|
|
125
|
+
|
|
126
|
+
Debug logging is controlled by the `debug` property in `config.json`.
|
|
127
|
+
|
|
128
|
+
- When `debug` is `false` or absent, no debug file handles are opened and no debug files are written.
|
|
129
|
+
- When `debug` is `true`, debug events are appended to `debug/pi-model-profiles-debug.jsonl`.
|
|
130
|
+
- Debug output is never written to console, stdout, or stderr.
|
|
131
|
+
|
|
132
|
+
## Development
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
npm install
|
|
136
|
+
npm run build
|
|
137
|
+
npm run lint
|
|
138
|
+
npm run test
|
|
139
|
+
npm run check
|
|
140
|
+
npm run package:dry-run
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Publishing
|
|
144
|
+
|
|
145
|
+
The package metadata follows the same publish-ready shape used by established Pi extensions:
|
|
146
|
+
|
|
147
|
+
- entrypoint: `index.ts`
|
|
148
|
+
- package exports: `.` → `./index.ts`
|
|
149
|
+
- Pi extension manifest: `pi.extensions`
|
|
150
|
+
- published files: source, README, changelog, license, and config template
|
|
151
|
+
- runtime `config.json`, `profiles.json`, and `debug/` logs excluded from npm publication
|
|
152
|
+
|
|
153
|
+
## Related Pi Extensions
|
|
154
|
+
|
|
155
|
+
- [pi-context-injector](https://github.com/MasuRii/pi-context-injector) — Inject compact project context into first-turn and compaction prompts
|
|
156
|
+
- [pi-agent-router](https://github.com/MasuRii/pi-agent-router) — Active-agent routing and controlled subagent delegation
|
|
157
|
+
- [pi-multi-auth](https://github.com/MasuRii/pi-multi-auth) — Multi-provider credential management, OAuth login, and account rotation
|
|
158
|
+
- [pi-tool-display](https://github.com/MasuRii/pi-tool-display) — Compact tool rendering and diff visualization
|
|
159
|
+
|
|
160
|
+
## License
|
|
161
|
+
|
|
162
|
+
[MIT](LICENSE)
|
package/index.ts
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-model-profiles",
|
|
3
|
+
"version": "0.2.0",
|
|
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 test/frontmatter-parser.test.ts test/import-service.test.ts test/agent-writer.test.ts test/profile-store.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",
|
|
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
|
+
"@mariozechner/pi-coding-agent": "^0.70.5",
|
|
61
|
+
"@mariozechner/pi-tui": "^0.70.5"
|
|
62
|
+
},
|
|
63
|
+
"publishConfig": {
|
|
64
|
+
"access": "public"
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { AGENTS_DIR } from "./constants.js";
|
|
5
|
+
import { writeFileAtomic } from "./atomic-write.js";
|
|
6
|
+
import { ModelProfilesError } from "./errors.js";
|
|
7
|
+
import {
|
|
8
|
+
listAppliedKeys,
|
|
9
|
+
listRemovedKeys,
|
|
10
|
+
readAgentNameFromMarkdown,
|
|
11
|
+
readProfileFieldsFromMarkdown,
|
|
12
|
+
updateMarkdownProfileFields,
|
|
13
|
+
} from "./frontmatter-parser.js";
|
|
14
|
+
import { normalizeProfileFields } from "./profile-fields.js";
|
|
15
|
+
import type {
|
|
16
|
+
AgentFileRecord,
|
|
17
|
+
AgentScanResult,
|
|
18
|
+
AgentSnapshotResult,
|
|
19
|
+
AppliedAgentUpdate,
|
|
20
|
+
ApplyProfileResult,
|
|
21
|
+
ProfileFields,
|
|
22
|
+
SavedProfile,
|
|
23
|
+
SavedProfileAgent,
|
|
24
|
+
} from "./types.js";
|
|
25
|
+
|
|
26
|
+
interface SessionEntryLike {
|
|
27
|
+
type?: unknown;
|
|
28
|
+
customType?: unknown;
|
|
29
|
+
data?: unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface SessionManagerLike {
|
|
33
|
+
getEntries(): readonly unknown[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type AgentScope = "user" | "project" | "both";
|
|
37
|
+
|
|
38
|
+
export interface AgentSelectionOptions {
|
|
39
|
+
cwd?: string;
|
|
40
|
+
agentsDir?: string;
|
|
41
|
+
scope?: AgentScope;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const PROJECT_AGENT_SOURCE_DIRS = [
|
|
45
|
+
[".omp", "agents"],
|
|
46
|
+
[".pi", "agents"],
|
|
47
|
+
[".claude", "agents"],
|
|
48
|
+
] as const;
|
|
49
|
+
|
|
50
|
+
function toRecord(value: unknown): Record<string, unknown> {
|
|
51
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
52
|
+
return {};
|
|
53
|
+
}
|
|
54
|
+
return value as Record<string, unknown>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function normalizeName(value: unknown): string | null {
|
|
58
|
+
if (typeof value !== "string") {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const trimmed = value.trim();
|
|
62
|
+
return trimmed ? trimmed : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function normalizeCompareValue(value: string): string {
|
|
66
|
+
return value.trim().toLowerCase();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseActiveAgentFromPrompt(systemPrompt: string): string | null {
|
|
70
|
+
const identityMatch = /<active_agent_identity\s+name=["']([^"']+)["']/i.exec(systemPrompt);
|
|
71
|
+
if (identityMatch?.[1]) {
|
|
72
|
+
return identityMatch[1].trim() || null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const summaryMatch = /The selected active agent identity is "([^"]+)"\./i.exec(systemPrompt);
|
|
76
|
+
if (summaryMatch?.[1]) {
|
|
77
|
+
return summaryMatch[1].trim() || null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function cloneSavedAgent(agent: SavedProfileAgent): SavedProfileAgent {
|
|
84
|
+
return {
|
|
85
|
+
fileName: agent.fileName,
|
|
86
|
+
agentName: agent.agentName,
|
|
87
|
+
fields: normalizeProfileFields(agent.fields),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function normalizeSavedAgents(agents: readonly SavedProfileAgent[]): SavedProfileAgent[] {
|
|
92
|
+
return [...agents]
|
|
93
|
+
.map((agent) => cloneSavedAgent(agent))
|
|
94
|
+
.sort((left, right) => left.fileName.localeCompare(right.fileName) || left.agentName.localeCompare(right.agentName));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function detectActiveAgentName(sessionManager: SessionManagerLike, systemPrompt = ""): string | null {
|
|
98
|
+
const entries = sessionManager.getEntries();
|
|
99
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
100
|
+
const entry = entries[index] as SessionEntryLike | undefined;
|
|
101
|
+
if (entry?.type !== "custom" || entry.customType !== "active_agent") {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const data = toRecord(entry.data);
|
|
106
|
+
if (data.name === null) {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const name = normalizeName(data.name);
|
|
111
|
+
if (name) {
|
|
112
|
+
return name;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return parseActiveAgentFromPrompt(systemPrompt);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function normalizeCompareKey(value: string): string {
|
|
122
|
+
return value.trim().toLowerCase();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isDirectory(path: string): boolean {
|
|
126
|
+
try {
|
|
127
|
+
return existsSync(path) && readdirSync(path, { withFileTypes: true }).length >= 0;
|
|
128
|
+
} catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function findNearestProjectAgentDirs(cwd: string): string[] {
|
|
134
|
+
let currentDir = resolve(cwd);
|
|
135
|
+
|
|
136
|
+
while (true) {
|
|
137
|
+
const candidates = PROJECT_AGENT_SOURCE_DIRS.map((segments) => join(currentDir, ...segments)).filter((candidate) =>
|
|
138
|
+
isDirectory(candidate),
|
|
139
|
+
);
|
|
140
|
+
if (candidates.length > 0) {
|
|
141
|
+
return candidates;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const parentDir = dirname(currentDir);
|
|
145
|
+
if (parentDir === currentDir) {
|
|
146
|
+
return [];
|
|
147
|
+
}
|
|
148
|
+
currentDir = parentDir;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function resolveAgentSourceDirs(options: string | AgentSelectionOptions = AGENTS_DIR): string[] {
|
|
153
|
+
if (typeof options === "string") {
|
|
154
|
+
return [options];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const scope = options.scope ?? "user";
|
|
158
|
+
const cwd = resolve(options.cwd ?? process.cwd());
|
|
159
|
+
const projectDirs = scope === "user" ? [] : findNearestProjectAgentDirs(cwd);
|
|
160
|
+
const userAgentsDir = options.agentsDir ?? AGENTS_DIR;
|
|
161
|
+
const userDirs = scope === "project" ? [] : [userAgentsDir].filter((candidate) => isDirectory(candidate));
|
|
162
|
+
return [...userDirs.slice().reverse(), ...projectDirs.slice().reverse()];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function readAgentFileRecord(filePath: string): AgentFileRecord {
|
|
166
|
+
if (!existsSync(filePath)) {
|
|
167
|
+
throw new ModelProfilesError(`Agent file '${filePath}' was not found.`, "AGENT_NOT_FOUND");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const markdown = readFileSync(filePath, "utf-8");
|
|
171
|
+
return {
|
|
172
|
+
path: filePath,
|
|
173
|
+
fileName: basename(filePath),
|
|
174
|
+
agentName: readAgentNameFromMarkdown(markdown),
|
|
175
|
+
fields: readProfileFieldsFromMarkdown(markdown),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function scanAgentFiles(options: string | AgentSelectionOptions = AGENTS_DIR): AgentScanResult {
|
|
180
|
+
const sourceDirs = resolveAgentSourceDirs(options);
|
|
181
|
+
if (sourceDirs.length === 0) {
|
|
182
|
+
const targetDescription = typeof options === "string" ? options : options.agentsDir ?? options.cwd ?? AGENTS_DIR;
|
|
183
|
+
throw new ModelProfilesError(`Unable to read agents directory '${targetDescription}'.`, "AGENTS_DIR_UNAVAILABLE");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const warnings: string[] = [];
|
|
187
|
+
const agentsByName = new Map<string, AgentFileRecord>();
|
|
188
|
+
|
|
189
|
+
for (const agentsDir of sourceDirs) {
|
|
190
|
+
let entries: Array<{ name: string; isFile(): boolean }>;
|
|
191
|
+
try {
|
|
192
|
+
entries = readdirSync(agentsDir, { withFileTypes: true }) as Array<{ name: string; isFile(): boolean }>;
|
|
193
|
+
} catch {
|
|
194
|
+
throw new ModelProfilesError(`Unable to read agents directory '${agentsDir}'.`, "AGENTS_DIR_UNAVAILABLE");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
198
|
+
if (!entry.isFile() || !entry.name.endsWith(".md")) {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const filePath = join(agentsDir, entry.name);
|
|
203
|
+
try {
|
|
204
|
+
const record = readAgentFileRecord(filePath);
|
|
205
|
+
agentsByName.set(normalizeCompareKey(record.agentName), record);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
208
|
+
warnings.push(`Skipped agent file '${filePath}': ${message}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
agents: [...agentsByName.values()].sort(
|
|
215
|
+
(left, right) => left.fileName.localeCompare(right.fileName) || left.agentName.localeCompare(right.agentName),
|
|
216
|
+
),
|
|
217
|
+
warnings,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function findAgentRecordByName(agentName: string, options: string | AgentSelectionOptions = AGENTS_DIR): AgentFileRecord | null {
|
|
222
|
+
const target = normalizeCompareValue(agentName);
|
|
223
|
+
const scan = scanAgentFiles(options);
|
|
224
|
+
return scan.agents.find((agent) => normalizeCompareValue(agent.agentName) === target) ?? null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function captureAgentSnapshots(options: string | AgentSelectionOptions = AGENTS_DIR): AgentSnapshotResult {
|
|
228
|
+
const scan = scanAgentFiles(options);
|
|
229
|
+
if (scan.agents.length === 0) {
|
|
230
|
+
const targetDescription = typeof options === "string" ? options : options.agentsDir ?? options.cwd ?? AGENTS_DIR;
|
|
231
|
+
throw new ModelProfilesError(`No readable agent markdown files were found in '${targetDescription}'.`, "NO_AGENT_FILES");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
agents: normalizeSavedAgents(
|
|
236
|
+
scan.agents.map((agent) => ({
|
|
237
|
+
fileName: agent.fileName,
|
|
238
|
+
agentName: agent.agentName,
|
|
239
|
+
fields: agent.fields,
|
|
240
|
+
})),
|
|
241
|
+
),
|
|
242
|
+
warnings: scan.warnings,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function applyProfileToAgentRecord(agent: AgentFileRecord, fields: ProfileFields): AppliedAgentUpdate {
|
|
247
|
+
const normalizedFields = normalizeProfileFields(fields);
|
|
248
|
+
const markdown = readFileSync(agent.path, "utf-8");
|
|
249
|
+
const updatedMarkdown = updateMarkdownProfileFields(markdown, normalizedFields);
|
|
250
|
+
writeFileAtomic(agent.path, updatedMarkdown);
|
|
251
|
+
return {
|
|
252
|
+
updatedPath: agent.path,
|
|
253
|
+
fileName: agent.fileName,
|
|
254
|
+
agentName: agent.agentName,
|
|
255
|
+
appliedKeys: listAppliedKeys(normalizedFields),
|
|
256
|
+
removedKeys: listRemovedKeys(normalizedFields),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function applySavedProfile(profile: SavedProfile, options: string | AgentSelectionOptions = AGENTS_DIR): ApplyProfileResult {
|
|
261
|
+
if (profile.agents.length === 0) {
|
|
262
|
+
throw new ModelProfilesError(`Saved profile '${profile.name}' does not contain any agent snapshots.`, "EMPTY_PROFILE");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const warnings: string[] = [];
|
|
266
|
+
const missingAgents: string[] = [];
|
|
267
|
+
const pendingWrites: Array<{ target: AgentFileRecord; updatedMarkdown: string; fields: ProfileFields }> = [];
|
|
268
|
+
const seenFiles = new Set<string>();
|
|
269
|
+
const scan = scanAgentFiles(options);
|
|
270
|
+
const targetByFileName = new Map(scan.agents.map((agent) => [normalizeCompareKey(agent.fileName), agent] as const));
|
|
271
|
+
const targetByAgentName = new Map(scan.agents.map((agent) => [normalizeCompareKey(agent.agentName), agent] as const));
|
|
272
|
+
const targetDescription = typeof options === "string" ? options : options.agentsDir ?? options.cwd ?? AGENTS_DIR;
|
|
273
|
+
|
|
274
|
+
for (const savedAgent of normalizeSavedAgents(profile.agents)) {
|
|
275
|
+
const sourceKey = savedAgent.fileName.toLowerCase();
|
|
276
|
+
if (seenFiles.has(sourceKey)) {
|
|
277
|
+
throw new ModelProfilesError(
|
|
278
|
+
`Saved profile '${profile.name}' contains duplicate agent entry '${savedAgent.fileName}'.`,
|
|
279
|
+
"DUPLICATE_PROFILE_AGENT",
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
seenFiles.add(sourceKey);
|
|
283
|
+
|
|
284
|
+
const target =
|
|
285
|
+
targetByFileName.get(normalizeCompareKey(savedAgent.fileName)) ??
|
|
286
|
+
targetByAgentName.get(normalizeCompareKey(savedAgent.agentName));
|
|
287
|
+
if (!target) {
|
|
288
|
+
missingAgents.push(savedAgent.fileName);
|
|
289
|
+
warnings.push(`Skipped missing agent file '${savedAgent.fileName}' while resolving '${targetDescription}'.`);
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const markdown = readFileSync(target.path, "utf-8");
|
|
294
|
+
const normalizedFields = normalizeProfileFields(savedAgent.fields);
|
|
295
|
+
const updatedMarkdown = updateMarkdownProfileFields(markdown, normalizedFields);
|
|
296
|
+
const currentAgentName = readAgentNameFromMarkdown(markdown);
|
|
297
|
+
pendingWrites.push({
|
|
298
|
+
target: {
|
|
299
|
+
path: target.path,
|
|
300
|
+
fileName: target.fileName,
|
|
301
|
+
agentName: currentAgentName,
|
|
302
|
+
fields: normalizedFields,
|
|
303
|
+
},
|
|
304
|
+
updatedMarkdown,
|
|
305
|
+
fields: normalizedFields,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (pendingWrites.length === 0) {
|
|
310
|
+
throw new ModelProfilesError(
|
|
311
|
+
`Saved profile '${profile.name}' does not match any existing agent markdown files in '${targetDescription}'.`,
|
|
312
|
+
"NO_MATCHING_AGENT_FILES",
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
for (const pending of pendingWrites) {
|
|
317
|
+
writeFileAtomic(pending.target.path, pending.updatedMarkdown);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
appliedAgents: pendingWrites.map((pending) => ({
|
|
322
|
+
updatedPath: pending.target.path,
|
|
323
|
+
fileName: pending.target.fileName,
|
|
324
|
+
agentName: pending.target.agentName,
|
|
325
|
+
appliedKeys: listAppliedKeys(pending.fields),
|
|
326
|
+
removedKeys: listRemovedKeys(pending.fields),
|
|
327
|
+
})),
|
|
328
|
+
missingAgents,
|
|
329
|
+
warnings,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { mkdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
function buildTempPath(targetPath: string): string {
|
|
5
|
+
const nonce = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
6
|
+
return join(dirname(targetPath), `.${basename(targetPath)}.${nonce}.tmp`);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function writeFileAtomic(path: string, content: string): void {
|
|
10
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
11
|
+
|
|
12
|
+
const tempPath = buildTempPath(path);
|
|
13
|
+
let renamed = false;
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
writeFileSync(tempPath, content, "utf-8");
|
|
17
|
+
renameSync(tempPath, path);
|
|
18
|
+
renamed = true;
|
|
19
|
+
} finally {
|
|
20
|
+
if (!renamed) {
|
|
21
|
+
try {
|
|
22
|
+
unlinkSync(tempPath);
|
|
23
|
+
} catch {
|
|
24
|
+
// Ignore temp cleanup failures after a write error.
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|