pi-profile-switch 0.1.0 → 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/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  Named profiles for [Pi](https://github.com/badlogic/pi-mono). A profile selects what the model sees and which capabilities the session uses — skills, MCP servers, tools, a model preset, and extra instructions — switchable in the same Pi process.
6
6
 
7
- Use a lean read-only profile for code review, a full-powered one for implementation, a minimal one for a quick question.
7
+ A shipped `read-only` preset covers the read-and-report workflow; other workflows you write yourself — a profile can narrow the tool set, hide skills, pin a model, or add instructions.
8
8
 
9
9
  `pi-profile-switch` is a plain Pi package (ADR-0007). It installs like any other extension, leaves Pi's configuration directory untouched, and keeps sessions, packages, project trust, and every other installed extension native.
10
10
 
@@ -23,32 +23,36 @@ Requires [Pi](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) (in
23
23
  pi
24
24
 
25
25
  # Use a profile for this run only (not saved)
26
- pi --profile review
26
+ pi --profile read-only
27
27
 
28
28
  # The explicit native baseline
29
29
  pi --profile default
30
30
  ```
31
31
 
32
- Define profiles in `~/.pi/agent/profiles.json` (global) or `<project>/.pi/profiles.json` (project, trusted projects only):
32
+ Create profiles with `/profile create`, which writes `~/.pi/agent/profiles.json` (global) or `<project>/.pi/profiles.json` (project, trusted projects only). The wizard offers the shipped `read-only` preset — Pi's built-in tools only, no skills, MCP servers, or model assumed — or a blank definition:
33
33
 
34
34
  ```json
35
35
  {
36
- "schemaVersion": 2,
36
+ "schemaVersion": 1,
37
37
  "profiles": {
38
- "review": {
39
- "label": "Code review",
40
- "skills": ["code-review"],
41
- "mcp": ["github"],
42
- "tools": ["read", "grep", "find", "bash"],
43
- "instructions": "Review only; do not modify files."
38
+ "read-only": {
39
+ "label": "Read-only",
40
+ "description": "Read-only session; no skills or MCP servers assumed — add your own.",
41
+ "tools": [
42
+ "read",
43
+ "grep",
44
+ "find",
45
+ "ls"
46
+ ],
47
+ "instructions": "Read-only session: inspect and report; never create, edit, rename, or delete files.\nIf a change is needed, describe it in your reply instead of applying it.\nDo not run commands that modify state (installs, formatters, commits, pushes, network writes).\nPrefer an available skill or MCP tool when it fits the request; otherwise use the tools you have.\nGround claims in evidence: cite file:line and separate verified facts from inferences.\nReply in English."
44
48
  }
45
49
  }
46
50
  }
47
51
  ```
48
52
 
49
- Profiles **reference** resources by name — they never copy them. Full schema with more examples: [`examples/profiles.json`](examples/profiles.json).
53
+ A preset is a one-time copy into your catalog: it is not tracked, so a package update never changes a profile you already created. Profiles **reference** resources by name — they never copy them. [`examples/profiles.json`](examples/profiles.json) is that same preset as a catalog; the field reference is [`schemas/profiles.schema.json`](schemas/profiles.schema.json).
50
54
 
51
- Existing `schemaVersion: 1` catalogs keep working: the `extensions` field is reported once and ignored, because extensions now load natively in every profile.
55
+ `schemaVersion` is 1. A profile cannot select extensions: they load natively in every profile, so manage them with `pi install`.
52
56
 
53
57
  ## What a profile controls
54
58
 
@@ -75,6 +79,8 @@ In the TUI, the `/profile` command family manages everything in-session:
75
79
  | `/profile customize` / `/profile reset` | Narrow the active profile for this session only |
76
80
  | `/mcp enable\|disable <server>` | Toggle MCP servers in the active profile |
77
81
 
82
+ While a non-`default` profile is active, the footer shows `profile: <name>`, with `*` appended when a session-only overlay is in effect. `default` shows no badge, so an unprofiled session keeps Pi's native footer.
83
+
78
84
  All commands work in non-interactive modes (`--mode rpc|print|json`); CRUD wizards are TUI-only.
79
85
 
80
86
  ## Guarantees
@@ -84,18 +90,6 @@ All commands work in non-interactive modes (`--mode rpc|print|json`); CRUD wizar
84
90
  - **Fail safe** — untrusted project directories are never read; a failed activation applies nothing and reports the cause.
85
91
  - **No reload** — switching re-applies runtime state in place; the next turn's prompt carries the new selection.
86
92
 
87
- ## Migrating from the launcher
88
-
89
- ```bash
90
- pi-profile review # before
91
- pi --profile review # after
92
-
93
- pi-profile review -- --mode rpc # before
94
- pi --profile review --mode rpc # after
95
- ```
96
-
97
- `/profile reload` and the `/profile resource` commands are gone: skills are read on demand and catalogs are re-read on every use, so there is nothing to reload. Remove leftover `~/.pi/agent/pi-profile/runtime/` directories; the extension no longer creates them.
98
-
99
93
  ## Docs
100
94
 
101
95
  - [Architecture](docs/architecture/overview.md) · [ADRs](docs/adr/) · [Glossary](CONTEXT.md) (Chinese)
package/README.zh-CN.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  [Pi](https://github.com/badlogic/pi-mono) 的命名 profile 扩展。一个 profile 选择模型看到什么、会话使用哪些能力——skills、MCP server、tools、预制模型和附加指令——并在同一 Pi 进程内即时切换。
6
6
 
7
- 代码评审用精简的只读 profile,实现需求用全量 profile,临时提问用最小 profile。
7
+ 随包提供一个 `read-only` 预设覆盖「读代码 + 报告」的工作流;其余工作流由你自己定义——profile 可以收窄工具集、隐藏 skill、固定模型或附加指令。
8
8
 
9
9
  `pi-profile-switch` 是普通 Pi package(ADR-0007):像其他扩展一样安装,不改动 Pi 的配置目录,session、packages、项目信任和其他扩展全部保持原生。
10
10
 
@@ -23,32 +23,36 @@ pi install npm:pi-profile-switch
23
23
  pi
24
24
 
25
25
  # 仅本次启动使用指定 profile(不保存)
26
- pi --profile review
26
+ pi --profile read-only
27
27
 
28
28
  # 显式的原生基线
29
29
  pi --profile default
30
30
  ```
31
31
 
32
- `~/.pi/agent/profiles.json`(全局)或 `<项目>/.pi/profiles.json`(项目级,仅限已信任项目)中定义 profile:
32
+ `/profile create` 创建 profile,写入 `~/.pi/agent/profiles.json`(全局)或 `<项目>/.pi/profiles.json`(项目级,仅限已信任项目)。向导提供随包的 `read-only` 预设——只用 Pi 内建工具,不假设任何 skill、MCP server 或模型——也可以从空定义开始:
33
33
 
34
34
  ```json
35
35
  {
36
- "schemaVersion": 2,
36
+ "schemaVersion": 1,
37
37
  "profiles": {
38
- "review": {
39
- "label": "Code review",
40
- "skills": ["code-review"],
41
- "mcp": ["github"],
42
- "tools": ["read", "grep", "find", "bash"],
43
- "instructions": "Review only; do not modify files."
38
+ "read-only": {
39
+ "label": "Read-only",
40
+ "description": "Read-only session; no skills or MCP servers assumed — add your own.",
41
+ "tools": [
42
+ "read",
43
+ "grep",
44
+ "find",
45
+ "ls"
46
+ ],
47
+ "instructions": "Read-only session: inspect and report; never create, edit, rename, or delete files.\nIf a change is needed, describe it in your reply instead of applying it.\nDo not run commands that modify state (installs, formatters, commits, pushes, network writes).\nPrefer an available skill or MCP tool when it fits the request; otherwise use the tools you have.\nGround claims in evidence: cite file:line and separate verified facts from inferences.\nReply in English."
44
48
  }
45
49
  }
46
50
  }
47
51
  ```
48
52
 
49
- Profile 只**引用**资源,从不复制资源。完整示例见 [`examples/profiles.json`](examples/profiles.json)。
53
+ 预设只被复制一次:它不被跟踪,包升级不会改动你已经创建的 profile。Profile 只**引用**资源,从不复制资源。[`examples/profiles.json`](examples/profiles.json) 就是同一个预设构成的 catalog;字段参考见 [`schemas/profiles.schema.json`](schemas/profiles.schema.json)
50
54
 
51
- 已有的 `schemaVersion: 1` catalog 继续可用:`extensions` 字段会被警告一次并忽略——extensions 现在在所有 profile 中原生加载。
55
+ `schemaVersion` 1。Profile 不选择 extension:extensions 在所有 profile 中原生加载,请用 `pi install` 管理。
52
56
 
53
57
  ## Profile 控制的范围
54
58
 
@@ -75,6 +79,8 @@ Profile 只**引用**资源,从不复制资源。完整示例见 [`examples/pr
75
79
  | `/profile customize` / `/profile reset` | 仅本次会话收窄活动 profile |
76
80
  | `/mcp enable\|disable <server>` | 在活动 profile 中开关 MCP server |
77
81
 
82
+ 活动 profile 不是 `default` 时,footer 显示 `profile: <name>`;本次会话的 overlay 生效时追加 `*`。`default` 不显示 badge,未使用 profile 的会话保持 Pi 原生 footer。
83
+
78
84
  非交互模式(`--mode rpc|print|json`)下命令同样生效;CRUD 向导仅 TUI 可用。
79
85
 
80
86
  ## 保证
@@ -84,18 +90,6 @@ Profile 只**引用**资源,从不复制资源。完整示例见 [`examples/pr
84
90
  - **失败安全**——未信任的项目目录从不读取;激活失败时不应用任何设置并报出原因。
85
91
  - **无 reload**——切换在原位重新应用运行时状态;下一个 turn 的 prompt 直接带上新选择。
86
92
 
87
- ## 从 launcher 迁移
88
-
89
- ```bash
90
- pi-profile review # 旧
91
- pi --profile review # 新
92
-
93
- pi-profile review -- --mode rpc # 旧
94
- pi --profile review --mode rpc # 新
95
- ```
96
-
97
- `/profile reload` 与 `/profile resource` 命令已移除:skill 内容按需读取、catalog 每次使用重新读取,没有需要 reload 的缓存。删除遗留的 `~/.pi/agent/pi-profile/runtime/` 目录;新版本不再创建它们。
98
-
99
93
  ## 文档
100
94
 
101
95
  - [架构设计](docs/architecture/overview.md) · [ADR](docs/adr/) · [术语表](CONTEXT.md)
@@ -1,38 +1,16 @@
1
1
  {
2
- "schemaVersion": 2,
2
+ "schemaVersion": 1,
3
3
  "profiles": {
4
- "review": {
5
- "label": "Code review",
6
- "description": "Read-heavy review workflow",
7
- "skills": [
8
- "code-review",
9
- "diagnosing-bugs"
10
- ],
11
- "mcp": [
12
- "github"
13
- ],
4
+ "read-only": {
5
+ "label": "Read-only",
6
+ "description": "Read-only session; no skills or MCP servers assumed — add your own.",
14
7
  "tools": [
15
8
  "read",
16
9
  "grep",
17
10
  "find",
18
- "bash"
19
- ],
20
- "instructions": "Review only; do not modify files."
21
- },
22
- "impl": {
23
- "label": "Implementation",
24
- "skills": [
25
- "tdd"
26
- ],
27
- "mcp": [
28
- "github",
29
- "linear"
11
+ "ls"
30
12
  ],
31
- "model": {
32
- "provider": "deepseek",
33
- "id": "deepseek-v4-pro",
34
- "thinkingLevel": "high"
35
- }
13
+ "instructions": "Read-only session: inspect and report; never create, edit, rename, or delete files.\nIf a change is needed, describe it in your reply instead of applying it.\nDo not run commands that modify state (installs, formatters, commits, pushes, network writes).\nPrefer an available skill or MCP tool when it fits the request; otherwise use the tools you have.\nGround claims in evidence: cite file:line and separate verified facts from inferences.\nReply in English."
36
14
  }
37
15
  }
38
16
  }
@@ -9,13 +9,16 @@ import {
9
9
  import { discoverAdapterServerNames } from "../../src/mcp-config.ts";
10
10
  import { probeAdapterPresence } from "../../src/mcp-coordination.ts";
11
11
  import { readSessionChoices } from "../../src/model-selection.ts";
12
+ import { buildProfileBadge, PROFILE_STATUS_KEY, renderProfileBadge } from "../../src/profile-badge.ts";
12
13
  import type { ProfileDefinition } from "../../src/profile-catalog.ts";
13
14
  import {
14
15
  formatSelectionWarnings,
16
+ formatSkillWarnings,
17
+ skillWarnings,
15
18
  type LiveResources,
16
19
  type ResolvedSelection,
17
20
  } from "../../src/profile-resolver.ts";
18
- import { RuntimeStateStore, stateDirFor, type RuntimeOverlay } from "../../src/runtime-state-store.ts";
21
+ import { RuntimeStateStore, overlayNarrows, stateDirFor, type RuntimeOverlay } from "../../src/runtime-state-store.ts";
19
22
  import {
20
23
  applySkillsFilter,
21
24
  formatInstructionsBlock,
@@ -69,6 +72,8 @@ import { buildStatusReport, formatStatusMarkdown } from "../../src/switching/sta
69
72
  * instructions. Unselected skills stay loaded and `/skill:`-invocable.
70
73
  * - `/profile …` command family and `/mcp enable|disable`.
71
74
  * - Retry pending tool literals each turn until MCP/extension tools register.
75
+ * - Footer badge: `profile: <name>` (plus `*` for a runtime overlay) in Pi's
76
+ * footer status line while a non-`default` profile is active.
72
77
  */
73
78
 
74
79
  type ContextWithOptions = ExtensionContext & { getSystemPromptOptions?: () => BuildSystemPromptOptions };
@@ -77,6 +82,21 @@ type ContextWithOptions = ExtensionContext & { getSystemPromptOptions?: () => Bu
77
82
  interface Activation {
78
83
  selection: ResolvedSelection;
79
84
  skillsOutcome?: SkillsFilterOutcome;
85
+ /** Whether the skill references were checked against Pi's loaded set.
86
+ * False after a startup activation: `session_start`'s event context
87
+ * cannot read that list, so the check moves to the first turn. */
88
+ skillsChecked: boolean;
89
+ /** The overlay this runtime was activated with, when one is in effect. */
90
+ overlay?: RuntimeOverlay;
91
+ }
92
+
93
+ /** Maps an activation result onto the runtime state `current` mirrors. */
94
+ function activationOf(result: ActivationResult, skillsChecked: boolean): Activation {
95
+ return {
96
+ selection: result.selection,
97
+ skillsChecked,
98
+ ...(result.overlay === undefined ? {} : { overlay: result.overlay }),
99
+ };
80
100
  }
81
101
 
82
102
  /** Subcommands that mutate a catalog; they need dialog-capable UI. */
@@ -98,6 +118,33 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
98
118
  const explicit = detectExplicitDeclarations(process.argv.slice(2));
99
119
  let current: Activation | undefined;
100
120
  let filterWarningShown = false;
121
+ /** The last badge written to the footer, so a refresh only talks to Pi
122
+ * when the rendering actually changed. */
123
+ let badgeText: string | undefined;
124
+
125
+ /** The only writer of `current`'s profile identity (`selection.name` and
126
+ * `overlay`) and of the footer badge. Both mirror the selection this
127
+ * runtime applied, so a failed activation (which throws before reaching
128
+ * here) never claims to be active. The per-turn updates (`pendingTools`,
129
+ * `skillsOutcome`) leave the identity and the badge untouched. */
130
+ function setCurrent(ctx: ExtensionContext, next: Activation | undefined): void {
131
+ current = next;
132
+ refreshBadge(ctx);
133
+ }
134
+
135
+ /** Re-renders the badge from `current`. `default` and an unapplied profile
136
+ * render no badge, which removes Pi's footer status line entirely. */
137
+ function refreshBadge(ctx: ExtensionContext): void {
138
+ if (!ctx.hasUI) return;
139
+ const badge =
140
+ current === undefined
141
+ ? undefined
142
+ : buildProfileBadge(current.selection.name, { overlay: overlayNarrows(current.overlay) });
143
+ const text = badge === undefined ? undefined : renderProfileBadge(badge, ctx.ui.theme);
144
+ if (text === badgeText) return;
145
+ badgeText = text;
146
+ ctx.ui.setStatus(PROFILE_STATUS_KEY, text);
147
+ }
101
148
 
102
149
  const surface = (ctx: ExtensionContext): ApplySurface => ({
103
150
  getAllTools: () => pi.getAllTools(),
@@ -120,8 +167,10 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
120
167
  }
121
168
 
122
169
  async function loadLive(ctx: ExtensionContext, projectTrusted: boolean): Promise<LiveResources> {
170
+ // Only command contexts expose the system-prompt options; the
171
+ // `session_start` event context has no accessor, so the loaded skills
172
+ // stay unknown there and their existence check moves to the first turn.
123
173
  const options = (ctx as ContextWithOptions).getSystemPromptOptions?.();
124
- const skills = (options?.skills ?? []).map((skill) => ({ name: skill.name, filePath: skill.filePath }));
125
174
  const adapterPresent = probeAdapterPresence(pi.events);
126
175
  let servers: string[] = [];
127
176
  if (adapterPresent) {
@@ -131,7 +180,13 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
131
180
  notify(ctx, error instanceof Error ? error.message : String(error), "warning");
132
181
  }
133
182
  }
134
- return { skills, toolNames: pi.getAllTools().map((tool) => tool.name), mcp: { adapterPresent, servers } };
183
+ return {
184
+ ...(options?.skills === undefined
185
+ ? {}
186
+ : { skills: options.skills.map((skill) => ({ name: skill.name, filePath: skill.filePath })) }),
187
+ toolNames: pi.getAllTools().map((tool) => tool.name),
188
+ mcp: { adapterPresent, servers },
189
+ };
135
190
  }
136
191
 
137
192
  /** Builds the dependencies for one activation. `force` marks an explicit
@@ -162,20 +217,17 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
162
217
  overlay: options?.overlay ?? null,
163
218
  persist: options?.persist ?? true,
164
219
  });
165
- current = { selection: result.selection };
166
- reportWarnings(ctx, result.warnings);
220
+ setCurrent(ctx, activationOf(result, deps.live.skills !== undefined));
167
221
  reportWarnings(ctx, formatSelectionWarnings(result.selection));
168
222
  return result;
169
223
  }
170
224
 
171
225
  async function profileEntries(ctx: ExtensionContext): Promise<ProfileListEntry[]> {
172
- const { entries, warnings } = await listProfiles({
226
+ return listProfiles({
173
227
  realAgentDir: getAgentDir(),
174
228
  cwd: ctx.cwd,
175
229
  projectTrusted: ctx.isProjectTrusted(),
176
230
  });
177
- reportWarnings(ctx, warnings);
178
- return entries;
179
231
  }
180
232
 
181
233
  function sendListMessage(entries: ProfileListEntry[]): void {
@@ -219,7 +271,12 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
219
271
  const wizard = await runProfileCreateWizard(ctx.ui, { projectTrusted: scopeInput.projectTrusted });
220
272
  if (wizard === undefined) return;
221
273
  await createProfile(scopeInput, wizard.scope, wizard.name, wizard.definition);
222
- notify(ctx, `created profile "${wizard.name}" (${wizard.scope}) activate with /profile use ${wizard.name}`, "info");
274
+ const origin = wizard.preset !== undefined ? ` from preset "${wizard.preset}"` : "";
275
+ notify(
276
+ ctx,
277
+ `created profile "${wizard.name}" (${wizard.scope})${origin} — activate with /profile use ${wizard.name}`,
278
+ "info",
279
+ );
223
280
  return;
224
281
  }
225
282
  if (subcommand === "duplicate") {
@@ -307,7 +364,7 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
307
364
  }
308
365
 
309
366
  pi.on("session_start", async (_event, ctx) => {
310
- current = undefined;
367
+ setCurrent(ctx, undefined);
311
368
  filterWarningShown = false;
312
369
  const agentDir = getAgentDir();
313
370
  const projectTrusted = ctx.isProjectTrusted();
@@ -351,6 +408,39 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
351
408
  },
352
409
  };
353
410
  }
411
+ // A startup activation cannot see Pi's skill list, so its references are
412
+ // checked here, on the first turn that carries the complete set — by then
413
+ // `resources_discover` has contributed every extension's skills. The
414
+ // corrected warnings replace the (empty) startup ones, so `/profile
415
+ // status` reports the same thing the user was told.
416
+ if (!current.skillsChecked) {
417
+ const refs = current.selection.skills?.refs;
418
+ const corrected =
419
+ refs === undefined || refs === "all"
420
+ ? undefined
421
+ : skillWarnings(
422
+ refs,
423
+ (event.systemPromptOptions.skills ?? []).map((skill) => ({
424
+ name: skill.name,
425
+ filePath: skill.filePath,
426
+ })),
427
+ );
428
+ current = {
429
+ ...current,
430
+ skillsChecked: true,
431
+ ...(corrected === undefined
432
+ ? {}
433
+ : { selection: { ...current.selection, warnings: { ...current.selection.warnings, ...corrected } } }),
434
+ };
435
+ if (corrected !== undefined) {
436
+ reportWarnings(ctx, formatSkillWarnings(current.selection.name, corrected));
437
+ }
438
+ }
439
+ // Pi has no extension-visible theme-change event and footer statuses are
440
+ // stored as finished strings, so re-render once per turn: a `/theme`
441
+ // switch is picked up without waiting for the next profile change.
442
+ // `refreshBadge` dedupes, so an unchanged badge sends nothing.
443
+ refreshBadge(ctx);
354
444
  const filtered = applySkillsFilter({
355
445
  systemPrompt: event.systemPrompt,
356
446
  options: event.systemPromptOptions,
@@ -418,8 +508,7 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
418
508
  const deps = await activationDeps(ctx, false);
419
509
  const target = { profile: { name: current.selection.name, source: current.selection.source } };
420
510
  const result = await customizeOverlay({ ...deps, ...target }, parseCustomizeArgs(rest.join(" ")));
421
- current = { selection: result.selection };
422
- reportWarnings(ctx, result.warnings);
511
+ setCurrent(ctx, activationOf(result, deps.live.skills !== undefined));
423
512
  notify(ctx, `overlay updated: ${result.selection.name}`, "info");
424
513
  return;
425
514
  }
@@ -431,8 +520,7 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
431
520
  const deps = await activationDeps(ctx, false);
432
521
  const target = { profile: { name: current.selection.name, source: current.selection.source } };
433
522
  const result = await resetOverlay({ ...deps, ...target });
434
- current = { selection: result.selection };
435
- reportWarnings(ctx, result.warnings);
523
+ setCurrent(ctx, activationOf(result, deps.live.skills !== undefined));
436
524
  notify(ctx, `overlay cleared: ${result.selection.name}`, "info");
437
525
  return;
438
526
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-profile-switch",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Named profiles for Pi: skills, MCP servers, tools, model, and instructions per workflow — switched in place in the same session.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -10,6 +10,7 @@
10
10
  "additionalProperties": false,
11
11
  "properties": {
12
12
  "schemaVersion": {
13
+ "description": "1 is current. 2 is the number v0.1.0 wrote for the same field shape and still reads; saves always write 1.",
13
14
  "enum": [1, 2]
14
15
  },
15
16
  "profiles": {
@@ -48,7 +49,7 @@
48
49
  "items": {
49
50
  "type": "string"
50
51
  },
51
- "description": "Deprecated (schemaVersion 1). Extensions are always loaded natively (ADR-0007); the field is ignored with a warning."
52
+ "description": "Ignored. Extensions always load natively (ADR-0007) and are managed with pi install; the field is accepted so existing catalogs keep validating."
52
53
  },
53
54
  "mcp": {
54
55
  "type": "array",
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Profile badge: the persistent, one-line answer to "which profile is this
3
+ * session running?".
4
+ *
5
+ * Presentation only — no state, no I/O, no activation. The extension decides
6
+ * when a badge is written (after an activation that succeeded, via
7
+ * `ctx.ui.setStatus(PROFILE_STATUS_KEY, …)`), so the badge can never claim a
8
+ * selection that was not applied.
9
+ *
10
+ * One canonical rendering, shared with the `/profile status` heading
11
+ * (`profile: <name>`), plus `*` when a runtime overlay is in effect — the only
12
+ * runtime difference the catalog does not show.
13
+ */
14
+
15
+ import type { ThemeColor } from "@earendil-works/pi-coding-agent";
16
+
17
+ import { DEFAULT_PROFILE_NAME } from "./profile-catalog.ts";
18
+
19
+ /**
20
+ * Status key in Pi's footer. Pi joins all extension statuses into one line,
21
+ * orders them by key, and truncates that line from the right — so an earlier
22
+ * key keeps its text visible on a narrow terminal. `active-profile` sorts
23
+ * before the keys it shares the line with (`mcp`, `pi-…`, `thinking`).
24
+ */
25
+ export const PROFILE_STATUS_KEY = "active-profile";
26
+
27
+ /** The label prefix, matching the `/profile status` heading. */
28
+ export const PROFILE_BADGE_LABEL = "profile";
29
+
30
+ /**
31
+ * Display columns reserved for the name before it is elided. The footer is a
32
+ * shared, fixed-width line: profile names are unbounded user input, so an
33
+ * unelided name would evict the statuses of other extensions.
34
+ */
35
+ export const PROFILE_BADGE_NAME_COLUMNS = 16;
36
+
37
+ const ELLIPSIS = "…";
38
+
39
+ export interface ProfileBadge {
40
+ /** The name `/profile use` accepts (never the display `label`). */
41
+ name: string;
42
+ /** A runtime overlay is in effect: this runtime differs from the catalog. */
43
+ overlay: boolean;
44
+ }
45
+
46
+ /** The minimum theme surface the badge needs; `ctx.ui.theme` satisfies it. */
47
+ export interface BadgeTheme {
48
+ fg(color: ThemeColor, text: string): string;
49
+ }
50
+
51
+ export interface BadgeOptions {
52
+ overlay: boolean;
53
+ /** Override the name budget (tests, future width awareness). */
54
+ nameColumns?: number;
55
+ }
56
+
57
+ /**
58
+ * Builds the badge for a resolved profile, or `undefined` when there must be
59
+ * no badge at all.
60
+ *
61
+ * `default` is Pi's native baseline — it declares nothing — so it must not
62
+ * change the footer either: a plain Pi session shows no badge, and the footer
63
+ * status line only exists while some extension status is set.
64
+ */
65
+ export function buildProfileBadge(name: string, options: BadgeOptions): ProfileBadge | undefined {
66
+ if (name === DEFAULT_PROFILE_NAME) return undefined;
67
+ return {
68
+ name: truncateToColumns(name, options.nameColumns ?? PROFILE_BADGE_NAME_COLUMNS),
69
+ overlay: options.overlay,
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Renders the badge. Colors come from the caller's theme at render time: Pi
75
+ * stores footer statuses as finished strings, so the extension re-renders on
76
+ * profile changes and on each turn (there is no extension-visible theme-change
77
+ * event).
78
+ */
79
+ export function renderProfileBadge(badge: ProfileBadge, theme: BadgeTheme): string {
80
+ const label = theme.fg("dim", `${PROFILE_BADGE_LABEL}: `);
81
+ const name = theme.fg("dim", badge.name);
82
+ return badge.overlay ? `${label}${name}${theme.fg("warning", "*")}` : `${label}${name}`;
83
+ }
84
+
85
+ /* -------------------------------------------------------------------------- */
86
+ /* Display width */
87
+ /* -------------------------------------------------------------------------- */
88
+
89
+ /**
90
+ * Width of one code point in terminal columns: 2 for East Asian wide and
91
+ * fullwidth code points, 1 otherwise. Zero-width joiners and combining marks
92
+ * are counted as 1 — an approximation that only over-reserves space for
93
+ * exotic names.
94
+ */
95
+ export function codePointWidth(codePoint: number): number {
96
+ return isWide(codePoint) ? 2 : 1;
97
+ }
98
+
99
+ /** Display width of `text` in terminal columns. */
100
+ export function displayWidth(text: string): number {
101
+ let width = 0;
102
+ for (const character of text) width += codePointWidth(character.codePointAt(0)!);
103
+ return width;
104
+ }
105
+
106
+ /**
107
+ * Truncates `text` to at most `columns` terminal columns, appending `…` when
108
+ * something was dropped. The ellipsis is part of the budget, so the result
109
+ * never exceeds `columns`.
110
+ */
111
+ export function truncateToColumns(text: string, columns: number, ellipsis = ELLIPSIS): string {
112
+ if (columns <= 0) return "";
113
+ if (displayWidth(text) <= columns) return text;
114
+ const budget = columns - displayWidth(ellipsis);
115
+ let result = "";
116
+ let width = 0;
117
+ for (const character of text) {
118
+ const next = width + codePointWidth(character.codePointAt(0)!);
119
+ if (next > budget) break;
120
+ result += character;
121
+ width = next;
122
+ }
123
+ return budget < 0 ? "" : `${result}${ellipsis}`;
124
+ }
125
+
126
+ function isWide(codePoint: number): boolean {
127
+ return (
128
+ (codePoint >= 0x1100 && codePoint <= 0x115f) || // Hangul Jamo
129
+ (codePoint >= 0x2e80 && codePoint <= 0x303e) || // CJK radicals, Kangxi, CJK symbols
130
+ (codePoint >= 0x3041 && codePoint <= 0x33ff) || // kana, CJK compatibility, CJK punctuation
131
+ (codePoint >= 0x3400 && codePoint <= 0x4dbf) || // CJK unified ideographs extension A
132
+ (codePoint >= 0x4e00 && codePoint <= 0x9fff) || // CJK unified ideographs
133
+ (codePoint >= 0xa000 && codePoint <= 0xa4cf) || // Yi syllables
134
+ (codePoint >= 0xac00 && codePoint <= 0xd7a3) || // Hangul syllables
135
+ (codePoint >= 0xf900 && codePoint <= 0xfaff) || // CJK compatibility ideographs
136
+ (codePoint >= 0xfe30 && codePoint <= 0xfe6f) || // CJK compatibility forms
137
+ (codePoint >= 0xff00 && codePoint <= 0xff60) || // fullwidth forms
138
+ (codePoint >= 0xffe0 && codePoint <= 0xffe6) || // fullwidth signs
139
+ (codePoint >= 0x1f300 && codePoint <= 0x1faff) || // emoji, pictographs
140
+ (codePoint >= 0x20000 && codePoint <= 0x3fffd) // CJK unified ideographs extension B+
141
+ );
142
+ }
@@ -3,9 +3,8 @@
3
3
  * separate from the read-only ProfileCatalog.
4
4
  *
5
5
  * Invariants:
6
- * - Whole-file overwrites (pretty-printed, `schemaVersion 2` envelope).
7
- * A version 1 file is read with its `extensions` fields dropped and is
8
- * rewritten as version 2 on the next save; wizard saves never block on
6
+ * - Whole-file overwrites (pretty-printed, `schemaVersion 1` envelope, the
7
+ * constant owned by profile-catalog.ts). Wizard saves never block on
9
8
  * concurrent edits — re-read at write time, same-name conflicts resolve
10
9
  * last-write-wins.
11
10
  * - Definitions are complete and self-contained: no inheritance fields
@@ -38,7 +37,7 @@ export class ProfileCatalogStore {
38
37
 
39
38
  /** Validated definitions: missing file → empty; malformed → CatalogError
40
39
  * (catalog errors never pass silently, even on the write path).
41
- * Version 1 files load with `extensions` dropped. */
40
+ * Unknown fields (a legacy `extensions` declaration) are dropped. */
42
41
  async readDefinitions(): Promise<Map<string, ProfileDefinition>> {
43
42
  const result = await readJsonFile(this.#filePath);
44
43
  if (!result.ok) {
@@ -48,7 +47,7 @@ export class ProfileCatalogStore {
48
47
  if (!isRecord(result.value)) {
49
48
  throw new CatalogError(`${this.#filePath}: catalog must be an object`);
50
49
  }
51
- return parseCatalogDocument(result.value, this.#filePath).profiles;
50
+ return parseCatalogDocument(result.value, this.#filePath);
52
51
  }
53
52
 
54
53
  /** Overwrites the file with the given definitions (last write wins). */
@@ -7,9 +7,11 @@
7
7
  * - A profile references skills, MCP servers, and tools, and may declare
8
8
  * instructions and a model preset. Extensions are not a profile resource:
9
9
  * every installed extension loads natively in every profile.
10
- * - schemaVersion 2 is current. Version 1 files load with their per-profile
11
- * `extensions` field ignored and a warning, so existing catalogs keep
12
- * working without an edit.
10
+ * - schemaVersion 1 is current. Version 2 describes the same fields — the
11
+ * number only marked the era in which `extensions` was still a profile
12
+ * resource and reads the same; every save writes version 1.
13
+ * - A legacy `extensions` field is ignored silently, like any other unknown
14
+ * field: extensions load natively and are managed with `pi install`.
13
15
  *
14
16
  * Invariants:
15
17
  * - The built-in `default` profile never exists in either file and cannot be
@@ -27,7 +29,9 @@ import path from "node:path";
27
29
 
28
30
  import { isRecord, readJsonFile } from "./json-file.ts";
29
31
 
30
- export const PROFILE_SCHEMA_VERSION = 2;
32
+ export const PROFILE_SCHEMA_VERSION = 1;
33
+ /** The number v0.1.0 wrote for the same field shape: read, never written. */
34
+ const LEGACY_SCHEMA_VERSION = 2;
31
35
  export const DEFAULT_PROFILE_NAME = "default";
32
36
 
33
37
  export interface ProfileModel {
@@ -62,13 +66,6 @@ export interface ResolvedProfile {
62
66
  definition: ProfileDefinition;
63
67
  }
64
68
 
65
- /** One parsed catalog file: definitions plus non-fatal compatibility
66
- * warnings the caller surfaces once per activation. */
67
- export interface CatalogDocument {
68
- profiles: Map<string, ProfileDefinition>;
69
- warnings: string[];
70
- }
71
-
72
69
  export class CatalogError extends Error {
73
70
  constructor(message: string) {
74
71
  super(message);
@@ -94,8 +91,8 @@ function readOptionalString(value: unknown, field: string, profileName: string):
94
91
 
95
92
  /** Parses one raw profile definition; exported for the write-side store
96
93
  * (profile-catalog-store.ts) so anything written is loadable. Unknown
97
- * fields are ignored by design — `extensions` is reported by
98
- * parseCatalogDocument, which has the file path for the warning. */
94
+ * fields are ignored by design — a legacy `extensions` declaration is
95
+ * dropped silently, exactly like any other unknown key. */
99
96
  export function parseProfileDefinition(name: string, raw: unknown): ProfileDefinition {
100
97
  if (!isRecord(raw)) {
101
98
  throw new CatalogError(`profile "${name}" must be an object`);
@@ -123,22 +120,16 @@ export function parseProfileDefinition(name: string, raw: unknown): ProfileDefin
123
120
 
124
121
  /** Parses one catalog document. Missing files are handled by the caller;
125
122
  * this function sees only parsed JSON. */
126
- export function parseCatalogDocument(value: unknown, filePath: string): CatalogDocument {
123
+ export function parseCatalogDocument(value: unknown, filePath: string): Map<string, ProfileDefinition> {
127
124
  if (!isRecord(value)) {
128
125
  throw new CatalogError(`${filePath}: catalog must be an object`);
129
126
  }
130
- const warnings: string[] = [];
131
127
  const version = value.schemaVersion;
132
- if (version !== 1 && version !== PROFILE_SCHEMA_VERSION) {
128
+ if (version !== PROFILE_SCHEMA_VERSION && version !== LEGACY_SCHEMA_VERSION) {
133
129
  throw new CatalogError(
134
130
  `${filePath}: unsupported schemaVersion ${JSON.stringify(version)} (expected ${PROFILE_SCHEMA_VERSION})`,
135
131
  );
136
132
  }
137
- if (version === 1) {
138
- warnings.push(
139
- `${filePath}: schemaVersion 1 is read as version ${PROFILE_SCHEMA_VERSION}; profiles declaring "extensions" are upgraded with that field ignored`,
140
- );
141
- }
142
133
  if (!isRecord(value.profiles)) {
143
134
  throw new CatalogError(`${filePath}: "profiles" must be an object mapping names to definitions`);
144
135
  }
@@ -149,21 +140,16 @@ export function parseCatalogDocument(value: unknown, filePath: string): CatalogD
149
140
  `${filePath}: "${DEFAULT_PROFILE_NAME}" is built in and must not be defined in the catalog`,
150
141
  );
151
142
  }
152
- if (isRecord(raw) && raw.extensions !== undefined) {
153
- warnings.push(
154
- `${filePath}: profile "${name}" declares "extensions"; extensions are always loaded natively (ADR-0007) and the field is ignored — manage extensions with pi install`,
155
- );
156
- }
157
143
  profiles.set(name, parseProfileDefinition(name, raw));
158
144
  }
159
- return { profiles, warnings };
145
+ return profiles;
160
146
  }
161
147
 
162
148
  /** Reads one catalog file; missing → empty map, malformed → CatalogError. */
163
- async function loadCatalogFile(catalogPath: string): Promise<CatalogDocument> {
149
+ async function loadCatalogFile(catalogPath: string): Promise<Map<string, ProfileDefinition>> {
164
150
  const result = await readJsonFile(catalogPath);
165
151
  if (!result.ok) {
166
- if (result.reason === "missing") return { profiles: new Map(), warnings: [] };
152
+ if (result.reason === "missing") return new Map();
167
153
  throw new CatalogError(`invalid JSON in ${catalogPath}`);
168
154
  }
169
155
  return parseCatalogDocument(result.value, catalogPath);
@@ -171,17 +157,9 @@ async function loadCatalogFile(catalogPath: string): Promise<CatalogDocument> {
171
157
 
172
158
  export class ProfileCatalog {
173
159
  readonly #profiles: ReadonlyMap<string, CatalogEntry>;
174
- readonly #warnings: readonly string[];
175
160
 
176
- private constructor(profiles: ReadonlyMap<string, CatalogEntry>, warnings: readonly string[]) {
161
+ private constructor(profiles: ReadonlyMap<string, CatalogEntry>) {
177
162
  this.#profiles = profiles;
178
- this.#warnings = warnings;
179
- }
180
-
181
- /** Compatibility warnings from reading the catalog files (v1 schema,
182
- * ignored `extensions` fields). Empty for a current, well-formed pair. */
183
- get warnings(): readonly string[] {
184
- return this.#warnings;
185
163
  }
186
164
 
187
165
  /**
@@ -193,18 +171,16 @@ export class ProfileCatalog {
193
171
  static async load(agentDir: string, options?: { projectDir?: string }): Promise<ProfileCatalog> {
194
172
  const global = await loadCatalogFile(path.join(agentDir, "profiles.json"));
195
173
  const profiles = new Map<string, CatalogEntry>();
196
- const warnings = [...global.warnings];
197
- for (const [name, definition] of global.profiles) {
174
+ for (const [name, definition] of global) {
198
175
  profiles.set(name, { source: "global", definition });
199
176
  }
200
177
  if (options?.projectDir !== undefined) {
201
178
  const project = await loadCatalogFile(path.join(options.projectDir, ".pi", "profiles.json"));
202
- warnings.push(...project.warnings);
203
- for (const [name, definition] of project.profiles) {
179
+ for (const [name, definition] of project) {
204
180
  profiles.set(name, { source: "project", definition });
205
181
  }
206
182
  }
207
- return new ProfileCatalog(profiles, warnings);
183
+ return new ProfileCatalog(profiles);
208
184
  }
209
185
 
210
186
  /** Resolves a profile by name. `default` always resolves to the built-in
@@ -0,0 +1,60 @@
1
+ /**
2
+ * ProfilePresets: the starting points `/profile create` offers.
3
+ *
4
+ * A preset is DATA, not a profile. It never appears in `/profile list` and
5
+ * cannot be activated until the create wizard copies it into a user catalog,
6
+ * so `default` stays the only built-in profile and nothing here is ever
7
+ * silently in effect. Once copied, the definition belongs to the user: the
8
+ * preset is not tracked, and later changes to it do not reach existing
9
+ * profiles.
10
+ *
11
+ * Every preset must work on a machine with no skills installed, no MCP
12
+ * adapter, and no provider credentials:
13
+ *
14
+ * - no `mcp`: a declared MCP intent fails the whole activation when
15
+ * `pi-mcp-adapter` is absent or the server was never discovered
16
+ * (ADR-0002).
17
+ * - no `model`: `provider` and `id` are required, and an unauthenticated
18
+ * model also fails the whole activation.
19
+ * - no `skills`: a literal reference warns once the skill turns out to be
20
+ * missing, and `[]` hides every skill — omitting the field keeps Pi's full
21
+ * visibility.
22
+ * - `tools` names Pi's built-in tools only, because extension and MCP tool
23
+ * names may never register, and it always contains `read`: Pi emits the
24
+ * prompt's skills section only while `read` or `bash` is active.
25
+ * - `instructions` states behavior, never a capability name, and stays
26
+ * short: Pi appends it to the system prompt on every turn.
27
+ *
28
+ * `test/profile-presets.test.ts` enforces every rule above and asserts
29
+ * `examples/profiles.json` is exactly this catalog.
30
+ */
31
+
32
+ import type { ProfileDefinition } from "./profile-catalog.ts";
33
+
34
+ export interface ProfilePreset {
35
+ /** Catalog key the create wizard offers as the new profile's name. */
36
+ name: string;
37
+ definition: ProfileDefinition;
38
+ }
39
+
40
+ /** The read-only behavior contract: describe, do not mutate. */
41
+ const READ_ONLY_INSTRUCTIONS = [
42
+ "Read-only session: inspect and report; never create, edit, rename, or delete files.",
43
+ "If a change is needed, describe it in your reply instead of applying it.",
44
+ "Do not run commands that modify state (installs, formatters, commits, pushes, network writes).",
45
+ "Prefer an available skill or MCP tool when it fits the request; otherwise use the tools you have.",
46
+ "Ground claims in evidence: cite file:line and separate verified facts from inferences.",
47
+ "Reply in English.",
48
+ ].join("\n");
49
+
50
+ export const PROFILE_PRESETS: readonly ProfilePreset[] = [
51
+ {
52
+ name: "read-only",
53
+ definition: {
54
+ label: "Read-only",
55
+ description: "Read-only session; no skills or MCP servers assumed — add your own.",
56
+ tools: ["read", "grep", "find", "ls"],
57
+ instructions: READ_ONLY_INSTRUCTIONS,
58
+ },
59
+ },
60
+ ];
@@ -23,7 +23,11 @@ import type { RuntimeOverlay } from "./runtime-state-store.ts";
23
23
 
24
24
  /** The live resource view a resolution runs against. */
25
25
  export interface LiveResources {
26
- skills: Array<{ name: string; filePath: string }>;
26
+ /** Pi's loaded skills. `undefined` when the caller cannot read them yet:
27
+ * Pi exposes the list on command contexts and on the `before_agent_start`
28
+ * event, not in `session_start`'s event context. An unknown set leaves the
29
+ * visibility filter intact and reports nothing (see `skillWarnings`). */
30
+ skills?: Array<{ name: string; filePath: string }>;
27
31
  toolNames: string[];
28
32
  /** MCP adapter state: presence plus discovered server names. */
29
33
  mcp: { adapterPresent: boolean; servers: string[] };
@@ -77,17 +81,21 @@ export class SelectionError extends Error {
77
81
  }
78
82
  }
79
83
 
80
- function resolveSkills(
81
- declared: string[] | undefined,
82
- disabled: string[],
84
+ /** Existence warnings for skill references against one live skill set.
85
+ * `undefined` means "not known yet" — Pi's skill list is only readable from
86
+ * a command context or from the `before_agent_start` event, so a startup
87
+ * activation reports nothing instead of every reference as unloaded. The
88
+ * same function re-checks the references on the first turn, when the list
89
+ * (including skills contributed through `resources_discover`) is complete. */
90
+ export function skillWarnings(
91
+ refs: readonly string[],
83
92
  live: LiveResources["skills"],
84
- ): { filter?: SkillsFilter; warning: Pick<SelectionWarnings, "skillsUnresolved" | "skillsUnmatched"> } {
85
- const refs = declared ?? [];
86
- const warning = { skillsUnresolved: [] as UnresolvedRef[], skillsUnmatched: [] as string[] };
87
- if (declared === undefined) {
88
- // The profile declares nothing; an overlay may still hide skills.
89
- return { ...(disabled.length > 0 ? { filter: { refs: "all" as const, disabled } } : {}), warning };
90
- }
93
+ ): Pick<SelectionWarnings, "skillsUnresolved" | "skillsUnmatched"> {
94
+ const warning: Pick<SelectionWarnings, "skillsUnresolved" | "skillsUnmatched"> = {
95
+ skillsUnresolved: [],
96
+ skillsUnmatched: [],
97
+ };
98
+ if (live === undefined) return warning;
91
99
  for (const ref of refs) {
92
100
  const hits = live.filter((skill) => matchesReference(ref, skill.name));
93
101
  if (hits.length === 0) {
@@ -95,7 +103,22 @@ function resolveSkills(
95
103
  else warning.skillsUnresolved.push({ reference: ref, suggestions: suggestNames(ref, live.map((s) => s.name)) });
96
104
  }
97
105
  }
98
- return { filter: { refs, disabled }, warning };
106
+ return warning;
107
+ }
108
+
109
+ function resolveSkills(
110
+ declared: string[] | undefined,
111
+ disabled: string[],
112
+ live: LiveResources["skills"],
113
+ ): { filter?: SkillsFilter; warning: Pick<SelectionWarnings, "skillsUnresolved" | "skillsUnmatched"> } {
114
+ if (declared === undefined) {
115
+ // The profile declares nothing; an overlay may still hide skills.
116
+ return {
117
+ ...(disabled.length > 0 ? { filter: { refs: "all" as const, disabled } } : {}),
118
+ warning: skillWarnings([], live),
119
+ };
120
+ }
121
+ return { filter: { refs: declared, disabled }, warning: skillWarnings(declared, live) };
99
122
  }
100
123
 
101
124
  function resolveMcp(
@@ -211,23 +234,33 @@ export function resolveSelection(input: {
211
234
  return selection;
212
235
  }
213
236
 
214
- /** User-facing warning lines for one resolved selection. */
215
- export function formatSelectionWarnings(selection: ResolvedSelection): string[] {
237
+ /** User-facing lines for one set of skill warnings. Shared by the startup
238
+ * report and the first-turn re-check, so both read identically. */
239
+ export function formatSkillWarnings(
240
+ profile: string,
241
+ warning: Pick<SelectionWarnings, "skillsUnresolved" | "skillsUnmatched">,
242
+ ): string[] {
216
243
  const lines: string[] = [];
217
- for (const unresolved of selection.warnings.skillsUnresolved) {
244
+ for (const unresolved of warning.skillsUnresolved) {
218
245
  const hint =
219
246
  unresolved.suggestions.length > 0
220
247
  ? ` — did you mean: ${unresolved.suggestions.map((name) => JSON.stringify(name)).join(", ")}?`
221
248
  : "";
222
249
  lines.push(
223
- `profile "${selection.name}": skill ${JSON.stringify(unresolved.reference)} is not loaded in this session${hint}`,
250
+ `profile "${profile}": skill ${JSON.stringify(unresolved.reference)} is not loaded in this session${hint}`,
224
251
  );
225
252
  }
226
- if (selection.warnings.skillsUnmatched.length > 0) {
253
+ if (warning.skillsUnmatched.length > 0) {
227
254
  lines.push(
228
- `profile "${selection.name}": skill glob(s) ${selection.warnings.skillsUnmatched.map((ref) => JSON.stringify(ref)).join(", ")} matched nothing`,
255
+ `profile "${profile}": skill glob(s) ${warning.skillsUnmatched.map((ref) => JSON.stringify(ref)).join(", ")} matched nothing`,
229
256
  );
230
257
  }
258
+ return lines;
259
+ }
260
+
261
+ /** User-facing warning lines for one resolved selection. */
262
+ export function formatSelectionWarnings(selection: ResolvedSelection): string[] {
263
+ const lines: string[] = formatSkillWarnings(selection.name, selection.warnings);
231
264
  if (selection.warnings.mcpUnmatched.length > 0) {
232
265
  lines.push(
233
266
  `profile "${selection.name}": MCP glob(s) ${selection.warnings.mcpUnmatched.map((ref) => JSON.stringify(ref)).join(", ")} matched nothing`,
@@ -42,6 +42,19 @@ export interface RuntimeOverlay {
42
42
  tools?: string[];
43
43
  }
44
44
 
45
+ /** True when an overlay actually narrows the active profile. An overlay whose
46
+ * fields were all removed again (`/profile customize enable …`) is not a
47
+ * difference from the catalog, and `parseOverlay` drops an empty overlay on
48
+ * read. A `tools: []` override is a difference: it selects no tools. */
49
+ export function overlayNarrows(overlay: RuntimeOverlay | undefined): boolean {
50
+ if (overlay === undefined) return false;
51
+ return (
52
+ (overlay.disabledSkills?.length ?? 0) > 0 ||
53
+ (overlay.disabledMcp?.length ?? 0) > 0 ||
54
+ overlay.tools !== undefined
55
+ );
56
+ }
57
+
45
58
  function parseOverlay(value: unknown): RuntimeOverlay | undefined {
46
59
  if (!isRecord(value)) return undefined;
47
60
  const overlay: RuntimeOverlay = {};
@@ -105,10 +105,7 @@ export async function resolveStartupProfile(input: {
105
105
  const catalog = await ProfileCatalog.load(input.agentDir, {
106
106
  projectDir: input.projectTrusted ? input.cwd : undefined,
107
107
  });
108
- const warnings: string[] = [
109
- ...catalog.warnings,
110
- ...(await legacyRegistryWarnings(input)),
111
- ];
108
+ const warnings: string[] = await legacyRegistryWarnings(input);
112
109
 
113
110
  if (input.requested !== undefined) {
114
111
  if (catalog.resolve(input.requested) === undefined) {
@@ -53,7 +53,10 @@ export interface ActivationDeps {
53
53
 
54
54
  export interface ActivationResult {
55
55
  selection: ResolvedSelection;
56
- warnings: string[];
56
+ /** The overlay this activation applied; absent when the runtime runs the
57
+ * profile exactly as it is declared. Callers surface it (the footer badge)
58
+ * instead of re-reading the state file. */
59
+ overlay?: RuntimeOverlay;
57
60
  }
58
61
 
59
62
  /** Resolves a profile against the live resources without applying it.
@@ -65,7 +68,7 @@ export async function resolveProfileSelection(
65
68
  name: string,
66
69
  deps: Pick<ActivationDeps, "agentDir" | "cwd" | "projectTrusted" | "live" | "presetInputs">,
67
70
  overlay?: RuntimeOverlay,
68
- ): Promise<{ selection: ResolvedSelection; warnings: string[] }> {
71
+ ): Promise<ResolvedSelection> {
69
72
  const catalog = await ProfileCatalog.load(deps.agentDir, {
70
73
  projectDir: deps.projectTrusted ? deps.cwd : undefined,
71
74
  });
@@ -76,8 +79,7 @@ export async function resolveProfileSelection(
76
79
  );
77
80
  }
78
81
  const suppressTools = !deps.presetInputs.force && deps.presetInputs.explicit.tools;
79
- const selection = resolveSelection({ profile, overlay, live: deps.live, suppressTools });
80
- return { selection, warnings: [...catalog.warnings] };
82
+ return resolveSelection({ profile, overlay, live: deps.live, suppressTools });
81
83
  }
82
84
 
83
85
  /** Activates a profile: resolve, validate, optionally persist, apply. */
@@ -87,7 +89,7 @@ export async function activateProfile(
87
89
  options?: { overlay?: RuntimeOverlay | null; persist?: boolean },
88
90
  ): Promise<ActivationResult> {
89
91
  const overlay = options?.overlay ?? undefined;
90
- const { selection, warnings } = await resolveProfileSelection(name, deps, overlay);
92
+ const selection = await resolveProfileSelection(name, deps, overlay);
91
93
  const preset = decidePreset({ model: selection.model, ...deps.presetInputs });
92
94
 
93
95
  // Validate before touching anything: the model preset is the only
@@ -109,5 +111,5 @@ export async function activateProfile(
109
111
  throw new ActivationError(result.error ?? `profile "${name}" could not be applied`);
110
112
  }
111
113
 
112
- return { selection, warnings };
114
+ return { selection, ...(overlay === undefined ? {} : { overlay }) };
113
115
  }
@@ -24,20 +24,17 @@ export async function listProfiles(input: {
24
24
  realAgentDir: string;
25
25
  cwd: string;
26
26
  projectTrusted: boolean;
27
- }): Promise<{ entries: ProfileListEntry[]; warnings: string[] }> {
27
+ }): Promise<ProfileListEntry[]> {
28
28
  const catalog = await ProfileCatalog.load(input.realAgentDir, {
29
29
  projectDir: input.projectTrusted ? input.cwd : undefined,
30
30
  });
31
- return {
32
- entries: catalog.list().map((profile) => ({
33
- name: profile.name,
34
- source: profile.source,
35
- ...(typeof profile.definition.label === "string" ? { label: profile.definition.label } : {}),
36
- ...(typeof profile.definition.description === "string" ? { description: profile.definition.description } : {}),
37
- shadowsGlobal: profile.source === "project" && catalog.shadowsGlobal(profile.name),
38
- })),
39
- warnings: [...catalog.warnings],
40
- };
31
+ return catalog.list().map((profile) => ({
32
+ name: profile.name,
33
+ source: profile.source,
34
+ ...(typeof profile.definition.label === "string" ? { label: profile.definition.label } : {}),
35
+ ...(typeof profile.definition.description === "string" ? { description: profile.definition.description } : {}),
36
+ shadowsGlobal: profile.source === "project" && catalog.shadowsGlobal(profile.name),
37
+ }));
41
38
  }
42
39
 
43
40
  export function formatProfileList(entries: ProfileListEntry[], activeProfile?: string): string {
@@ -8,9 +8,14 @@
8
8
  * keeps the current value (prefill via placeholder); there is no
9
9
  * field-clearing gesture (delete + create instead). Any cancelled step
10
10
  * aborts the wizard — nothing is written.
11
+ *
12
+ * Create can start from a shipped preset (src/profile-presets.ts). A preset is
13
+ * prefilled form state only: the wizard copies its complete definition into the
14
+ * chosen catalog, and the new profile belongs to the user from then on.
11
15
  */
12
16
 
13
17
  import type { ProfileDefinition } from "../profile-catalog.ts";
18
+ import { PROFILE_PRESETS, type ProfilePreset } from "../profile-presets.ts";
14
19
  import type { CatalogScope } from "./profile-crud.ts";
15
20
 
16
21
  export interface ProfileWizardUi {
@@ -22,6 +27,8 @@ export interface ProfileWizardResult {
22
27
  scope: CatalogScope;
23
28
  name: string;
24
29
  definition: ProfileDefinition;
30
+ /** Name of the preset this definition was copied from, when any. */
31
+ preset?: string;
25
32
  }
26
33
 
27
34
  interface ExistingProfile {
@@ -108,23 +115,49 @@ async function captureDefinition(
108
115
  return definition;
109
116
  }
110
117
 
111
- /** Create: scope first (project only when trusted), then name, then fields. */
118
+ const BLANK_OPTION = "blank start from an empty definition";
119
+
120
+ /** One preset row. The caller identifies the preset by the option's INDEX, so
121
+ * a preset named like another row's text cannot be misread. */
122
+ function presetOption(preset: ProfilePreset): string {
123
+ const hint = preset.definition.description ?? preset.definition.label;
124
+ return hint !== undefined ? `${preset.name} — ${hint}` : preset.name;
125
+ }
126
+
127
+ /** Create: scope, optional preset, name, then fields. */
112
128
  export async function runProfileCreateWizard(
113
129
  ui: ProfileWizardUi,
114
- input: { projectTrusted: boolean },
130
+ input: { projectTrusted: boolean; presets?: readonly ProfilePreset[] },
115
131
  ): Promise<ProfileWizardResult | undefined> {
116
132
  const scopeOptions = input.projectTrusted ? ["global", "project"] : ["global"];
117
133
  // A single available scope needs no dialog.
118
134
  const scope = scopeOptions.length === 1 ? scopeOptions[0] : await ui.select("write to which catalog?", scopeOptions);
119
135
  if (scope === undefined) return undefined;
120
136
 
121
- const name = await ui.input("profile name");
122
- if (name === undefined || name.trim().length === 0) return undefined;
137
+ const presets = input.presets ?? PROFILE_PRESETS;
138
+ let preset: ProfilePreset | undefined;
139
+ if (presets.length > 0) {
140
+ const options = [BLANK_OPTION, ...presets.map(presetOption)];
141
+ const chosen = await ui.select("start from which preset?", options);
142
+ if (chosen === undefined) return undefined;
143
+ preset = presets[options.indexOf(chosen) - 1];
144
+ }
145
+
146
+ // An empty answer takes the preset's name, so Enter accepts the preset.
147
+ const name = await ui.input("profile name", preset?.name);
148
+ if (name === undefined) return undefined;
149
+ const resolvedName = name.trim().length > 0 ? name.trim() : preset?.name;
150
+ if (resolvedName === undefined) return undefined;
123
151
 
124
- const definition = await captureDefinition(ui);
152
+ const definition = await captureDefinition(ui, preset?.definition);
125
153
  if (definition === undefined) return undefined;
126
154
 
127
- return { scope: scope as CatalogScope, name: name.trim(), definition };
155
+ return {
156
+ scope: scope as CatalogScope,
157
+ name: resolvedName,
158
+ definition,
159
+ ...(preset !== undefined ? { preset: preset.name } : {}),
160
+ };
128
161
  }
129
162
 
130
163
  /** Edit: fields prefilled from the existing complete definition. */