pi-profile-switch 0.9.1 → 0.10.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.
@@ -76,11 +76,14 @@ export function resolveProjectTrust(input: ProjectTrustInput): boolean {
76
76
  return input.userDefaultProjectTrust === "always";
77
77
  }
78
78
 
79
- /** pi-profile's own project files are trust-requiring even though Pi's
80
- * native list doesn't know them: a committed catalog/state file would
81
- * otherwise inject profile definitions (and instructions) unguarded. */
82
- function hasPiProfileProjectFiles(cwd: string): boolean {
83
- return ["profiles", "pi-profile-state.json"].some((name) =>
84
- existsSync(path.join(cwd, ".pi", name)),
85
- );
79
+ /** pi-profile's own project files, relative to the project's `.pi`
80
+ * directory. They are trust-requiring even though Pi's native list doesn't
81
+ * know them: a committed catalog/state file would otherwise inject profile
82
+ * definitions (and instructions) unguarded. Exported so the launcher's
83
+ * untrusted-project diagnostic names exactly the files the trust gate
84
+ * skips — the two must never drift apart. */
85
+ export const PI_PROFILE_PROJECT_FILES = ["profiles", "pi-profile-state.json"] as const;
86
+
87
+ export function hasPiProfileProjectFiles(cwd: string): boolean {
88
+ return PI_PROFILE_PROJECT_FILES.some((name) => existsSync(path.join(cwd, ".pi", name)));
86
89
  }
@@ -32,14 +32,13 @@ export interface RuntimeOverlay {
32
32
  disabledSkills?: string[];
33
33
  disabledExtensions?: string[];
34
34
  disabledMcps?: string[];
35
- /** Replaces the profile's tool references when set. */
36
- tools?: string[];
35
+ disabledTools?: string[];
37
36
  }
38
37
 
39
38
  function parseOverlay(value: unknown): RuntimeOverlay | undefined {
40
39
  if (!isRecord(value)) return undefined;
41
40
  const overlay: RuntimeOverlay = {};
42
- for (const key of ["disabledSkills", "disabledExtensions", "disabledMcps", "tools"] as const) {
41
+ for (const key of ["disabledSkills", "disabledExtensions", "disabledMcps", "disabledTools"] as const) {
43
42
  const list = value[key];
44
43
  if (Array.isArray(list) && list.every((entry) => typeof entry === "string")) {
45
44
  overlay[key] = list;
@@ -77,7 +76,7 @@ export class RuntimeStateStore {
77
76
  }
78
77
 
79
78
  /** Read-modify-write merge. A field set to `undefined` is deleted; absent
80
- * fields keep their stored value. Used by the switch/customize paths so
79
+ * fields keep their stored value. Used by the switch/overlay paths so
81
80
  * one concern (selection, anchor, overlay) never clobbers another. */
82
81
  async update(patch: Partial<RuntimeState>): Promise<RuntimeState> {
83
82
  const current = await this.read();
@@ -401,6 +401,7 @@ export async function writeRuntimeFiles(
401
401
  agentDir: options.agentDir,
402
402
  ...(plan.tools !== undefined ? { tools: plan.tools } : {}),
403
403
  ...(plan.toolReferences !== undefined ? { toolReferences: plan.toolReferences } : {}),
404
+ ...(plan.disabledTools !== undefined ? { disabledTools: plan.disabledTools } : {}),
404
405
  ...(plan.mcps !== undefined ? { mcps: plan.mcps } : {}),
405
406
  // The resolved sets feed /profile status (absolute paths) and the
406
407
  // glob-delta diff against the previous activation.
@@ -17,25 +17,25 @@ import { getAgentDir } from "@earendil-works/pi-coding-agent";
17
17
  import { getGlobalProfilesDir } from "./workspace.ts";
18
18
 
19
19
  export interface StarterAssetFileResult {
20
- /** 目标文件绝对路径 */
20
+ /** Absolute path of the target file */
21
21
  path: string;
22
- /** 本次调用是否发生了写入 */
22
+ /** Whether this call performed a write */
23
23
  written: boolean;
24
24
  }
25
25
 
26
26
  export interface StarterAssetsResult {
27
27
  profile: StarterAssetFileResult;
28
28
  skill: StarterAssetFileResult;
29
- /** 人类可读的降级警告;为空表示全部成功或无操作 */
29
+ /** Human-readable degradation warnings; empty means all succeeded or no-op */
30
30
  warnings: string[];
31
31
  }
32
32
 
33
33
  export interface EnsureStarterAssetsOptions {
34
- /** 默认 getGlobalProfilesDir();测试注入 */
34
+ /** Defaults to getGlobalProfilesDir(); injected by tests */
35
35
  globalProfilesDir?: string;
36
- /** 默认 Pi 的 getAgentDir();测试注入 */
36
+ /** Defaults to Pi's getAgentDir(); injected by tests */
37
37
  agentDir?: string;
38
- /** 默认由 import.meta.url 定位包根;测试注入 */
38
+ /** Defaults to locating the package root from import.meta.url; injected by tests */
39
39
  packageRoot?: string;
40
40
  }
41
41
 
@@ -15,6 +15,11 @@
15
15
  * `defaultTools` provides only the boot baseline for built-ins.
16
16
  * Literals that no tool provides are dropped with a warning — Pi
17
17
  * silently ignores unknown names, so the warning is the only signal.
18
+ * When the plan carries overlay disabled tool entries, the active set
19
+ * is the base expansion — the profile references, or the whole live
20
+ * registry when the profile declares none — minus the entries' live
21
+ * matches, re-expanded at this moment so registry drift re-applies
22
+ * correctly after reload.
18
23
  * 2. persistence: when the plan is marked `persistSelection` and this is a
19
24
  * reload, save the selection (activeProfile = plan.profile) to the
20
25
  * profile's scope state file. Launch-transient selections never write.
@@ -40,6 +45,9 @@ export interface LaunchPlanFile {
40
45
  agentDir?: string;
41
46
  tools?: string[];
42
47
  toolReferences?: string[];
48
+ /** Overlay tool disable entries (names or globs, verbatim); subtracted
49
+ * from the base expansion at session start. */
50
+ disabledTools?: string[];
43
51
  mcps?: string[];
44
52
  switchedFrom?: string;
45
53
  persistSelection?: boolean;
@@ -95,22 +103,42 @@ export async function applyLaunchPlan(input: {
95
103
  const warnings: string[] = [];
96
104
 
97
105
  // --- tools ---
98
- if (plan.toolReferences !== undefined) {
106
+ if (plan.toolReferences !== undefined || plan.disabledTools !== undefined) {
99
107
  const liveNames = surface.getAllTools().map((tool) => tool.name);
100
- const { expanded, droppedLiterals } = expandToolReferences(plan.toolReferences, liveNames);
101
- if (droppedLiterals.length > 0) {
102
- warnings.push(
103
- `profile "${plan.profile}": tools ${droppedLiterals.map((name) => JSON.stringify(name)).join(", ")} match nothing in Pi's live registry`,
108
+ let active: string[];
109
+ if (plan.toolReferences !== undefined) {
110
+ const { expanded, droppedLiterals } = expandToolReferences(plan.toolReferences, liveNames);
111
+ if (droppedLiterals.length > 0) {
112
+ warnings.push(
113
+ `profile "${plan.profile}": tools ${droppedLiterals.map((name) => JSON.stringify(name)).join(", ")} match nothing in Pi's live registry`,
114
+ );
115
+ }
116
+ active = expanded;
117
+ } else {
118
+ // No declared tools: the base is the whole live registry.
119
+ active = [...liveNames];
120
+ }
121
+ if (plan.disabledTools !== undefined && plan.disabledTools.length > 0) {
122
+ const { expanded: disabled, droppedLiterals: vanishedEntries } = expandToolReferences(
123
+ plan.disabledTools,
124
+ liveNames,
104
125
  );
126
+ if (vanishedEntries.length > 0) {
127
+ warnings.push(
128
+ `profile "${plan.profile}": overlay tool entries ${vanishedEntries.map((name) => JSON.stringify(name)).join(", ")} match nothing in Pi's live registry`,
129
+ );
130
+ }
131
+ const disabledSet = new Set(disabled);
132
+ active = active.filter((name) => !disabledSet.has(name));
105
133
  }
106
- surface.setActiveTools(expanded);
134
+ surface.setActiveTools(active);
107
135
  }
108
136
 
109
137
  // --- persistence (post-reload only) ---
110
138
  if (plan.persistSelection === true && input.reason === "reload" && plan.agentDir !== undefined) {
111
139
  const stateDir = plan.source === "project" ? path.join(input.cwd, ".pi") : getGlobalStateDir(plan.agentDir);
112
- // Merge: the overlay belongs to customize/reset, not to this write.
113
- // A switch (clearOverlay) explicitly drops it.
140
+ // Merge: the overlay belongs to the overlay commands, not to this
141
+ // write. A switch (clearOverlay) explicitly drops it.
114
142
  await new RuntimeStateStore(stateDir).update({
115
143
  activeProfile: plan.profile,
116
144
  ...(plan.clearOverlay === true ? { overlay: undefined } : {}),
@@ -1,6 +1,6 @@
1
1
  /**
2
- * ProfileListing: the `/profile list` and `/profile` selector data surface
3
- * (ticket 07).
2
+ * ProfileListing: the profile selector's and the degraded bare `/profile`
3
+ * list's data surface (ticket 07).
4
4
  *
5
5
  * Trust-gated exactly like activation: an untrusted project's profiles are
6
6
  * invisible. The listing reports each visible profile with the source of
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Overlay customize/reset orchestration (ticket 06).
2
+ * Overlay command orchestration (ticket 06; module renamed with the
3
+ * `/profile overlay` subcommand).
3
4
  *
4
5
  * The overlay narrows the ACTIVE profile for this runtime only. It is
5
6
  * written to the scope state file (never a catalog) purely as the
@@ -8,12 +9,12 @@
8
9
  * ignores stored overlays, so an overlay never outlives its runtime.
9
10
  *
10
11
  * Ordering invariants:
11
- * - customize: re-resolve with the candidate overlay FIRST (validation:
12
- * unknown references fail here, before anything
13
- * is written), then switch+reload, then persist the overlay to state. A
14
- * failed switch leaves the stored overlay untouched, consistent with the
15
- * rolled-back runtime.
16
- * - reset: switch+reload WITHOUT the overlay first, then delete it from
12
+ * - disable|enable: re-resolve with the candidate overlay FIRST
13
+ * (validation: unknown references and enable misses fail here, before
14
+ * anything is written), then switch+reload, then persist the overlay to
15
+ * state. A failed switch leaves the stored overlay untouched, consistent
16
+ * with the rolled-back runtime.
17
+ * - clear: switch+reload WITHOUT the overlay first, then delete it from
17
18
  * state. If the switch rolls back, the stored overlay still matches the
18
19
  * restored runtime.
19
20
  */
@@ -31,7 +32,7 @@ async function currentStateTarget(
31
32
  ): Promise<{ profile: string; store: RuntimeStateStore; state: RuntimeState }> {
32
33
  const plan = await readLaunchPlanFile(deps.runtimeDir);
33
34
  if (plan === undefined) {
34
- throw new SwitchError("no active profile — nothing to customize");
35
+ throw new SwitchError("no active profile — nothing to overlay");
35
36
  }
36
37
  const stateDir = plan.source === "project" ? path.join(deps.cwd, ".pi") : getGlobalStateDir(plan.agentDir);
37
38
  if (stateDir === undefined) {
@@ -42,7 +43,7 @@ async function currentStateTarget(
42
43
  }
43
44
 
44
45
  /** Applies a mutation to the active profile's overlay and re-activates. */
45
- export async function customizeOverlay(
46
+ export async function applyOverlayMutation(
46
47
  deps: SwitchDeps,
47
48
  mutate: (overlay: RuntimeOverlay) => RuntimeOverlay,
48
49
  ): Promise<SwitchResult> {
@@ -59,7 +60,7 @@ export async function customizeOverlay(
59
60
  }
60
61
 
61
62
  /** Discards the overlay and reactivates the profile exactly as declared. */
62
- export async function resetOverlay(deps: SwitchDeps): Promise<SwitchResult> {
63
+ export async function clearOverlay(deps: SwitchDeps): Promise<SwitchResult> {
63
64
  const { profile, store } = await currentStateTarget(deps);
64
65
 
65
66
  // overlay: null — explicit "none"; without it the reload path would
@@ -70,47 +71,78 @@ export async function resetOverlay(deps: SwitchDeps): Promise<SwitchResult> {
70
71
  return result;
71
72
  }
72
73
 
73
- export const CUSTOMIZE_USAGE =
74
- "/profile customize disable|enable skill|extension|mcp <name> · /profile customize tools [ref...]" as const;
74
+ export const OVERLAY_USAGE =
75
+ "/profile overlay disable|enable skill|extension|mcp|tool <name-or-glob> · /profile overlay clear" as const;
76
+
77
+ /** The parsed form of `/profile overlay` arguments: either a mutation to
78
+ * validate and apply against the stored overlay, or `clear`. */
79
+ export type OverlayCommand =
80
+ | { kind: "mutate"; mutate: (overlay: RuntimeOverlay) => RuntimeOverlay }
81
+ | { kind: "clear" };
75
82
 
76
83
  const DISABLED_FIELDS = {
77
84
  skill: "disabledSkills",
78
85
  extension: "disabledExtensions",
79
86
  mcp: "disabledMcps",
87
+ tool: "disabledTools",
80
88
  } as const;
81
89
 
82
- /** Parses `/profile customize` arguments into an overlay mutation.
90
+ /** Parses `/profile overlay` arguments into an overlay command.
83
91
  * Grammar:
84
- * customize disable skill|extension|mcp <name>
85
- * customize enable skill|extension|mcp <name> (un-disable)
86
- * customize tools <ref>... (replace tool refs)
87
- * customize tools (clear the tools override)
92
+ * overlay disable skill|extension|mcp|tool <name-or-glob>
93
+ * overlay enable skill|extension|mcp|tool <name-or-glob> (remove a stored entry)
94
+ * overlay clear (discard the overlay)
95
+ *
96
+ * One uniform grammar for all four resource kinds (the tools-only
97
+ * replace-form is gone — tools are disabled like the other kinds). The
98
+ * removed `tools` action falls through to the usage error above.
99
+ *
100
+ * `enable` removes a stored entry by exact string match — no hole-punching
101
+ * through globs — and fails when nothing equals the given name, listing the
102
+ * current entries of that kind.
88
103
  */
89
- export function parseCustomizeArgs(args: string): (overlay: RuntimeOverlay) => RuntimeOverlay {
104
+ export function parseOverlayArgs(args: string): OverlayCommand {
90
105
  const [action, kind, ...rest] = args.trim().split(/\s+/).filter(Boolean);
91
106
 
92
- if (action === "tools") {
93
- const refs = [kind, ...rest].filter((entry): entry is string => entry !== undefined);
94
- return (overlay) => {
95
- const next = { ...overlay };
96
- if (refs.length === 0) delete next.tools;
97
- else next.tools = refs;
98
- return next;
99
- };
107
+ if (action === "clear") {
108
+ if (rest.length > 0 || kind !== undefined) throw new SwitchError(`usage: ${OVERLAY_USAGE}`);
109
+ return { kind: "clear" };
100
110
  }
101
111
 
102
112
  const field = DISABLED_FIELDS[kind as keyof typeof DISABLED_FIELDS];
103
113
  if ((action !== "disable" && action !== "enable") || field === undefined || rest.length !== 1) {
104
- throw new SwitchError(`usage: ${CUSTOMIZE_USAGE}`);
114
+ throw new SwitchError(`usage: ${OVERLAY_USAGE}`);
105
115
  }
106
116
  const [name] = rest;
107
- return (overlay) => {
108
- const current = overlay[field] ?? [];
109
- const nextList =
110
- action === "disable" ? [...new Set([...current, name])] : current.filter((entry) => entry !== name);
111
- const next = { ...overlay };
112
- if (nextList.length === 0) delete next[field];
113
- else next[field] = nextList;
114
- return next;
117
+
118
+ if (action === "disable") {
119
+ return {
120
+ kind: "mutate",
121
+ mutate: (overlay) => {
122
+ const current = overlay[field] ?? [];
123
+ // `name` is always appended, so the list never empties here (the
124
+ // enable closure below owns the delete-when-empty branch).
125
+ return { ...overlay, [field]: [...new Set([...current, name])] };
126
+ },
127
+ };
128
+ }
129
+
130
+ return {
131
+ kind: "mutate",
132
+ mutate: (overlay) => {
133
+ const current = overlay[field] ?? [];
134
+ if (!current.includes(name)) {
135
+ throw new SwitchError(
136
+ `no stored ${kind} disable entry equals "${name}" — current entries: ${
137
+ current.length > 0 ? current.join(", ") : "(none)"
138
+ }`,
139
+ );
140
+ }
141
+ const nextList = current.filter((entry) => entry !== name);
142
+ const next = { ...overlay };
143
+ if (nextList.length === 0) delete next[field];
144
+ else next[field] = nextList;
145
+ return next;
146
+ },
115
147
  };
116
148
  }
@@ -145,7 +145,7 @@ export function formatStatusMarkdown(report: StatusReport): string {
145
145
  ...(report.overlay.disabledSkills ?? []).map((name) => `-skill:${name}`),
146
146
  ...(report.overlay.disabledExtensions ?? []).map((id) => `-extension:${id}`),
147
147
  ...(report.overlay.disabledMcps ?? []).map((name) => `-mcp:${name}`),
148
- ...(report.overlay.tools !== undefined ? [`tools=[${report.overlay.tools.join(", ")}]`] : []),
148
+ ...(report.overlay.disabledTools ?? []).map((name) => `-tool:${name}`),
149
149
  ];
150
150
  lines.push(`overlay: ${parts.length > 0 ? parts.join(" ") : "(empty)"}`);
151
151
  }
@@ -55,6 +55,10 @@ export interface SwitchDeps {
55
55
  realAgentDir: string;
56
56
  /** The project working directory. */
57
57
  cwd: string;
58
+ /** Pi's live tool registry (pi.getAllTools()): the base set overlay tool
59
+ * disable entries narrow when the profile declares no tools, and the
60
+ * in-session expansion universe for declared references. */
61
+ getAllTools(): Array<{ name: string }>;
58
62
  /** Pi's native idle wait (ctx.waitForIdle): resolves when the current
59
63
  * turn/compaction finishes. */
60
64
  waitForIdle(): Promise<void>;
@@ -162,8 +166,8 @@ export async function switchProfile(
162
166
  throw new SwitchError("no active profile to reload");
163
167
  }
164
168
 
165
- // Overlay resolution: explicit `overlay` wins (customize), explicit
166
- // `null` suppresses (reset/switch), and a plain `/profile reload`
169
+ // Overlay resolution: explicit `overlay` wins (overlay mutation),
170
+ // explicit `null` suppresses (clear/switch), and a plain `/profile reload`
167
171
  // re-applies the stored overlay so runtime and state never diverge.
168
172
  let overlay = options?.overlay;
169
173
  if (overlay === undefined && options?.reloadCurrent === true && current.profile !== undefined) {
@@ -185,7 +189,7 @@ export async function switchProfile(
185
189
  const resolved = await resolveInitialProfile(
186
190
  target,
187
191
  { agentDir: deps.realAgentDir, cwd: deps.cwd },
188
- { overlay: overlay ?? undefined },
192
+ { overlay: overlay ?? undefined, liveToolNames: deps.getAllTools().map((tool) => tool.name) },
189
193
  );
190
194
 
191
195
  const isSwitch = !options?.reloadCurrent && target !== current.profile;
@@ -1,86 +0,0 @@
1
- /**
2
- * ProfileCatalogStore: the WRITE side of profile catalog files,
3
- * storing each profile in its own `<name>.json` file.
4
- *
5
- * Invariants:
6
- * - Single-file operations (pretty-printed JSON without schemaVersion envelope).
7
- * - Writes land in `<dir>/<name>.json` via a temporary file + rename.
8
- * - Names must match `PROFILE_NAME_PATTERN` and cannot be "default".
9
- * - Definitions are complete and self-contained: no inheritance fields exist.
10
- * - Definitions are re-parsed through the catalog's own
11
- * `parseProfileDefinition`, so anything written is loadable.
12
- * - Writes land in exactly one scope directory (global or project).
13
- */
14
-
15
- import { mkdir, rename, rm, unlink, writeFile } from "node:fs/promises";
16
- import path from "node:path";
17
-
18
- import {
19
- CatalogError,
20
- DEFAULT_PROFILE_NAME,
21
- loadCatalogDirectory,
22
- parseProfileDefinition,
23
- PROFILE_NAME_PATTERN,
24
- type ProfileDefinition,
25
- } from "./profile-catalog.ts";
26
-
27
- export class ProfileCatalogStore {
28
- readonly #dirPath: string;
29
-
30
- constructor(catalogDir: string) {
31
- this.#dirPath = catalogDir;
32
- }
33
-
34
- /** Validated definitions: missing directory → empty; malformed → CatalogError
35
- * (catalog errors never pass silently, even on the write path). */
36
- async readDefinitions(): Promise<Map<string, ProfileDefinition>> {
37
- return loadCatalogDirectory(this.#dirPath);
38
- }
39
-
40
- /** Inserts or replaces one complete definition (via atomic rename). */
41
- async upsert(name: string, definition: ProfileDefinition): Promise<void> {
42
- if (!PROFILE_NAME_PATTERN.test(name)) {
43
- throw new CatalogError(`invalid profile name "${name}" (must match ${PROFILE_NAME_PATTERN})`);
44
- }
45
- if (name === DEFAULT_PROFILE_NAME) {
46
- throw new CatalogError(`"${DEFAULT_PROFILE_NAME}" is built in and must not be defined in the catalog`);
47
- }
48
-
49
- // Validate before mutating: the definition must parse.
50
- const validated = parseProfileDefinition(name, definition);
51
-
52
- await mkdir(this.#dirPath, { recursive: true });
53
- const targetPath = path.join(this.#dirPath, `${name}.json`);
54
- const tempPath = path.join(
55
- this.#dirPath,
56
- `.${name}.json.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`,
57
- );
58
-
59
- try {
60
- await writeFile(tempPath, `${JSON.stringify(validated, null, 2)}\n`);
61
- await rename(tempPath, targetPath);
62
- } catch (error) {
63
- await rm(tempPath, { force: true });
64
- throw error;
65
- }
66
- }
67
-
68
- /** Removes one profile; unknown names are a loud error, not a no-op. */
69
- async remove(name: string): Promise<void> {
70
- if (!PROFILE_NAME_PATTERN.test(name)) {
71
- throw new CatalogError(`invalid profile name "${name}" (must match ${PROFILE_NAME_PATTERN})`);
72
- }
73
- if (name === DEFAULT_PROFILE_NAME) {
74
- throw new CatalogError(`"${DEFAULT_PROFILE_NAME}" is built in and cannot be deleted`);
75
- }
76
- const targetPath = path.join(this.#dirPath, `${name}.json`);
77
- try {
78
- await unlink(targetPath);
79
- } catch (error) {
80
- if ((error as NodeJS.ErrnoException).code === "ENOENT") {
81
- throw new CatalogError(`profile "${name}" not found in ${this.#dirPath}`);
82
- }
83
- throw error;
84
- }
85
- }
86
- }
@@ -1,136 +0,0 @@
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 { getGlobalProfilesDir } from "../workspace.ts";
20
- import { readTrustInputs } from "../launcher/initial-profile.ts";
21
- import { CatalogError, DEFAULT_PROFILE_NAME, type ProfileDefinition } from "../profile-catalog.ts";
22
- import { ProfileCatalogStore } from "../profile-catalog-store.ts";
23
-
24
- export type CatalogScope = "global" | "project";
25
-
26
- /** The store for one scope's catalog file — the ONLY place scope-file
27
- * paths are constructed. Callers must still trust-gate project access
28
- * (`requireScope` / `readCatalogScope`). */
29
- export function catalogStore(
30
- input: { realAgentDir: string; cwd: string },
31
- scope: CatalogScope,
32
- ): ProfileCatalogStore {
33
- if (scope === "global") {
34
- return new ProfileCatalogStore(getGlobalProfilesDir());
35
- }
36
- return new ProfileCatalogStore(path.join(input.cwd, ".pi", "profiles"));
37
- }
38
-
39
- async function requireScope(input: { realAgentDir: string; cwd: string }, scope: CatalogScope): Promise<void> {
40
- if (
41
- scope === "project" &&
42
- !(await readTrustInputs({ agentDir: input.realAgentDir, cwd: input.cwd })).projectTrusted
43
- ) {
44
- throw new CatalogError(`project catalog is unavailable: ${input.cwd} is not trusted`);
45
- }
46
- }
47
-
48
- /** Reads one scope's catalog with the trust gate applied — project reads
49
- * return empty when untrusted (never touching the file). */
50
- export async function readCatalogScope(
51
- input: { realAgentDir: string; cwd: string },
52
- scope: CatalogScope,
53
- ): Promise<Map<string, ProfileDefinition>> {
54
- if (
55
- scope === "project" &&
56
- !(await readTrustInputs({ agentDir: input.realAgentDir, cwd: input.cwd })).projectTrusted
57
- ) {
58
- return new Map();
59
- }
60
- return catalogStore(input, scope).readDefinitions();
61
- }
62
-
63
- /** Creates a complete definition in the chosen scope. */
64
- export async function createProfile(
65
- input: { realAgentDir: string; cwd: string },
66
- scope: CatalogScope,
67
- name: string,
68
- definition: ProfileDefinition,
69
- ): Promise<void> {
70
- await requireScope(input, scope);
71
- const store = catalogStore(input, scope);
72
- if ((await store.readDefinitions()).has(name)) {
73
- throw new CatalogError(`profile "${name}" already exists in the ${scope} catalog`);
74
- }
75
- await store.upsert(name, definition);
76
- }
77
-
78
- /** Replaces a complete definition; the caller reloads iff it is active. */
79
- export async function editProfile(
80
- input: { realAgentDir: string; cwd: string },
81
- scope: CatalogScope,
82
- name: string,
83
- definition: ProfileDefinition,
84
- ): Promise<void> {
85
- await requireScope(input, scope);
86
- if (name === DEFAULT_PROFILE_NAME) {
87
- throw new CatalogError(`"${DEFAULT_PROFILE_NAME}" is built in and cannot be edited`);
88
- }
89
- const store = catalogStore(input, scope);
90
- if (!(await store.readDefinitions()).has(name)) {
91
- throw new CatalogError(`profile "${name}" not found in the ${scope} catalog`);
92
- }
93
- await store.upsert(name, definition);
94
- }
95
-
96
- /**
97
- * Deletes a profile from the chosen scope. Deleting the active profile
98
- * requires `replacement` (validated for existence in the remaining merged
99
- * catalog by the caller's switch); the built-in default can never be
100
- * deleted.
101
- */
102
- export async function deleteProfile(
103
- input: { realAgentDir: string; cwd: string },
104
- scope: CatalogScope,
105
- name: string,
106
- options: { activeProfile?: string; replacement?: string },
107
- ): Promise<void> {
108
- await requireScope(input, scope);
109
- if (name === DEFAULT_PROFILE_NAME) {
110
- throw new CatalogError(`"${DEFAULT_PROFILE_NAME}" is built in and cannot be deleted`);
111
- }
112
- if (options.activeProfile === name && options.replacement === undefined) {
113
- throw new CatalogError(`profile "${name}" is active — choose a replacement profile first`);
114
- }
115
- await catalogStore(input, scope).remove(name);
116
- }
117
-
118
- /** Copies a complete definition under a new, unused name. */
119
- export async function duplicateProfile(
120
- input: { realAgentDir: string; cwd: string },
121
- scope: CatalogScope,
122
- sourceName: string,
123
- newName: string,
124
- ): Promise<void> {
125
- await requireScope(input, scope);
126
- const store = catalogStore(input, scope);
127
- const definitions = await store.readDefinitions();
128
- const source = definitions.get(sourceName);
129
- if (source === undefined) {
130
- throw new CatalogError(`profile "${sourceName}" not found in the ${scope} catalog`);
131
- }
132
- if (definitions.has(newName)) {
133
- throw new CatalogError(`profile "${newName}" already exists in the ${scope} catalog`);
134
- }
135
- await store.upsert(newName, { ...source });
136
- }