pi-profile-switch 0.3.0 → 0.4.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 +32 -32
- package/README.zh-CN.md +32 -32
- package/bin/pi-profile.js +11 -0
- package/bin/pi-profile.ts +65 -0
- package/bin/postinstall.d.ts +13 -0
- package/bin/postinstall.js +88 -0
- package/defaults/profiles.json +18 -0
- package/examples/profiles.json +31 -5
- package/extensions/pi-profile/index.ts +451 -0
- package/package.json +10 -17
- package/schemas/profiles.schema.json +22 -24
- package/src/extension-discovery.ts +347 -0
- package/src/json-file.ts +1 -21
- package/src/launcher/args.ts +57 -0
- package/src/launcher/discovery.ts +64 -0
- package/src/launcher/initial-profile.ts +179 -0
- package/src/launcher/model-check.ts +52 -0
- package/src/launcher/runtime-cleanup.ts +85 -0
- package/src/launcher/spawn.ts +82 -0
- package/src/mcp-config.ts +37 -153
- package/src/mcp-coordination.ts +29 -10
- package/src/profile-catalog-store.ts +31 -12
- package/src/profile-catalog.ts +44 -63
- package/src/profile-resolver.ts +239 -245
- package/src/project-trust.ts +82 -0
- package/src/runtime-state-store.ts +25 -35
- package/src/settings-generator.ts +541 -0
- package/src/skill-registry.ts +94 -0
- package/src/switching/apply-plan.ts +197 -0
- package/src/switching/customize.ts +62 -33
- package/src/switching/list-profiles.ts +9 -6
- package/src/switching/mcp-toggle.ts +21 -25
- package/src/switching/profile-crud.ts +31 -24
- package/src/switching/profile-wizard.ts +21 -49
- package/src/switching/status.ts +142 -72
- package/src/switching/switch-profile.ts +219 -0
- package/src/switching/tool-references.ts +40 -0
- package/src/workspace.ts +57 -0
- package/LICENSE +0 -21
- package/examples/profiles.example.json +0 -74
- package/extensions/pi-profile-switch/index.ts +0 -744
- package/src/adapter-presence.ts +0 -75
- package/src/mcp-overlay-file.ts +0 -35
- package/src/mcp-overlay.ts +0 -122
- package/src/model-selection.ts +0 -64
- package/src/name-matching.ts +0 -50
- package/src/profile-badge.ts +0 -142
- package/src/profile-presets.ts +0 -61
- package/src/skill-selection.ts +0 -81
- package/src/startup-mcp-scope.ts +0 -262
- package/src/startup-selection.ts +0 -144
- package/src/switching/activate-profile.ts +0 -115
- package/src/switching/apply-profile.ts +0 -131
|
@@ -16,30 +16,34 @@
|
|
|
16
16
|
|
|
17
17
|
import path from "node:path";
|
|
18
18
|
|
|
19
|
+
import { getGlobalProfilesPath } from "../workspace.ts";
|
|
20
|
+
import { readTrustInputs } from "../launcher/initial-profile.ts";
|
|
19
21
|
import { CatalogError, DEFAULT_PROFILE_NAME, type ProfileDefinition } from "../profile-catalog.ts";
|
|
20
22
|
import { ProfileCatalogStore } from "../profile-catalog-store.ts";
|
|
21
23
|
|
|
22
24
|
export type CatalogScope = "global" | "project";
|
|
23
25
|
|
|
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
26
|
/** The store for one scope's catalog file — the ONLY place scope-file
|
|
33
27
|
* paths are constructed. Callers must still trust-gate project access
|
|
34
28
|
* (`requireScope` / `readCatalogScope`). */
|
|
35
|
-
export function catalogStore(
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
29
|
+
export function catalogStore(
|
|
30
|
+
input: { realAgentDir: string; cwd: string },
|
|
31
|
+
scope: CatalogScope,
|
|
32
|
+
): ProfileCatalogStore {
|
|
33
|
+
if (scope === "global") {
|
|
34
|
+
return new ProfileCatalogStore(
|
|
35
|
+
getGlobalProfilesPath(),
|
|
36
|
+
path.join(input.realAgentDir, "profiles.json")
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
return new ProfileCatalogStore(path.join(input.cwd, ".pi", "profiles.json"));
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
function requireScope(input:
|
|
42
|
-
if (
|
|
42
|
+
async function requireScope(input: { realAgentDir: string; cwd: string }, scope: CatalogScope): Promise<void> {
|
|
43
|
+
if (
|
|
44
|
+
scope === "project" &&
|
|
45
|
+
!(await readTrustInputs({ agentDir: input.realAgentDir, cwd: input.cwd })).projectTrusted
|
|
46
|
+
) {
|
|
43
47
|
throw new CatalogError(`project catalog is unavailable: ${input.cwd} is not trusted`);
|
|
44
48
|
}
|
|
45
49
|
}
|
|
@@ -47,10 +51,13 @@ function requireScope(input: CatalogInput, scope: CatalogScope): void {
|
|
|
47
51
|
/** Reads one scope's catalog with the trust gate applied — project reads
|
|
48
52
|
* return empty when untrusted (never touching the file). */
|
|
49
53
|
export async function readCatalogScope(
|
|
50
|
-
input:
|
|
54
|
+
input: { realAgentDir: string; cwd: string },
|
|
51
55
|
scope: CatalogScope,
|
|
52
56
|
): Promise<Map<string, ProfileDefinition>> {
|
|
53
|
-
if (
|
|
57
|
+
if (
|
|
58
|
+
scope === "project" &&
|
|
59
|
+
!(await readTrustInputs({ agentDir: input.realAgentDir, cwd: input.cwd })).projectTrusted
|
|
60
|
+
) {
|
|
54
61
|
return new Map();
|
|
55
62
|
}
|
|
56
63
|
return catalogStore(input, scope).readDefinitions();
|
|
@@ -58,12 +65,12 @@ export async function readCatalogScope(
|
|
|
58
65
|
|
|
59
66
|
/** Creates a complete definition in the chosen scope. */
|
|
60
67
|
export async function createProfile(
|
|
61
|
-
input:
|
|
68
|
+
input: { realAgentDir: string; cwd: string },
|
|
62
69
|
scope: CatalogScope,
|
|
63
70
|
name: string,
|
|
64
71
|
definition: ProfileDefinition,
|
|
65
72
|
): Promise<void> {
|
|
66
|
-
requireScope(input, scope);
|
|
73
|
+
await requireScope(input, scope);
|
|
67
74
|
const store = catalogStore(input, scope);
|
|
68
75
|
if ((await store.readDefinitions()).has(name)) {
|
|
69
76
|
throw new CatalogError(`profile "${name}" already exists in the ${scope} catalog`);
|
|
@@ -73,12 +80,12 @@ export async function createProfile(
|
|
|
73
80
|
|
|
74
81
|
/** Replaces a complete definition; the caller reloads iff it is active. */
|
|
75
82
|
export async function editProfile(
|
|
76
|
-
input:
|
|
83
|
+
input: { realAgentDir: string; cwd: string },
|
|
77
84
|
scope: CatalogScope,
|
|
78
85
|
name: string,
|
|
79
86
|
definition: ProfileDefinition,
|
|
80
87
|
): Promise<void> {
|
|
81
|
-
requireScope(input, scope);
|
|
88
|
+
await requireScope(input, scope);
|
|
82
89
|
if (name === DEFAULT_PROFILE_NAME) {
|
|
83
90
|
throw new CatalogError(`"${DEFAULT_PROFILE_NAME}" is built in and cannot be edited`);
|
|
84
91
|
}
|
|
@@ -96,12 +103,12 @@ export async function editProfile(
|
|
|
96
103
|
* deleted.
|
|
97
104
|
*/
|
|
98
105
|
export async function deleteProfile(
|
|
99
|
-
input:
|
|
106
|
+
input: { realAgentDir: string; cwd: string },
|
|
100
107
|
scope: CatalogScope,
|
|
101
108
|
name: string,
|
|
102
109
|
options: { activeProfile?: string; replacement?: string },
|
|
103
110
|
): Promise<void> {
|
|
104
|
-
requireScope(input, scope);
|
|
111
|
+
await requireScope(input, scope);
|
|
105
112
|
if (name === DEFAULT_PROFILE_NAME) {
|
|
106
113
|
throw new CatalogError(`"${DEFAULT_PROFILE_NAME}" is built in and cannot be deleted`);
|
|
107
114
|
}
|
|
@@ -113,12 +120,12 @@ export async function deleteProfile(
|
|
|
113
120
|
|
|
114
121
|
/** Copies a complete definition under a new, unused name. */
|
|
115
122
|
export async function duplicateProfile(
|
|
116
|
-
input:
|
|
123
|
+
input: { realAgentDir: string; cwd: string },
|
|
117
124
|
scope: CatalogScope,
|
|
118
125
|
sourceName: string,
|
|
119
126
|
newName: string,
|
|
120
127
|
): Promise<void> {
|
|
121
|
-
requireScope(input, scope);
|
|
128
|
+
await requireScope(input, scope);
|
|
122
129
|
const store = catalogStore(input, scope);
|
|
123
130
|
const definitions = await store.readDefinitions();
|
|
124
131
|
const source = definitions.get(sourceName);
|
|
@@ -8,14 +8,9 @@
|
|
|
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.
|
|
15
11
|
*/
|
|
16
12
|
|
|
17
13
|
import type { ProfileDefinition } from "../profile-catalog.ts";
|
|
18
|
-
import { PROFILE_PRESETS, type ProfilePreset } from "../profile-presets.ts";
|
|
19
14
|
import type { CatalogScope } from "./profile-crud.ts";
|
|
20
15
|
|
|
21
16
|
export interface ProfileWizardUi {
|
|
@@ -27,8 +22,6 @@ export interface ProfileWizardResult {
|
|
|
27
22
|
scope: CatalogScope;
|
|
28
23
|
name: string;
|
|
29
24
|
definition: ProfileDefinition;
|
|
30
|
-
/** Name of the preset this definition was copied from, when any. */
|
|
31
|
-
preset?: string;
|
|
32
25
|
}
|
|
33
26
|
|
|
34
27
|
interface ExistingProfile {
|
|
@@ -45,7 +38,7 @@ function parseList(raw: string): string[] {
|
|
|
45
38
|
}
|
|
46
39
|
|
|
47
40
|
/** "provider/id[/thinkingLevel]" → ProfileModel; empty/undefined → none. */
|
|
48
|
-
function parseModel(raw: string):
|
|
41
|
+
function parseModel(raw: string): { defaultProvider: string; defaultModel: string; defaultThinkingLevel?: string } | undefined {
|
|
49
42
|
const trimmed = raw.trim();
|
|
50
43
|
if (trimmed.length === 0) return undefined;
|
|
51
44
|
const [provider, id, thinkingLevel] = trimmed.split("/").map((part) => part.trim());
|
|
@@ -53,8 +46,8 @@ function parseModel(raw: string): ProfileDefinition["model"] | undefined {
|
|
|
53
46
|
return undefined;
|
|
54
47
|
}
|
|
55
48
|
return thinkingLevel !== undefined && thinkingLevel.length > 0
|
|
56
|
-
? { provider, id, thinkingLevel }
|
|
57
|
-
: { provider, id };
|
|
49
|
+
? { defaultProvider: provider, defaultModel: id, defaultThinkingLevel: thinkingLevel }
|
|
50
|
+
: { defaultProvider: provider, defaultModel: id };
|
|
58
51
|
}
|
|
59
52
|
|
|
60
53
|
async function captureDefinition(
|
|
@@ -74,8 +67,9 @@ async function captureDefinition(
|
|
|
74
67
|
else if (existing?.description !== undefined) definition.description = existing.description;
|
|
75
68
|
|
|
76
69
|
const listFields = [
|
|
77
|
-
["skills", "skills (comma-separated names or globs, empty =
|
|
78
|
-
["
|
|
70
|
+
["skills", "skills (comma-separated names or globs, empty = none)"],
|
|
71
|
+
["extensions", "extensions (names or globs, empty = none)"],
|
|
72
|
+
["mcps", "mcp servers (names or globs, empty = none)"],
|
|
79
73
|
["tools", "tools (names or globs, empty = pi default set)"],
|
|
80
74
|
] as const;
|
|
81
75
|
for (const [field, prompt] of listFields) {
|
|
@@ -100,64 +94,42 @@ async function captureDefinition(
|
|
|
100
94
|
else if (existing?.instructions !== undefined) definition.instructions = existing.instructions;
|
|
101
95
|
|
|
102
96
|
const currentModel =
|
|
103
|
-
existing?.
|
|
104
|
-
? `${existing.
|
|
97
|
+
existing?.defaultProvider !== undefined && existing?.defaultModel !== undefined
|
|
98
|
+
? `${existing.defaultProvider}/${existing.defaultModel}${existing.defaultThinkingLevel !== undefined ? `/${existing.defaultThinkingLevel}` : ""}`
|
|
105
99
|
: undefined;
|
|
106
100
|
const modelRaw = await ui.input("model provider/id[/thinking] (empty = none)", currentModel);
|
|
107
101
|
if (modelRaw === undefined) return undefined;
|
|
108
102
|
const model = parseModel(modelRaw);
|
|
109
103
|
if (model !== undefined) {
|
|
110
|
-
definition.
|
|
111
|
-
|
|
112
|
-
definition.
|
|
104
|
+
definition.defaultProvider = model.defaultProvider;
|
|
105
|
+
definition.defaultModel = model.defaultModel;
|
|
106
|
+
if (model.defaultThinkingLevel !== undefined) definition.defaultThinkingLevel = model.defaultThinkingLevel;
|
|
107
|
+
} else if (existing?.defaultProvider !== undefined && modelRaw.trim().length === 0) {
|
|
108
|
+
definition.defaultProvider = existing.defaultProvider;
|
|
109
|
+
definition.defaultModel = existing.defaultModel;
|
|
110
|
+
if (existing.defaultThinkingLevel !== undefined) definition.defaultThinkingLevel = existing.defaultThinkingLevel;
|
|
113
111
|
}
|
|
114
112
|
|
|
115
113
|
return definition;
|
|
116
114
|
}
|
|
117
115
|
|
|
118
|
-
|
|
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. */
|
|
116
|
+
/** Create: scope first (project only when trusted), then name, then fields. */
|
|
128
117
|
export async function runProfileCreateWizard(
|
|
129
118
|
ui: ProfileWizardUi,
|
|
130
|
-
input: { projectTrusted: boolean
|
|
119
|
+
input: { projectTrusted: boolean },
|
|
131
120
|
): Promise<ProfileWizardResult | undefined> {
|
|
132
121
|
const scopeOptions = input.projectTrusted ? ["global", "project"] : ["global"];
|
|
133
122
|
// A single available scope needs no dialog.
|
|
134
123
|
const scope = scopeOptions.length === 1 ? scopeOptions[0] : await ui.select("write to which catalog?", scopeOptions);
|
|
135
124
|
if (scope === undefined) return undefined;
|
|
136
125
|
|
|
137
|
-
const
|
|
138
|
-
|
|
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;
|
|
126
|
+
const name = await ui.input("profile name");
|
|
127
|
+
if (name === undefined || name.trim().length === 0) return undefined;
|
|
151
128
|
|
|
152
|
-
const definition = await captureDefinition(ui
|
|
129
|
+
const definition = await captureDefinition(ui);
|
|
153
130
|
if (definition === undefined) return undefined;
|
|
154
131
|
|
|
155
|
-
return {
|
|
156
|
-
scope: scope as CatalogScope,
|
|
157
|
-
name: resolvedName,
|
|
158
|
-
definition,
|
|
159
|
-
...(preset !== undefined ? { preset: preset.name } : {}),
|
|
160
|
-
};
|
|
132
|
+
return { scope: scope as CatalogScope, name: name.trim(), definition };
|
|
161
133
|
}
|
|
162
134
|
|
|
163
135
|
/** Edit: fields prefilled from the existing complete definition. */
|
package/src/switching/status.ts
CHANGED
|
@@ -1,74 +1,139 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* StatusReport: the `/profile status` observability surface.
|
|
2
|
+
* StatusReport: the `/profile status` observability surface (ticket 07).
|
|
3
3
|
*
|
|
4
|
-
* Pure report builder: combines the ACTIVE
|
|
5
|
-
* resolved to), the stored overlay,
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Pure report builder: combines the ACTIVE launch plan (what the runtime
|
|
5
|
+
* was resolved to), the stored overlay, fresh MCP adapter discovery, and
|
|
6
|
+
* Pi's actual command registrations (the winner evidence for same-name
|
|
7
|
+
* conflicts). Markdown formatting is the only presentation; the extension
|
|
8
|
+
* ships it via `pi.sendMessage`.
|
|
9
|
+
*
|
|
10
|
+
* Conflict semantics: Pi's load order is first-wins by scope/file order,
|
|
11
|
+
* so the registered command IS the winner. A conflict is reported when the
|
|
12
|
+
* plan resolved a skill whose command name is registered from a DIFFERENT
|
|
13
|
+
* path (shadowed) or is absent (failed to load) — never blocked, always
|
|
14
|
+
* visible.
|
|
8
15
|
*/
|
|
9
16
|
|
|
10
|
-
import type { ProfileSource } from "../profile-catalog.ts";
|
|
11
|
-
import type { ResolvedSelection, UnresolvedRef } from "../profile-resolver.ts";
|
|
12
17
|
import type { RuntimeOverlay } from "../runtime-state-store.ts";
|
|
13
|
-
import {
|
|
18
|
+
import type { LaunchPlanFile } from "./apply-plan.ts";
|
|
19
|
+
|
|
20
|
+
export interface StatusConflict {
|
|
21
|
+
/** Command name as registered (e.g. `skill:review`). */
|
|
22
|
+
name: string;
|
|
23
|
+
/** The path the active plan resolved. */
|
|
24
|
+
expectedPath: string;
|
|
25
|
+
/** The path Pi actually registered (the winner), or "not loaded". */
|
|
26
|
+
winnerPath: string;
|
|
27
|
+
}
|
|
14
28
|
|
|
15
29
|
export interface StatusReport {
|
|
16
30
|
profile: string;
|
|
17
|
-
source:
|
|
31
|
+
source: string;
|
|
18
32
|
overlay?: RuntimeOverlay;
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
33
|
+
skills: Array<{ name: string; filePath: string }>;
|
|
34
|
+
extensions: Array<{ id: string; entry: string; origin?: string }>;
|
|
35
|
+
tools?: string[];
|
|
36
|
+
mcp: { enabled: string[]; disabled: string[]; missing: string[] };
|
|
37
|
+
/** Glob delta versus the previous activation (prefixed names). */
|
|
38
|
+
delta?: { added: string[]; removed: string[] };
|
|
39
|
+
/** Glob references that matched nothing at resolution (ADR-0006). */
|
|
40
|
+
unmatched?: string[];
|
|
41
|
+
conflicts: StatusConflict[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface RegisteredCommand {
|
|
45
|
+
name: string;
|
|
46
|
+
sourceInfo?: { path: string };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface RegisteredTool {
|
|
50
|
+
name: string;
|
|
51
|
+
sourceInfo?: { path: string; source: string };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function currentNames(plan: LaunchPlanFile): string[] {
|
|
55
|
+
const names = [
|
|
56
|
+
...(plan.resolved?.skills ?? []).map((skill) => `skill:${skill.name}`),
|
|
57
|
+
...(plan.resolved?.extensions ?? []).map((entry) => `extension:${entry.id}`),
|
|
58
|
+
...(plan.tools ?? []).map((tool) => `tool:${tool}`),
|
|
59
|
+
...(plan.mcps ?? []).map((server) => `mcp:${server}`),
|
|
60
|
+
];
|
|
61
|
+
return names.sort();
|
|
31
62
|
}
|
|
32
63
|
|
|
33
64
|
export function buildStatusReport(input: {
|
|
34
|
-
|
|
35
|
-
|
|
65
|
+
plan: LaunchPlanFile;
|
|
66
|
+
overlay?: RuntimeOverlay;
|
|
36
67
|
discoveredMcpServers: string[];
|
|
37
|
-
|
|
68
|
+
commands: RegisteredCommand[];
|
|
69
|
+
tools: RegisteredTool[];
|
|
38
70
|
}): StatusReport {
|
|
39
|
-
const {
|
|
40
|
-
const visibleNames = visibleSkillNames(input.allSkills, selection.skills);
|
|
41
|
-
const visible =
|
|
42
|
-
visibleNames === undefined
|
|
43
|
-
? input.allSkills
|
|
44
|
-
: input.allSkills.filter((skill) => visibleNames.includes(skill.name));
|
|
71
|
+
const { plan } = input;
|
|
45
72
|
|
|
46
|
-
const enabled =
|
|
73
|
+
const enabled = plan.mcps ?? [];
|
|
47
74
|
const discovered = new Set(input.discoveredMcpServers);
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
75
|
+
const mcp = {
|
|
76
|
+
enabled,
|
|
77
|
+
disabled: input.discoveredMcpServers.filter((name) => !enabled.includes(name)),
|
|
78
|
+
missing: enabled.filter((name) => !discovered.has(name)),
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
let delta: StatusReport["delta"];
|
|
82
|
+
if (plan.previousResolved !== undefined) {
|
|
83
|
+
const before = new Set(
|
|
84
|
+
[
|
|
85
|
+
...plan.previousResolved.skills.map((name) => `skill:${name}`),
|
|
86
|
+
...plan.previousResolved.extensions.map((id) => `extension:${id}`),
|
|
87
|
+
...(plan.previousResolved.tools ?? []).map((name) => `tool:${name}`),
|
|
88
|
+
...(plan.previousResolved.mcps ?? []).map((name) => `mcp:${name}`),
|
|
89
|
+
].sort(),
|
|
90
|
+
);
|
|
91
|
+
const after = new Set(currentNames(plan));
|
|
92
|
+
const added = [...after].filter((name) => !before.has(name));
|
|
93
|
+
const removed = [...before].filter((name) => !after.has(name));
|
|
94
|
+
if (added.length > 0 || removed.length > 0) {
|
|
95
|
+
delta = { added, removed };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const conflicts: StatusConflict[] = [];
|
|
100
|
+
for (const skill of plan.resolved?.skills ?? []) {
|
|
101
|
+
const command = input.commands.find((entry) => entry.name === `skill:${skill.name}`);
|
|
102
|
+
const winnerPath = command?.sourceInfo?.path;
|
|
103
|
+
if (winnerPath === undefined) {
|
|
104
|
+
conflicts.push({ name: `skill:${skill.name}`, expectedPath: skill.filePath, winnerPath: "not loaded" });
|
|
105
|
+
} else if (winnerPath !== skill.filePath) {
|
|
106
|
+
conflicts.push({ name: `skill:${skill.name}`, expectedPath: skill.filePath, winnerPath });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Tool conflicts: a plan tool whose registered winner is neither a pi
|
|
111
|
+
// builtin nor a tool from one of the plan's selected extensions was
|
|
112
|
+
// shadowed by (or shadows) an unexpected source.
|
|
113
|
+
const extensionDirs = (plan.resolved?.extensions ?? []).map((entry) =>
|
|
114
|
+
entry.entry.endsWith(".ts") ? entry.entry.slice(0, entry.entry.lastIndexOf("/")) : entry.entry,
|
|
115
|
+
);
|
|
116
|
+
for (const toolName of plan.tools ?? []) {
|
|
117
|
+
const winner = input.tools.find((entry) => entry.name === toolName);
|
|
118
|
+
const info = winner?.sourceInfo;
|
|
119
|
+
if (info === undefined) continue; // unknown names are dropped by pi.setActiveTools
|
|
120
|
+
const expected = info.source === "builtin" || extensionDirs.some((dir) => info.path.startsWith(dir));
|
|
121
|
+
if (!expected) {
|
|
122
|
+
conflicts.push({ name: `tool:${toolName}`, expectedPath: "builtin or selected extension", winnerPath: info.path });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
53
125
|
|
|
54
126
|
return {
|
|
55
|
-
profile:
|
|
56
|
-
source:
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
},
|
|
63
|
-
...(
|
|
64
|
-
|
|
65
|
-
: {}),
|
|
66
|
-
mcp: {
|
|
67
|
-
enabled,
|
|
68
|
-
discovered: input.discoveredMcpServers,
|
|
69
|
-
missing: enabled.filter((name) => !discovered.has(name)),
|
|
70
|
-
},
|
|
71
|
-
unresolved: { skills: selection.warnings.skillsUnresolved, unmatched },
|
|
127
|
+
profile: plan.profile,
|
|
128
|
+
source: plan.source,
|
|
129
|
+
overlay: input.overlay,
|
|
130
|
+
skills: plan.resolved?.skills ?? [],
|
|
131
|
+
extensions: plan.resolved?.extensions ?? [],
|
|
132
|
+
...(plan.tools !== undefined ? { tools: plan.tools } : {}),
|
|
133
|
+
mcp,
|
|
134
|
+
...(delta !== undefined ? { delta } : {}),
|
|
135
|
+
...(plan.unmatched !== undefined && plan.unmatched.length > 0 ? { unmatched: plan.unmatched } : {}),
|
|
136
|
+
conflicts,
|
|
72
137
|
};
|
|
73
138
|
}
|
|
74
139
|
|
|
@@ -78,36 +143,41 @@ export function formatStatusMarkdown(report: StatusReport): string {
|
|
|
78
143
|
if (report.overlay !== undefined) {
|
|
79
144
|
const parts = [
|
|
80
145
|
...(report.overlay.disabledSkills ?? []).map((name) => `-skill:${name}`),
|
|
81
|
-
...(report.overlay.
|
|
146
|
+
...(report.overlay.disabledExtensions ?? []).map((id) => `-extension:${id}`),
|
|
147
|
+
...(report.overlay.disabledMcps ?? []).map((name) => `-mcp:${name}`),
|
|
82
148
|
...(report.overlay.tools !== undefined ? [`tools=[${report.overlay.tools.join(", ")}]`] : []),
|
|
83
149
|
];
|
|
84
150
|
lines.push(`overlay: ${parts.length > 0 ? parts.join(" ") : "(empty)"}`);
|
|
85
151
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
lines.push(`skills filter: not applied (${report.skills.filterOutcome})`);
|
|
152
|
+
if (report.skills.length > 0) {
|
|
153
|
+
lines.push("skills:");
|
|
154
|
+
for (const skill of report.skills) {
|
|
155
|
+
lines.push(` ${skill.name} → ${skill.filePath}`);
|
|
156
|
+
}
|
|
92
157
|
}
|
|
93
|
-
|
|
94
|
-
lines.push(
|
|
158
|
+
if (report.extensions.length > 0) {
|
|
159
|
+
lines.push("extensions:");
|
|
160
|
+
for (const extension of report.extensions) {
|
|
161
|
+
const originTag = extension.origin !== undefined ? ` [${extension.origin}]` : "";
|
|
162
|
+
lines.push(` ${extension.id}${originTag} → ${extension.entry}`);
|
|
163
|
+
}
|
|
95
164
|
}
|
|
96
165
|
if (report.tools !== undefined) {
|
|
97
|
-
lines.push(`tools: [${report.tools.
|
|
98
|
-
if (report.tools.pending.length > 0) {
|
|
99
|
-
lines.push(`tools pending (not registered yet): [${report.tools.pending.join(", ")}]`);
|
|
100
|
-
}
|
|
166
|
+
lines.push(`tools: [${report.tools.join(", ")}]`);
|
|
101
167
|
}
|
|
102
168
|
lines.push(
|
|
103
|
-
`mcp: enabled=[${report.mcp.enabled.join(", ")}]
|
|
169
|
+
`mcp: enabled=[${report.mcp.enabled.join(", ")}] disabled=[${report.mcp.disabled.join(", ")}] missing=[${report.mcp.missing.join(", ")}]`,
|
|
104
170
|
);
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
171
|
+
if (report.delta !== undefined) {
|
|
172
|
+
lines.push(`delta: +[${report.delta.added.join(", ")}] -[${report.delta.removed.join(", ")}]`);
|
|
173
|
+
}
|
|
174
|
+
if (report.unmatched !== undefined) {
|
|
175
|
+
lines.push(`unmatched (zero-match globs this resolution): [${report.unmatched.join(", ")}]`);
|
|
108
176
|
}
|
|
109
|
-
|
|
110
|
-
lines.push(
|
|
177
|
+
for (const conflict of report.conflicts) {
|
|
178
|
+
lines.push(
|
|
179
|
+
`conflict: ${conflict.name} — plan resolved ${conflict.expectedPath}, Pi registered ${conflict.winnerPath} (Pi first-wins load order)`,
|
|
180
|
+
);
|
|
111
181
|
}
|
|
112
182
|
return lines.join("\n");
|
|
113
183
|
}
|