pi-profile-switch 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +106 -0
- package/README.zh-CN.md +106 -0
- package/examples/profiles.json +38 -0
- package/extensions/pi-profile-switch/index.ts +549 -0
- package/package.json +59 -0
- package/schemas/profiles.schema.json +94 -0
- package/src/json-file.ts +35 -0
- package/src/mcp-config.ts +63 -0
- package/src/mcp-coordination.ts +46 -0
- package/src/model-selection.ts +64 -0
- package/src/name-matching.ts +50 -0
- package/src/profile-catalog-store.ts +91 -0
- package/src/profile-catalog.ts +234 -0
- package/src/profile-resolver.ts +247 -0
- package/src/runtime-state-store.ts +105 -0
- package/src/skill-selection.ts +81 -0
- package/src/startup-selection.ts +147 -0
- package/src/switching/activate-profile.ts +113 -0
- package/src/switching/apply-profile.ts +131 -0
- package/src/switching/customize.ts +87 -0
- package/src/switching/list-profiles.ts +55 -0
- package/src/switching/mcp-toggle.ts +75 -0
- package/src/switching/profile-crud.ts +132 -0
- package/src/switching/profile-wizard.ts +158 -0
- package/src/switching/status.ts +113 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ApplyProfile: applies a resolved selection to the running Pi.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the launch-plan application of the host architecture: there is no
|
|
5
|
+
* plan file and no reload. Application is ordered so that the only
|
|
6
|
+
* fallible step — the model preset — runs first; a failure leaves the
|
|
7
|
+
* runtime untouched and the caller keeps the previous state.
|
|
8
|
+
*
|
|
9
|
+
* Steps:
|
|
10
|
+
* 1. validate: the declared model exists and has configured auth.
|
|
11
|
+
* 2. model: `setModel` + `setThinkingLevel` (when the preset applies).
|
|
12
|
+
* 3. tools: `setActiveTools` with the resolved active set. Literals the
|
|
13
|
+
* live registry does not provide yet stay in `pendingTools` and are
|
|
14
|
+
* retried by the caller each turn (MCP and extension tools register
|
|
15
|
+
* after session start).
|
|
16
|
+
* 4. mcp: publish the runtime server allowlist to pi-mcp-adapter.
|
|
17
|
+
*
|
|
18
|
+
* Dependency-injected against a narrow surface so unit tests never need a
|
|
19
|
+
* real Pi.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
MCP_ALLOWLIST_EVENT,
|
|
24
|
+
MCP_ALLOWLIST_VERSION,
|
|
25
|
+
} from "../mcp-coordination.ts";
|
|
26
|
+
import type { PresetDecisions } from "../model-selection.ts";
|
|
27
|
+
import type { ResolvedSelection } from "../profile-resolver.ts";
|
|
28
|
+
|
|
29
|
+
/** The narrow slice of ExtensionAPI/Context the application needs. */
|
|
30
|
+
export interface ApplySurface {
|
|
31
|
+
getAllTools(): Array<{ name: string }>;
|
|
32
|
+
setActiveTools(names: string[]): void;
|
|
33
|
+
modelRegistry: {
|
|
34
|
+
find(provider: string, id: string): unknown | undefined;
|
|
35
|
+
hasConfiguredAuth(model: unknown): boolean;
|
|
36
|
+
};
|
|
37
|
+
setModel(model: unknown): Promise<boolean>;
|
|
38
|
+
setThinkingLevel(level: string): void;
|
|
39
|
+
events: { emit(channel: string, data: unknown): void };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ApplyResult {
|
|
43
|
+
applied: boolean;
|
|
44
|
+
/** Present when nothing was applied. */
|
|
45
|
+
error?: string;
|
|
46
|
+
warnings: string[];
|
|
47
|
+
/** Tool literals still missing from the live registry. */
|
|
48
|
+
pendingTools: string[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Checks everything that can be checked before mutating the runtime. */
|
|
52
|
+
export function validateSelection(
|
|
53
|
+
selection: ResolvedSelection,
|
|
54
|
+
surface: ApplySurface,
|
|
55
|
+
preset: PresetDecisions,
|
|
56
|
+
): string | undefined {
|
|
57
|
+
if (selection.model === undefined || !preset.model) return undefined;
|
|
58
|
+
const model = surface.modelRegistry.find(selection.model.provider, selection.model.id);
|
|
59
|
+
if (model === undefined) {
|
|
60
|
+
return `profile "${selection.name}": declared model ${selection.model.provider}/${selection.model.id} not found`;
|
|
61
|
+
}
|
|
62
|
+
if (!surface.modelRegistry.hasConfiguredAuth(model)) {
|
|
63
|
+
return `profile "${selection.name}": model ${selection.model.provider}/${selection.model.id} has no configured auth`;
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Applies the selection. Validation runs first; on failure nothing is
|
|
69
|
+
* applied and the runtime keeps its previous state. */
|
|
70
|
+
export async function applySelection(input: {
|
|
71
|
+
selection: ResolvedSelection;
|
|
72
|
+
surface: ApplySurface;
|
|
73
|
+
preset: PresetDecisions;
|
|
74
|
+
}): Promise<ApplyResult> {
|
|
75
|
+
const { selection, surface, preset } = input;
|
|
76
|
+
const error = validateSelection(selection, surface, preset);
|
|
77
|
+
if (error !== undefined) {
|
|
78
|
+
return { applied: false, error, warnings: [], pendingTools: [] };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (selection.model !== undefined && preset.model) {
|
|
82
|
+
const model = surface.modelRegistry.find(selection.model.provider, selection.model.id);
|
|
83
|
+
const applied = await surface.setModel(model);
|
|
84
|
+
if (!applied) {
|
|
85
|
+
return {
|
|
86
|
+
applied: false,
|
|
87
|
+
error: `profile "${selection.name}": model ${selection.model.provider}/${selection.model.id} could not be activated`,
|
|
88
|
+
warnings: [],
|
|
89
|
+
pendingTools: [],
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
if (selection.model.thinkingLevel !== undefined && preset.thinking) {
|
|
93
|
+
surface.setThinkingLevel(selection.model.thinkingLevel);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (selection.tools !== undefined) {
|
|
98
|
+
surface.setActiveTools(selection.tools);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (selection.mcp !== undefined) {
|
|
102
|
+
surface.events.emit(MCP_ALLOWLIST_EVENT, {
|
|
103
|
+
version: MCP_ALLOWLIST_VERSION,
|
|
104
|
+
profile: selection.name,
|
|
105
|
+
servers: selection.mcp,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { applied: true, warnings: [], pendingTools: selection.pendingTools };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Retries pending tool literals against the live registry. Once every
|
|
113
|
+
* literal resolves the caller stops retrying, so a user's later native tool
|
|
114
|
+
* toggle is never clobbered. */
|
|
115
|
+
export function retryPendingTools(input: {
|
|
116
|
+
selection: ResolvedSelection;
|
|
117
|
+
surface: ApplySurface;
|
|
118
|
+
}): { pendingTools: string[]; active?: string[]; applied: boolean } {
|
|
119
|
+
const { selection, surface } = input;
|
|
120
|
+
if (selection.pendingTools.length === 0) {
|
|
121
|
+
return { pendingTools: [], applied: false };
|
|
122
|
+
}
|
|
123
|
+
const live = new Set(surface.getAllTools().map((tool) => tool.name));
|
|
124
|
+
const resolved = selection.pendingTools.filter((name) => live.has(name));
|
|
125
|
+
if (resolved.length === 0) {
|
|
126
|
+
return { pendingTools: selection.pendingTools, applied: false };
|
|
127
|
+
}
|
|
128
|
+
const active = [...new Set([...(selection.tools ?? []), ...resolved])];
|
|
129
|
+
surface.setActiveTools(active);
|
|
130
|
+
return { pendingTools: selection.pendingTools.filter((name) => !live.has(name)), active, applied: true };
|
|
131
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Overlay customize/reset orchestration.
|
|
3
|
+
*
|
|
4
|
+
* The overlay narrows the ACTIVE profile for this runtime only. It is
|
|
5
|
+
* written to the scope state file (never a catalog) as persistence and the
|
|
6
|
+
* status surface; the runtime effect flows through re-resolution with the
|
|
7
|
+
* overlay, exactly like a switch. Startup activation ignores stored
|
|
8
|
+
* overlays, so an overlay never outlives its runtime.
|
|
9
|
+
*
|
|
10
|
+
* Ordering invariants:
|
|
11
|
+
* - customize: activate with the candidate overlay FIRST (validation:
|
|
12
|
+
* unknown references fail here, before anything is written), then persist
|
|
13
|
+
* the overlay to state. A failed activation leaves the stored overlay
|
|
14
|
+
* untouched.
|
|
15
|
+
* - reset: activate without the overlay first, then delete it from state.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
import type { ProfileSource } from "../profile-catalog.ts";
|
|
20
|
+
import { RuntimeStateStore, stateDirFor, type RuntimeOverlay } from "../runtime-state-store.ts";
|
|
21
|
+
import { activateProfile, ActivationError, type ActivationDeps, type ActivationResult } from "./activate-profile.ts";
|
|
22
|
+
|
|
23
|
+
export interface OverlayTarget {
|
|
24
|
+
profile: { name: string; source: ProfileSource };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Applies a mutation to the active profile's overlay and re-activates. */
|
|
28
|
+
export async function customizeOverlay(
|
|
29
|
+
deps: ActivationDeps & OverlayTarget,
|
|
30
|
+
mutate: (overlay: RuntimeOverlay) => RuntimeOverlay,
|
|
31
|
+
): Promise<ActivationResult> {
|
|
32
|
+
const stateDir = stateDirFor(deps.profile.source, deps);
|
|
33
|
+
const stored = (await new RuntimeStateStore(stateDir).read()).overlay ?? {};
|
|
34
|
+
const candidate = mutate(stored);
|
|
35
|
+
// activateProfile re-resolves with the candidate and persists it
|
|
36
|
+
// (overlay in the state update) — validation fails before any write.
|
|
37
|
+
return activateProfile(deps.profile.name, deps, { overlay: candidate, persist: true });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Discards the overlay and reactivates the profile exactly as declared. */
|
|
41
|
+
export async function resetOverlay(deps: ActivationDeps & OverlayTarget): Promise<ActivationResult> {
|
|
42
|
+
return activateProfile(deps.profile.name, deps, { overlay: null, persist: true });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const CUSTOMIZE_USAGE =
|
|
46
|
+
"/profile customize disable|enable skill|mcp <name> · /profile customize tools [ref...]" as const;
|
|
47
|
+
|
|
48
|
+
const DISABLED_FIELDS = {
|
|
49
|
+
skill: "disabledSkills",
|
|
50
|
+
mcp: "disabledMcp",
|
|
51
|
+
} as const;
|
|
52
|
+
|
|
53
|
+
/** Parses `/profile customize` arguments into an overlay mutation.
|
|
54
|
+
* Grammar:
|
|
55
|
+
* customize disable skill|mcp <name>
|
|
56
|
+
* customize enable skill|mcp <name> (un-disable)
|
|
57
|
+
* customize tools <ref>... (replace tool refs)
|
|
58
|
+
* customize tools (clear the tools override)
|
|
59
|
+
*/
|
|
60
|
+
export function parseCustomizeArgs(args: string): (overlay: RuntimeOverlay) => RuntimeOverlay {
|
|
61
|
+
const [action, kind, ...rest] = args.trim().split(/\s+/).filter(Boolean);
|
|
62
|
+
|
|
63
|
+
if (action === "tools") {
|
|
64
|
+
const refs = [kind, ...rest].filter((entry): entry is string => entry !== undefined);
|
|
65
|
+
return (overlay) => {
|
|
66
|
+
const next = { ...overlay };
|
|
67
|
+
if (refs.length === 0) delete next.tools;
|
|
68
|
+
else next.tools = refs;
|
|
69
|
+
return next;
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const field = DISABLED_FIELDS[kind as keyof typeof DISABLED_FIELDS];
|
|
74
|
+
if (field === undefined || (action !== "disable" && action !== "enable") || rest.length !== 1) {
|
|
75
|
+
throw new ActivationError(`usage: ${CUSTOMIZE_USAGE}`);
|
|
76
|
+
}
|
|
77
|
+
const [name] = rest;
|
|
78
|
+
return (overlay) => {
|
|
79
|
+
const current = overlay[field] ?? [];
|
|
80
|
+
const nextList =
|
|
81
|
+
action === "disable" ? [...new Set([...current, name])] : current.filter((entry) => entry !== name);
|
|
82
|
+
const next = { ...overlay };
|
|
83
|
+
if (nextList.length === 0) delete next[field];
|
|
84
|
+
else next[field] = nextList;
|
|
85
|
+
return next;
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProfileListing: the `/profile list` and `/profile` selector data surface.
|
|
3
|
+
*
|
|
4
|
+
* Trust-gated exactly like activation: an untrusted project's profiles are
|
|
5
|
+
* invisible. The listing reports each visible profile with the source of
|
|
6
|
+
* the WINNING definition (a same-name project definition fully replaces the
|
|
7
|
+
* global one — the shadowed global entry is reported as such).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { ProfileCatalog, type ProfileSource } from "../profile-catalog.ts";
|
|
11
|
+
|
|
12
|
+
export interface ProfileListEntry {
|
|
13
|
+
name: string;
|
|
14
|
+
/** The source of the winning definition. */
|
|
15
|
+
source: ProfileSource;
|
|
16
|
+
label?: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
/** True when a global definition of the same name is shadowed by the
|
|
19
|
+
* project one. */
|
|
20
|
+
shadowsGlobal: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function listProfiles(input: {
|
|
24
|
+
realAgentDir: string;
|
|
25
|
+
cwd: string;
|
|
26
|
+
projectTrusted: boolean;
|
|
27
|
+
}): Promise<{ entries: ProfileListEntry[]; warnings: string[] }> {
|
|
28
|
+
const catalog = await ProfileCatalog.load(input.realAgentDir, {
|
|
29
|
+
projectDir: input.projectTrusted ? input.cwd : undefined,
|
|
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
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function formatProfileList(entries: ProfileListEntry[], activeProfile?: string): string {
|
|
44
|
+
if (entries.length === 0) {
|
|
45
|
+
return "no profiles found";
|
|
46
|
+
}
|
|
47
|
+
return entries
|
|
48
|
+
.map((entry) => {
|
|
49
|
+
const active = entry.name === activeProfile ? " ← active" : "";
|
|
50
|
+
const shadowed = entry.shadowsGlobal ? " (shadows global)" : "";
|
|
51
|
+
const label = entry.label ?? entry.description;
|
|
52
|
+
return `${entry.name} [${entry.source}]${shadowed}${label !== undefined ? ` — ${label}` : ""}${active}`;
|
|
53
|
+
})
|
|
54
|
+
.join("\n");
|
|
55
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* McpToggle: persistent, profile-scoped `/mcp enable|disable` (ticket 10).
|
|
3
|
+
*
|
|
4
|
+
* The profile's `mcp` array in its OWNING catalog is the profile-scoped
|
|
5
|
+
* state store (ticket 04 established that pi-mcp-adapter@2.33.0 has no
|
|
6
|
+
* allowlist/profile-state API — ADR-0002's assumed store does not exist;
|
|
7
|
+
* pi-profile-switch owns the contract). The runtime effect is the caller's
|
|
8
|
+
* re-activation of the active profile, which republishes the allowlist over
|
|
9
|
+
* the coordination channel.
|
|
10
|
+
*
|
|
11
|
+
* Invariants:
|
|
12
|
+
* - Enable accepts only adapter-discovered names (fail fast on typos);
|
|
13
|
+
* disable also removes names the adapter no longer reports (stale
|
|
14
|
+
* cleanup).
|
|
15
|
+
* - The built-in default profile has no catalog entry — toggling it is a
|
|
16
|
+
* clear error, not a synthetic write.
|
|
17
|
+
* - Only the ACTIVE profile's array changes; other profiles and the
|
|
18
|
+
* adapter's own configuration (global mcp.json, project .pi/mcp.json)
|
|
19
|
+
* are never modified.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { discoverAdapterServerNames } from "../mcp-config.ts";
|
|
23
|
+
import { CatalogError, DEFAULT_PROFILE_NAME, type ProfileDefinition, type ProfileSource } from "../profile-catalog.ts";
|
|
24
|
+
import { catalogStore, readCatalogScope, type CatalogScope } from "./profile-crud.ts";
|
|
25
|
+
|
|
26
|
+
export async function setMcpServerEnabled(
|
|
27
|
+
input: {
|
|
28
|
+
realAgentDir: string;
|
|
29
|
+
cwd: string;
|
|
30
|
+
projectTrusted: boolean;
|
|
31
|
+
profile: { name: string; source: ProfileSource };
|
|
32
|
+
},
|
|
33
|
+
server: string,
|
|
34
|
+
enabled: boolean,
|
|
35
|
+
): Promise<{ mcp: string[]; changed: boolean }> {
|
|
36
|
+
const { name, source } = input.profile;
|
|
37
|
+
if (name === DEFAULT_PROFILE_NAME || source === "builtin") {
|
|
38
|
+
throw new CatalogError(
|
|
39
|
+
`the built-in default profile has no catalog entry — create a named profile (/profile create) to toggle MCP servers`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
if (source !== "global" && source !== "project") {
|
|
43
|
+
throw new CatalogError(`profile "${name}" has no writable owning catalog (source: ${source})`);
|
|
44
|
+
}
|
|
45
|
+
const scope: CatalogScope = source;
|
|
46
|
+
|
|
47
|
+
if (scope === "project" && !input.projectTrusted) {
|
|
48
|
+
throw new CatalogError(`project catalog is unavailable: ${input.cwd} is not trusted`);
|
|
49
|
+
}
|
|
50
|
+
const discovered = await discoverAdapterServerNames(input.realAgentDir, input.projectTrusted ? input.cwd : undefined);
|
|
51
|
+
if (enabled && !discovered.includes(server)) {
|
|
52
|
+
throw new CatalogError(
|
|
53
|
+
`unknown MCP server "${server}" — adapter discovered: [${discovered.join(", ") || "(none)"}]`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const definitions = await readCatalogScope(input, scope);
|
|
58
|
+
const definition = definitions.get(input.profile.name);
|
|
59
|
+
if (definition === undefined) {
|
|
60
|
+
throw new CatalogError(`profile "${input.profile.name}" not found in the ${scope} catalog`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const current = definition.mcp ?? [];
|
|
64
|
+
if (enabled === current.includes(server)) {
|
|
65
|
+
return { mcp: current, changed: false }; // already in the requested state
|
|
66
|
+
}
|
|
67
|
+
const next = enabled ? [...current, server] : current.filter((name) => name !== server);
|
|
68
|
+
// Drop the key entirely when empty (exactOptionalPropertyTypes; a
|
|
69
|
+
// written `mcp: undefined` would also misrepresent the definition).
|
|
70
|
+
const rest = { ...definition };
|
|
71
|
+
delete rest.mcp;
|
|
72
|
+
const updated: ProfileDefinition = next.length > 0 ? { ...rest, mcp: next } : rest;
|
|
73
|
+
await catalogStore(input, scope).upsert(input.profile.name, updated);
|
|
74
|
+
return { mcp: next, changed: true };
|
|
75
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProfileCrud: `/profile create|edit|delete|duplicate` semantics (ticket 09)
|
|
3
|
+
* on top of ProfileCatalogStore.
|
|
4
|
+
*
|
|
5
|
+
* Invariants:
|
|
6
|
+
* - Trust-gated exactly like activation: an untrusted project's catalog is
|
|
7
|
+
* never read or written.
|
|
8
|
+
* - Deleting the ACTIVE profile requires a replacement up front — the
|
|
9
|
+
* session must land somewhere defined; the caller switches to it.
|
|
10
|
+
* - Deleting one scope's record while the other scope keeps the name
|
|
11
|
+
* reveals that definition (merged-catalog semantics; nothing extra to
|
|
12
|
+
* do beyond reloading).
|
|
13
|
+
* - Duplicating copies the COMPLETE definition under a new name — the
|
|
14
|
+
* only variant mechanism (no inheritance).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
|
|
19
|
+
import { CatalogError, DEFAULT_PROFILE_NAME, type ProfileDefinition } from "../profile-catalog.ts";
|
|
20
|
+
import { ProfileCatalogStore } from "../profile-catalog-store.ts";
|
|
21
|
+
|
|
22
|
+
export type CatalogScope = "global" | "project";
|
|
23
|
+
|
|
24
|
+
/** The caller's trust decision (`ctx.isProjectTrusted()`) plus the two
|
|
25
|
+
* directories scope files live in. */
|
|
26
|
+
export interface CatalogInput {
|
|
27
|
+
realAgentDir: string;
|
|
28
|
+
cwd: string;
|
|
29
|
+
projectTrusted: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The store for one scope's catalog file — the ONLY place scope-file
|
|
33
|
+
* paths are constructed. Callers must still trust-gate project access
|
|
34
|
+
* (`requireScope` / `readCatalogScope`). */
|
|
35
|
+
export function catalogStore(input: CatalogInput, scope: CatalogScope): ProfileCatalogStore {
|
|
36
|
+
return new ProfileCatalogStore(
|
|
37
|
+
scope === "global" ? path.join(input.realAgentDir, "profiles.json") : path.join(input.cwd, ".pi", "profiles.json"),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function requireScope(input: CatalogInput, scope: CatalogScope): void {
|
|
42
|
+
if (scope === "project" && !input.projectTrusted) {
|
|
43
|
+
throw new CatalogError(`project catalog is unavailable: ${input.cwd} is not trusted`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Reads one scope's catalog with the trust gate applied — project reads
|
|
48
|
+
* return empty when untrusted (never touching the file). */
|
|
49
|
+
export async function readCatalogScope(
|
|
50
|
+
input: CatalogInput,
|
|
51
|
+
scope: CatalogScope,
|
|
52
|
+
): Promise<Map<string, ProfileDefinition>> {
|
|
53
|
+
if (scope === "project" && !input.projectTrusted) {
|
|
54
|
+
return new Map();
|
|
55
|
+
}
|
|
56
|
+
return catalogStore(input, scope).readDefinitions();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Creates a complete definition in the chosen scope. */
|
|
60
|
+
export async function createProfile(
|
|
61
|
+
input: CatalogInput,
|
|
62
|
+
scope: CatalogScope,
|
|
63
|
+
name: string,
|
|
64
|
+
definition: ProfileDefinition,
|
|
65
|
+
): Promise<void> {
|
|
66
|
+
requireScope(input, scope);
|
|
67
|
+
const store = catalogStore(input, scope);
|
|
68
|
+
if ((await store.readDefinitions()).has(name)) {
|
|
69
|
+
throw new CatalogError(`profile "${name}" already exists in the ${scope} catalog`);
|
|
70
|
+
}
|
|
71
|
+
await store.upsert(name, definition);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Replaces a complete definition; the caller reloads iff it is active. */
|
|
75
|
+
export async function editProfile(
|
|
76
|
+
input: CatalogInput,
|
|
77
|
+
scope: CatalogScope,
|
|
78
|
+
name: string,
|
|
79
|
+
definition: ProfileDefinition,
|
|
80
|
+
): Promise<void> {
|
|
81
|
+
requireScope(input, scope);
|
|
82
|
+
if (name === DEFAULT_PROFILE_NAME) {
|
|
83
|
+
throw new CatalogError(`"${DEFAULT_PROFILE_NAME}" is built in and cannot be edited`);
|
|
84
|
+
}
|
|
85
|
+
const store = catalogStore(input, scope);
|
|
86
|
+
if (!(await store.readDefinitions()).has(name)) {
|
|
87
|
+
throw new CatalogError(`profile "${name}" not found in the ${scope} catalog`);
|
|
88
|
+
}
|
|
89
|
+
await store.upsert(name, definition);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Deletes a profile from the chosen scope. Deleting the active profile
|
|
94
|
+
* requires `replacement` (validated for existence in the remaining merged
|
|
95
|
+
* catalog by the caller's switch); the built-in default can never be
|
|
96
|
+
* deleted.
|
|
97
|
+
*/
|
|
98
|
+
export async function deleteProfile(
|
|
99
|
+
input: CatalogInput,
|
|
100
|
+
scope: CatalogScope,
|
|
101
|
+
name: string,
|
|
102
|
+
options: { activeProfile?: string; replacement?: string },
|
|
103
|
+
): Promise<void> {
|
|
104
|
+
requireScope(input, scope);
|
|
105
|
+
if (name === DEFAULT_PROFILE_NAME) {
|
|
106
|
+
throw new CatalogError(`"${DEFAULT_PROFILE_NAME}" is built in and cannot be deleted`);
|
|
107
|
+
}
|
|
108
|
+
if (options.activeProfile === name && options.replacement === undefined) {
|
|
109
|
+
throw new CatalogError(`profile "${name}" is active — choose a replacement profile first`);
|
|
110
|
+
}
|
|
111
|
+
await catalogStore(input, scope).remove(name);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Copies a complete definition under a new, unused name. */
|
|
115
|
+
export async function duplicateProfile(
|
|
116
|
+
input: CatalogInput,
|
|
117
|
+
scope: CatalogScope,
|
|
118
|
+
sourceName: string,
|
|
119
|
+
newName: string,
|
|
120
|
+
): Promise<void> {
|
|
121
|
+
requireScope(input, scope);
|
|
122
|
+
const store = catalogStore(input, scope);
|
|
123
|
+
const definitions = await store.readDefinitions();
|
|
124
|
+
const source = definitions.get(sourceName);
|
|
125
|
+
if (source === undefined) {
|
|
126
|
+
throw new CatalogError(`profile "${sourceName}" not found in the ${scope} catalog`);
|
|
127
|
+
}
|
|
128
|
+
if (definitions.has(newName)) {
|
|
129
|
+
throw new CatalogError(`profile "${newName}" already exists in the ${scope} catalog`);
|
|
130
|
+
}
|
|
131
|
+
await store.upsert(newName, { ...source });
|
|
132
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProfileWizard: the interactive create/edit/duplicate dialog for
|
|
3
|
+
* `/profile create|edit|delete|duplicate` (ticket 09), UI-injected for
|
|
4
|
+
* testability.
|
|
5
|
+
*
|
|
6
|
+
* Definitions are complete and self-contained — the wizard edits whole
|
|
7
|
+
* fields, never inheritance or merge syntax. On edit, an empty answer
|
|
8
|
+
* keeps the current value (prefill via placeholder); there is no
|
|
9
|
+
* field-clearing gesture (delete + create instead). Any cancelled step
|
|
10
|
+
* aborts the wizard — nothing is written.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ProfileDefinition } from "../profile-catalog.ts";
|
|
14
|
+
import type { CatalogScope } from "./profile-crud.ts";
|
|
15
|
+
|
|
16
|
+
export interface ProfileWizardUi {
|
|
17
|
+
select(title: string, options: string[]): Promise<string | undefined>;
|
|
18
|
+
input(title: string, placeholder?: string): Promise<string | undefined>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ProfileWizardResult {
|
|
22
|
+
scope: CatalogScope;
|
|
23
|
+
name: string;
|
|
24
|
+
definition: ProfileDefinition;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface ExistingProfile {
|
|
28
|
+
name: string;
|
|
29
|
+
source: CatalogScope;
|
|
30
|
+
definition: ProfileDefinition;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseList(raw: string): string[] {
|
|
34
|
+
return raw
|
|
35
|
+
.split(",")
|
|
36
|
+
.map((entry) => entry.trim())
|
|
37
|
+
.filter((entry) => entry.length > 0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** "provider/id[/thinkingLevel]" → ProfileModel; empty/undefined → none. */
|
|
41
|
+
function parseModel(raw: string): ProfileDefinition["model"] | undefined {
|
|
42
|
+
const trimmed = raw.trim();
|
|
43
|
+
if (trimmed.length === 0) return undefined;
|
|
44
|
+
const [provider, id, thinkingLevel] = trimmed.split("/").map((part) => part.trim());
|
|
45
|
+
if (provider === undefined || provider.length === 0 || id === undefined || id.length === 0) {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
return thinkingLevel !== undefined && thinkingLevel.length > 0
|
|
49
|
+
? { provider, id, thinkingLevel }
|
|
50
|
+
: { provider, id };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function captureDefinition(
|
|
54
|
+
ui: ProfileWizardUi,
|
|
55
|
+
existing?: ProfileDefinition,
|
|
56
|
+
): Promise<ProfileDefinition | undefined> {
|
|
57
|
+
const definition: ProfileDefinition = {};
|
|
58
|
+
|
|
59
|
+
const label = await ui.input("label (optional; empty keeps current)", existing?.label);
|
|
60
|
+
if (label === undefined) return undefined;
|
|
61
|
+
if (label.trim().length > 0) definition.label = label.trim();
|
|
62
|
+
else if (existing?.label !== undefined) definition.label = existing.label;
|
|
63
|
+
|
|
64
|
+
const description = await ui.input("description (optional; empty keeps current)", existing?.description);
|
|
65
|
+
if (description === undefined) return undefined;
|
|
66
|
+
if (description.trim().length > 0) definition.description = description.trim();
|
|
67
|
+
else if (existing?.description !== undefined) definition.description = existing.description;
|
|
68
|
+
|
|
69
|
+
const listFields = [
|
|
70
|
+
["skills", "skills (comma-separated names or globs, empty = all visible)"],
|
|
71
|
+
["mcp", "mcp servers (names or globs, empty = none)"],
|
|
72
|
+
["tools", "tools (names or globs, empty = pi default set)"],
|
|
73
|
+
] as const;
|
|
74
|
+
for (const [field, prompt] of listFields) {
|
|
75
|
+
const current = existing?.[field]?.join(", ");
|
|
76
|
+
const raw = await ui.input(prompt, current);
|
|
77
|
+
if (raw === undefined) return undefined;
|
|
78
|
+
const parsed = parseList(raw);
|
|
79
|
+
// Empty keeps the existing value on edit; on create it omits the field.
|
|
80
|
+
if (parsed.length > 0) {
|
|
81
|
+
definition[field] = parsed;
|
|
82
|
+
} else if (current !== undefined) {
|
|
83
|
+
definition[field] = existing?.[field];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const instructions = await ui.input(
|
|
88
|
+
"instructions (appended to the system prompt; empty keeps current)",
|
|
89
|
+
existing?.instructions,
|
|
90
|
+
);
|
|
91
|
+
if (instructions === undefined) return undefined;
|
|
92
|
+
if (instructions.trim().length > 0) definition.instructions = instructions;
|
|
93
|
+
else if (existing?.instructions !== undefined) definition.instructions = existing.instructions;
|
|
94
|
+
|
|
95
|
+
const currentModel =
|
|
96
|
+
existing?.model !== undefined
|
|
97
|
+
? `${existing.model.provider}/${existing.model.id}${existing.model.thinkingLevel !== undefined ? `/${existing.model.thinkingLevel}` : ""}`
|
|
98
|
+
: undefined;
|
|
99
|
+
const modelRaw = await ui.input("model provider/id[/thinking] (empty = none)", currentModel);
|
|
100
|
+
if (modelRaw === undefined) return undefined;
|
|
101
|
+
const model = parseModel(modelRaw);
|
|
102
|
+
if (model !== undefined) {
|
|
103
|
+
definition.model = model;
|
|
104
|
+
} else if (existing?.model !== undefined && modelRaw.trim().length === 0) {
|
|
105
|
+
definition.model = existing.model;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return definition;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Create: scope first (project only when trusted), then name, then fields. */
|
|
112
|
+
export async function runProfileCreateWizard(
|
|
113
|
+
ui: ProfileWizardUi,
|
|
114
|
+
input: { projectTrusted: boolean },
|
|
115
|
+
): Promise<ProfileWizardResult | undefined> {
|
|
116
|
+
const scopeOptions = input.projectTrusted ? ["global", "project"] : ["global"];
|
|
117
|
+
// A single available scope needs no dialog.
|
|
118
|
+
const scope = scopeOptions.length === 1 ? scopeOptions[0] : await ui.select("write to which catalog?", scopeOptions);
|
|
119
|
+
if (scope === undefined) return undefined;
|
|
120
|
+
|
|
121
|
+
const name = await ui.input("profile name");
|
|
122
|
+
if (name === undefined || name.trim().length === 0) return undefined;
|
|
123
|
+
|
|
124
|
+
const definition = await captureDefinition(ui);
|
|
125
|
+
if (definition === undefined) return undefined;
|
|
126
|
+
|
|
127
|
+
return { scope: scope as CatalogScope, name: name.trim(), definition };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Edit: fields prefilled from the existing complete definition. */
|
|
131
|
+
export async function runProfileEditWizard(
|
|
132
|
+
ui: ProfileWizardUi,
|
|
133
|
+
input: { existing: ExistingProfile },
|
|
134
|
+
): Promise<ProfileWizardResult | undefined> {
|
|
135
|
+
const definition = await captureDefinition(ui, input.existing.definition);
|
|
136
|
+
if (definition === undefined) return undefined;
|
|
137
|
+
return { scope: input.existing.source, name: input.existing.name, definition };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Duplicate: pick a source, name the copy — the only variant mechanism. */
|
|
141
|
+
export async function runProfileDuplicateWizard(
|
|
142
|
+
ui: ProfileWizardUi,
|
|
143
|
+
input: { candidates: ExistingProfile[] },
|
|
144
|
+
): Promise<{ scope: CatalogScope; sourceName: string; newName: string } | undefined> {
|
|
145
|
+
if (input.candidates.length === 0) return undefined;
|
|
146
|
+
const options = input.candidates.map(
|
|
147
|
+
(candidate) => `${candidate.name} [${candidate.source}]${candidate.definition.label !== undefined ? ` — ${candidate.definition.label}` : ""}`,
|
|
148
|
+
);
|
|
149
|
+
const chosen = await ui.select("duplicate which profile?", options);
|
|
150
|
+
if (chosen === undefined) return undefined;
|
|
151
|
+
const source = input.candidates.find((candidate) => chosen.startsWith(`${candidate.name} [`));
|
|
152
|
+
if (source === undefined) return undefined;
|
|
153
|
+
|
|
154
|
+
const newName = await ui.input("new profile name");
|
|
155
|
+
if (newName === undefined || newName.trim().length === 0) return undefined;
|
|
156
|
+
|
|
157
|
+
return { scope: source.source, sourceName: source.name, newName: newName.trim() };
|
|
158
|
+
}
|