@mrclrchtr/supi-review 1.10.0 → 1.11.1

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.
Files changed (45) hide show
  1. package/README.md +42 -10
  2. package/node_modules/@mrclrchtr/supi-core/README.md +103 -0
  3. package/node_modules/@mrclrchtr/supi-core/package.json +59 -0
  4. package/node_modules/@mrclrchtr/supi-core/src/api.ts +32 -0
  5. package/node_modules/@mrclrchtr/supi-core/src/config/config-settings.ts +182 -0
  6. package/node_modules/@mrclrchtr/supi-core/src/config/config.ts +206 -0
  7. package/node_modules/@mrclrchtr/supi-core/src/config.ts +11 -0
  8. package/node_modules/@mrclrchtr/supi-core/src/context/context-messages.ts +119 -0
  9. package/node_modules/@mrclrchtr/supi-core/src/context/context-provider-registry.ts +36 -0
  10. package/node_modules/@mrclrchtr/supi-core/src/context/context-tag.ts +31 -0
  11. package/node_modules/@mrclrchtr/supi-core/src/context.ts +16 -0
  12. package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +255 -0
  13. package/node_modules/@mrclrchtr/supi-core/src/index.ts +30 -0
  14. package/node_modules/@mrclrchtr/supi-core/src/llm.ts +211 -0
  15. package/node_modules/@mrclrchtr/supi-core/src/path-utils.ts +40 -0
  16. package/node_modules/@mrclrchtr/supi-core/src/path.ts +2 -0
  17. package/{src/ui → node_modules/@mrclrchtr/supi-core/src}/progress-widget.ts +32 -10
  18. package/node_modules/@mrclrchtr/supi-core/src/project-roots.ts +170 -0
  19. package/node_modules/@mrclrchtr/supi-core/src/project.ts +15 -0
  20. package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +86 -0
  21. package/node_modules/@mrclrchtr/supi-core/src/session-utils.ts +29 -0
  22. package/node_modules/@mrclrchtr/supi-core/src/session.ts +4 -0
  23. package/node_modules/@mrclrchtr/supi-core/src/settings/settings-command.ts +15 -0
  24. package/node_modules/@mrclrchtr/supi-core/src/settings/settings-registry.ts +41 -0
  25. package/node_modules/@mrclrchtr/supi-core/src/settings/settings-ui.ts +226 -0
  26. package/node_modules/@mrclrchtr/supi-core/src/settings-ui.ts +2 -0
  27. package/node_modules/@mrclrchtr/supi-core/src/settings.ts +9 -0
  28. package/node_modules/@mrclrchtr/supi-core/src/substrate-types.ts +11 -0
  29. package/node_modules/@mrclrchtr/supi-core/src/terminal.ts +60 -0
  30. package/node_modules/@mrclrchtr/supi-core/src/tool-framework.ts +182 -0
  31. package/node_modules/@mrclrchtr/supi-core/src/types.ts +2 -0
  32. package/package.json +7 -1
  33. package/src/history/synthesize.ts +13 -0
  34. package/src/review-result.ts +98 -0
  35. package/src/review.ts +90 -147
  36. package/src/target/packet.ts +84 -17
  37. package/src/target/review-instruction-blocks.ts +60 -0
  38. package/src/tool/brief-runner.ts +1 -1
  39. package/src/tool/review-runner.ts +176 -35
  40. package/src/tool/schemas.ts +55 -12
  41. package/src/types.ts +81 -9
  42. package/src/ui/flow.ts +10 -166
  43. package/src/ui/format-content.ts +81 -33
  44. package/src/ui/renderer.ts +103 -51
  45. package/src/ui/review-plan-inspector.ts +396 -0
package/README.md CHANGED
@@ -33,7 +33,7 @@ After install, pi gets one command:
33
33
  The reviewer runs in managed child agent sessions:
34
34
 
35
35
  - a **brief synthesizer** creates a structured review brief from the active session branch
36
- - a **read-only reviewer** inspects the selected snapshot (without receiving bulk inline diffs) and submits structured findings
36
+ - a **read-only reviewer** inspects the selected snapshot (without receiving bulk inline diffs) and submits structured review items
37
37
 
38
38
  ![Review target selection](https://raw.githubusercontent.com/mrclrchtr/supi/main/screenshots/supi-review-1.png)
39
39
 
@@ -52,10 +52,10 @@ The reviewer runs in managed child agent sessions:
52
52
  3. optionally add a short note
53
53
  4. resolve the snapshot
54
54
  5. synthesize a review brief from the current session history
55
- 6. preview the synthesized brief + compact prompt preview
55
+ 6. preview the synthesized brief + compact prompt preview, then press `v` for an in-app inspector (Overview first, Raw Prompt via `tab`, export via `e`)
56
56
  7. the reviewer fetches per-file diffs on demand via snapshot-aware tools; live progress widget shows activity
57
- 8. show the structured result as a custom message
58
- 9. if findings exist, hand off to the main agent so it can ask what to do next
57
+ 8. normalize the submitted review items into a host-derived verdict + structured result
58
+ 9. if review items exist, hand off to the main agent so it can ask what to do next with fixed options (`Fix all`, `Fix selected`, `Verify findings`, `Skip`)
59
59
 
60
60
  ## Review targets
61
61
 
@@ -81,13 +81,27 @@ Before the actual review starts, the package:
81
81
  - focus areas
82
82
  - risky files
83
83
  - unresolved questions
84
+ - `reviewInstructionBlockIds` selected from a fixed host-owned catalog
84
85
 
85
86
  The synthesizer also receives a bounded diff excerpt from the snapshot so it can reason about actual code changes, not just filenames.
86
87
 
87
- That synthesized brief is then combined with the git snapshot into a compact reviewer prompt. The prompt contains the brief, file manifest, and per-file overview, but no large inline diffs. Instead, the reviewer session gets snapshot-aware tools (`read_snapshot_diff`, `read_snapshot_file`) to fetch exact per-file diffs and before/after file contents on demand.
88
+ That synthesized brief is then combined with the git snapshot into a compact reviewer prompt. The host owns a fixed catalog of review instruction blocks, and the brief selects zero or more block IDs from that catalog when extra review guidance is warranted. The resulting prompt contains the brief, file manifest, per-file overview, and any brief-selected **mandatory review instructions**, but no large inline diffs. Instead, the reviewer session gets snapshot-aware tools (`read_snapshot_diff`, `read_snapshot_file`) to fetch exact per-file diffs and before/after file contents on demand.
88
89
 
89
90
  The session-transcript approach mirrors how Pi summarizes context for compaction: the entire resolved conversation is rendered in a readable label format and sent to the model as a whole, rather than relying on heuristic excerpt ranking.
90
91
 
92
+ ## Review-plan inspector
93
+
94
+ Before the reviewer runs, the plan preview stays inside Pi:
95
+
96
+ - `v` opens an in-app inspector instead of spawning an external pager
97
+ - the inspector opens in **Overview** mode first
98
+ - `tab` toggles between **Overview** and **Raw Prompt**
99
+ - `↑↓` or `j` / `k` scroll long content in the inspector
100
+ - `q` or `esc` returns to the summary preview without canceling the review
101
+ - `e` exports the raw prompt to a temp file as a debugging fallback
102
+
103
+ The Overview mode uses the same structured packet data that feeds the reviewer prompt: mandatory review instructions, file overview rows, and truncated snapshot notes all come from shared packet derivation rather than re-parsing the raw prompt text.
104
+
91
105
  ## Model selection
92
106
 
93
107
  Every `/supi-review` run asks you to choose the reviewer model.
@@ -101,20 +115,35 @@ Every `/supi-review` run asks you to choose the reviewer model.
101
115
 
102
116
  A successful review includes:
103
117
 
104
- - overall correctness verdict
118
+ - a host-derived binary verdict:
119
+ - `PATCH IS CORRECT`
120
+ - `PATCH HAS ISSUES`
105
121
  - overall explanation
106
122
  - overall confidence score
107
- - structured findings with title, body, priority, confidence score, and code location
123
+ - normalized action/category summary counts
124
+ - structured review items with:
125
+ - title
126
+ - body
127
+ - category
128
+ - impact
129
+ - effort
130
+ - recommended action
131
+ - confidence score
132
+ - suggested fix
133
+ - verification hint
134
+ - optional code location
108
135
  - the synthesized brief that drove the review
109
136
 
110
137
  The renderer also handles failed, canceled, and timed-out reviews.
111
138
 
112
- When a successful review contains findings, `supi-review` also injects an agent-visible hidden follow-up message that asks the main agent to decide the next step with the user. If `ask_user` is available, the main agent is instructed to use it and offer:
139
+ The reviewer model does **not** decide the final binary verdict directly. It submits review items plus overall explanation/confidence, then the host derives the verdict from the normalized items (`must-fix` items => `PATCH HAS ISSUES`).
140
+
141
+ When a successful review contains review items, `supi-review` also injects an agent-visible hidden follow-up message that asks the main agent to decide the next step with the user. If `ask_user` is available, the main agent is instructed to use it and offer:
113
142
 
114
- - Done
115
143
  - Fix all
116
144
  - Fix selected
117
145
  - Verify findings
146
+ - Skip
118
147
 
119
148
  ## Source
120
149
 
@@ -123,8 +152,11 @@ When a successful review contains findings, `supi-review` also injects an agent-
123
152
  - `src/git.ts` — git snapshot resolution
124
153
  - `src/history/collect.ts` — compaction-style session-context serialization
125
154
  - `src/history/synthesize.ts` — brief synthesis orchestration
126
- - `src/target/packet.ts` — final reviewer packet builder
155
+ - `src/review-result.ts` — review-item normalization, verdict derivation, and summary counts
156
+ - `src/target/review-instruction-blocks.ts` — fixed catalog of host-owned review instruction blocks
157
+ - `src/target/packet.ts` — final reviewer packet builder + shared preview-data derivation for the inspector
127
158
  - `src/tool/brief-runner.ts` — brief synthesis child session
128
159
  - `src/tool/review-runner.ts` — read-only reviewer child session with snapshot-aware tools
129
160
  - `src/tool/snapshot-tools.ts` — per-file diff and before/after content tools scoped to the selected snapshot
161
+ - `src/ui/review-plan-inspector.ts` — in-app summary/inspector preview with Overview + Raw Prompt modes and export fallback
130
162
  - `src/ui/renderer.ts` — structured result rendering
@@ -0,0 +1,103 @@
1
+ <div align="center">
2
+ <a href="https://github.com/mrclrchtr/supi/tree/main/packages/supi-core">
3
+ <picture>
4
+ <img src="https://raw.githubusercontent.com/mrclrchtr/supi/main/packages/supi-core/assets/logo.png" alt="SuPi" width="50%">
5
+ </picture>
6
+ </a>
7
+ </div>
8
+
9
+ # @mrclrchtr/supi-core
10
+
11
+ Shared infrastructure for SuPi extensions.
12
+
13
+ This is a **pure library** — it does not register any pi commands or tools. The `/supi-settings` command is now available through `@mrclrchtr/supi-settings`.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pnpm add @mrclrchtr/supi-core
19
+ ```
20
+
21
+ ## Package surfaces
22
+
23
+ - `@mrclrchtr/supi-core/api` — reusable helpers for other packages and extensions
24
+
25
+ ## What you get from the API
26
+
27
+ ### Config helpers
28
+
29
+ - `loadSupiConfig()` — merged config with resolution order `defaults <- global <- project`
30
+ - `loadSupiConfigForScope()` — load one scope at a time for settings UIs
31
+ - `writeSupiConfig()` — persist values
32
+ - `removeSupiConfigKey()` — remove a key or override
33
+
34
+ Config file locations:
35
+
36
+ - global: `~/.pi/agent/supi/config.json`
37
+ - project: `.pi/supi/config.json`
38
+
39
+ ### Settings helpers
40
+
41
+ - `registerSettings()` — register an arbitrary settings section
42
+ - `registerConfigSettings()` — register a config-backed settings section with scoped persistence helpers
43
+ - `registerSettingsCommand()` — register `/supi-settings`
44
+ - `openSettingsOverlay()` — open the shared settings UI directly
45
+ - `createInputSubmenu()` — helper for simple text-entry submenus
46
+
47
+ The built-in settings UI supports:
48
+
49
+ - project/global scope toggle
50
+ - grouped extension sections
51
+ - searchable setting lists
52
+
53
+ ### Context helpers
54
+
55
+ - `wrapExtensionContext()` — wrap injected text in SuPi's `<extension-context>` tag
56
+ - `findLastUserMessageIndex()`
57
+ - `getContextToken()`
58
+ - `getPromptContent()`
59
+ - `pruneAndReorderContextMessages()`
60
+ - `restorePromptContent()`
61
+
62
+ ### Shared registries
63
+
64
+ - context-provider registry for `/supi-context`
65
+ - debug-event registry for producers that want shared debug capture
66
+ - settings registry used by `/supi-settings`
67
+
68
+ ### Project and session helpers
69
+
70
+ - project-root detection and directory walking helpers such as `findProjectRoot()` and `walkProject()`
71
+ - active-branch session helper: `getActiveBranchEntries()`
72
+ - terminal helpers such as `formatTitle()`, `signalWaiting()`, and `signalDone()`
73
+
74
+ ## Example
75
+
76
+ ```ts
77
+ import { loadSupiConfig, registerConfigSettings, wrapExtensionContext } from "@mrclrchtr/supi-core/api";
78
+
79
+ const config = loadSupiConfig("my-extension", process.cwd(), {
80
+ enabled: true,
81
+ });
82
+
83
+ registerConfigSettings({
84
+ id: "my-extension",
85
+ label: "My Extension",
86
+ section: "my-extension",
87
+ defaults: { enabled: true },
88
+ buildItems: () => [],
89
+ persistChange: () => {},
90
+ });
91
+
92
+ const message = wrapExtensionContext("my-extension", "hello", {
93
+ file: "CLAUDE.md",
94
+ turn: 1,
95
+ });
96
+ ```
97
+
98
+ ## Source
99
+
100
+ - `src/api.ts` — exported library surface
101
+ - `src/config.ts` — shared config loading and writing
102
+ - `src/config-settings.ts` — config-backed settings registration helper
103
+ - `src/settings-ui.ts` — shared settings overlay
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@mrclrchtr/supi-core",
3
+ "version": "1.11.1",
4
+ "description": "SuPi core — shared infrastructure for SuPi extensions (XML context tags, config system)",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/mrclrchtr/supi.git"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "keywords": [
14
+ "pi",
15
+ "pi-coding-agent"
16
+ ],
17
+ "files": [
18
+ "src/**/*.ts",
19
+ "!__tests__"
20
+ ],
21
+ "peerDependencies": {
22
+ "@earendil-works/pi-ai": "*",
23
+ "@earendil-works/pi-coding-agent": "*",
24
+ "@earendil-works/pi-tui": "*",
25
+ "typebox": "*"
26
+ },
27
+ "peerDependenciesMeta": {
28
+ "@earendil-works/pi-ai": {
29
+ "optional": true
30
+ },
31
+ "@earendil-works/pi-coding-agent": {
32
+ "optional": true
33
+ },
34
+ "@earendil-works/pi-tui": {
35
+ "optional": true
36
+ },
37
+ "typebox": {
38
+ "optional": true
39
+ }
40
+ },
41
+ "main": "src/api.ts",
42
+ "exports": {
43
+ "./api": "./src/api.ts",
44
+ "./config": "./src/config.ts",
45
+ "./context": "./src/context.ts",
46
+ "./debug": "./src/debug-registry.ts",
47
+ "./llm": "./src/llm.ts",
48
+ "./package.json": "./package.json",
49
+ "./path": "./src/path.ts",
50
+ "./progress-widget": "./src/progress-widget.ts",
51
+ "./project": "./src/project.ts",
52
+ "./session": "./src/session.ts",
53
+ "./settings": "./src/settings.ts",
54
+ "./settings-ui": "./src/settings-ui.ts",
55
+ "./terminal": "./src/terminal.ts",
56
+ "./tool-framework": "./src/tool-framework.ts",
57
+ "./types": "./src/types.ts"
58
+ }
59
+ }
@@ -0,0 +1,32 @@
1
+ // supi-core — shared infrastructure for SuPi extensions.
2
+ // Provides XML context tag wrapping, unified config system, context-message utilities,
3
+ // settings registry for supi-wide TUI settings, and a shared tool-spec/registration framework.
4
+ //
5
+ // Convenience barrel — re-exports all domain entry points.
6
+ // For lighter imports, use one of the domain subpaths directly
7
+ // (e.g. @mrclrchtr/supi-core/config, @mrclrchtr/supi-core/context).
8
+
9
+ // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
10
+ export * from "./config.ts";
11
+ // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
12
+ export * from "./context.ts";
13
+ // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
14
+ export * from "./debug-registry.ts";
15
+ // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
16
+ export * from "./llm.ts";
17
+ // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
18
+ export * from "./path.ts";
19
+ // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
20
+ export * from "./project.ts";
21
+ // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
22
+ export * from "./session.ts";
23
+ // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
24
+ export * from "./settings.ts";
25
+ // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
26
+ export * from "./settings-ui.ts";
27
+ // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
28
+ export * from "./terminal.ts";
29
+ // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
30
+ export * from "./tool-framework.ts";
31
+ // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
32
+ export * from "./types.ts";
@@ -0,0 +1,182 @@
1
+ // Config-aware settings helper for SuPi config-backed settings sections.
2
+ // Wraps registerSettings() and centralizes selected-scope loading + scoped persistence.
3
+ //
4
+ // Setting items can declare a `configType` ("boolean" | "number" | "stringList")
5
+ // to enable auto-generated persistChange. When all items have a configType,
6
+ // the persistChange callback can be omitted.
7
+
8
+ import type { SettingItem } from "@earendil-works/pi-tui";
9
+ import type { SettingsScope } from "../settings/settings-registry.ts";
10
+ import { registerSettings } from "../settings/settings-registry.ts";
11
+ import { loadSupiConfigForScope, removeSupiConfigKey, writeSupiConfig } from "./config.ts";
12
+
13
+ // ── Types ──────────────────────────────────────────────────────────────────
14
+
15
+ /**
16
+ * Supported config value types for declarative persistChange.
17
+ *
18
+ * - `"boolean"`: maps "on" → true, "off" → false
19
+ * - `"number"`: parses integer via Number.parseInt, falls back to unset on invalid
20
+ * - `"stringList"`: splits on comma, trims whitespace, unsets on empty
21
+ */
22
+ export type ConfigSettingType = "boolean" | "number" | "stringList";
23
+
24
+ /**
25
+ * Extended setting item that can declare its config type for auto-generated
26
+ * persistence handling.
27
+ */
28
+ export interface ConfigSettingItem extends SettingItem {
29
+ /**
30
+ * When set, persistChange for this item is auto-generated.
31
+ * All items must declare a configType for auto-generation to activate.
32
+ */
33
+ configType?: ConfigSettingType;
34
+ }
35
+
36
+ /**
37
+ * Helpers provided to the persistChange callback for writing or removing
38
+ * scoped config values.
39
+ */
40
+ export interface ConfigSettingsHelpers {
41
+ /** Write a key to the selected scope's config section. */
42
+ set(key: string, value: unknown): void;
43
+ /** Remove a key from the selected scope's config section. */
44
+ unset(key: string): void;
45
+ }
46
+
47
+ export interface ConfigSettingsOptions<T> {
48
+ /** Extension identifier — e.g. "lsp", "claude-md" */
49
+ id: string;
50
+ /** Human-readable label shown in the UI */
51
+ label: string;
52
+ /** SuPi config section name — e.g. "lsp", "claude-md" */
53
+ section: string;
54
+ /** Default config values */
55
+ defaults: T;
56
+ /**
57
+ * Build SettingItem[] from scoped config. Called by loadValues.
58
+ *
59
+ * Items can include a `configType` property for auto-generated
60
+ * persistChange handling. When ALL items declare a configType,
61
+ * the `persistChange` callback can be omitted.
62
+ */
63
+ buildItems: (settings: T, scope: SettingsScope, cwd: string) => ConfigSettingItem[];
64
+ /**
65
+ * Handle a settings change with scoped persistence helpers.
66
+ *
67
+ * Optional when all items returned by `buildItems` declare a `configType`.
68
+ * Required when any item lacks a `configType`.
69
+ */
70
+ persistChange?: (
71
+ scope: SettingsScope,
72
+ cwd: string,
73
+ settingId: string,
74
+ value: string,
75
+ helpers: ConfigSettingsHelpers,
76
+ ) => void;
77
+ /** Optional home directory for config resolution (testing). */
78
+ homeDir?: string;
79
+ }
80
+
81
+ // ── Auto-generated persistChange ───────────────────────────────────────────
82
+
83
+ function autoPersistChange(
84
+ settingId: string,
85
+ value: string,
86
+ helpers: ConfigSettingsHelpers,
87
+ items: ConfigSettingItem[],
88
+ ): void {
89
+ const item = items.find((i) => i.id === settingId);
90
+ if (!item?.configType) return;
91
+
92
+ switch (item.configType) {
93
+ case "boolean": {
94
+ helpers.set(settingId, value === "on");
95
+ break;
96
+ }
97
+ case "number": {
98
+ const num = Number.parseInt(value, 10);
99
+ if (Number.isFinite(num) && num > 0) {
100
+ helpers.set(settingId, num);
101
+ } else {
102
+ helpers.unset(settingId);
103
+ }
104
+ break;
105
+ }
106
+ case "stringList": {
107
+ const names = value
108
+ .split(",")
109
+ .map((s) => s.trim())
110
+ .filter((s) => s.length > 0);
111
+ if (names.length > 0) {
112
+ helpers.set(settingId, names);
113
+ } else {
114
+ helpers.unset(settingId);
115
+ }
116
+ break;
117
+ }
118
+ }
119
+ }
120
+
121
+ function areAllItemsDeclarative(items: ConfigSettingItem[]): boolean {
122
+ return items.length > 0 && items.every((i) => i.configType !== undefined);
123
+ }
124
+
125
+ // ── Registration ───────────────────────────────────────────────────────────
126
+
127
+ /**
128
+ * Register a config-backed settings section.
129
+ *
130
+ * Loads display values from the selected scope only (`defaults <- selected scope`)
131
+ * instead of merged effective runtime config. Provides scoped `set` / `unset`
132
+ * persistence helpers so extensions don't need to wire `writeSupiConfig` /
133
+ * `removeSupiConfigKey` by hand.
134
+ *
135
+ * When every item returned by `buildItems` declares a `configType`, the
136
+ * `persistChange` callback is optional and will be auto-generated.
137
+ */
138
+ export function registerConfigSettings<T>(options: ConfigSettingsOptions<T>): void {
139
+ let cachedItems: ConfigSettingItem[] | undefined;
140
+
141
+ registerSettings({
142
+ id: options.id,
143
+ label: options.label,
144
+ loadValues: (scope, cwd) => {
145
+ const settings = loadSupiConfigForScope(options.section, cwd, options.defaults, {
146
+ scope,
147
+ homeDir: options.homeDir,
148
+ });
149
+ const items = options.buildItems(settings, scope, cwd);
150
+ cachedItems = items;
151
+ return items;
152
+ },
153
+ persistChange: (scope, cwd, settingId, value) => {
154
+ const helpers: ConfigSettingsHelpers = {
155
+ set: (key, val) => {
156
+ writeSupiConfig(
157
+ { section: options.section, scope, cwd },
158
+ { [key]: val },
159
+ { homeDir: options.homeDir },
160
+ );
161
+ },
162
+ unset: (key) => {
163
+ removeSupiConfigKey({ section: options.section, scope, cwd }, key, {
164
+ homeDir: options.homeDir,
165
+ });
166
+ },
167
+ };
168
+
169
+ // Use manual persistChange when provided
170
+ if (options.persistChange) {
171
+ options.persistChange(scope, cwd, settingId, value, helpers);
172
+ return;
173
+ }
174
+
175
+ // Auto-generate when all items are declarative
176
+ const items = cachedItems ?? options.buildItems(options.defaults, scope, cwd);
177
+ if (areAllItemsDeclarative(items)) {
178
+ autoPersistChange(settingId, value, helpers, items);
179
+ }
180
+ },
181
+ });
182
+ }
@@ -0,0 +1,206 @@
1
+ // Shared config system for SuPi extensions.
2
+ //
3
+ // Global config: ~/.pi/agent/supi/config.json
4
+ // Project config: .pi/supi/config.json (relative to cwd)
5
+ // Resolution: hardcoded defaults ← global ← project
6
+
7
+ import * as fs from "node:fs";
8
+ import * as os from "node:os";
9
+ import * as path from "node:path";
10
+
11
+ const GLOBAL_CONFIG_DIR = ".pi/agent/supi";
12
+ const PROJECT_CONFIG_DIR = ".pi/supi";
13
+ const CONFIG_FILE = "config.json";
14
+
15
+ function getGlobalConfigPath(homeDir?: string): string {
16
+ return path.join(homeDir ?? os.homedir(), GLOBAL_CONFIG_DIR, CONFIG_FILE);
17
+ }
18
+
19
+ function getProjectConfigPath(cwd: string): string {
20
+ return path.join(cwd, PROJECT_CONFIG_DIR, CONFIG_FILE);
21
+ }
22
+
23
+ export function readJsonFile(filePath: string): Record<string, unknown> | null {
24
+ let content: string;
25
+ try {
26
+ content = fs.readFileSync(filePath, "utf-8");
27
+ } catch {
28
+ // ENOENT or permission error — silent, file may not exist
29
+ return null;
30
+ }
31
+
32
+ let parsed: unknown;
33
+ try {
34
+ parsed = JSON.parse(content);
35
+ } catch {
36
+ // biome-ignore lint/suspicious/noConsole: deliberate config parse warning
37
+ console.warn(`[supi-core] Failed to parse config file, ignoring: ${filePath}`);
38
+ return null;
39
+ }
40
+
41
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
42
+ return parsed as Record<string, unknown>;
43
+ }
44
+
45
+ // biome-ignore lint/suspicious/noConsole: deliberate config parse warning
46
+ console.warn(`[supi-core] Config file root is not an object, ignoring: ${filePath}`);
47
+ return null;
48
+ }
49
+
50
+ function shallowMerge<T>(base: T, ...overrides: Array<Record<string, unknown> | null>): T {
51
+ let result = { ...base };
52
+ for (const override of overrides) {
53
+ if (!override) continue;
54
+ result = { ...result, ...override };
55
+ }
56
+ return result;
57
+ }
58
+
59
+ export interface SupiConfigOptions {
60
+ homeDir?: string;
61
+ }
62
+
63
+ /**
64
+ * Load and merge config for a given extension section.
65
+ *
66
+ * Resolution order: defaults ← global ← project
67
+ */
68
+ export function loadSupiConfig<T>(
69
+ section: string,
70
+ cwd: string,
71
+ defaults: T,
72
+ options?: SupiConfigOptions,
73
+ ): T {
74
+ const globalConfig = readJsonFile(getGlobalConfigPath(options?.homeDir));
75
+ const projectConfig = readJsonFile(getProjectConfigPath(cwd));
76
+
77
+ const globalSection = extractSection(globalConfig, section);
78
+ const projectSection = extractSection(projectConfig, section);
79
+
80
+ return shallowMerge(defaults, globalSection, projectSection);
81
+ }
82
+
83
+ /**
84
+ * Load config for a single scope only.
85
+ *
86
+ * Resolution order: defaults ← selected scope
87
+ *
88
+ * This is useful for settings UIs that need to show the raw values stored in
89
+ * one scope, rather than the effective merged config.
90
+ */
91
+ export function loadSupiConfigForScope<T>(
92
+ section: string,
93
+ cwd: string,
94
+ defaults: T,
95
+ options: { scope: "global" | "project" } & SupiConfigOptions,
96
+ ): T {
97
+ const config =
98
+ options.scope === "global"
99
+ ? readJsonFile(getGlobalConfigPath(options.homeDir))
100
+ : readJsonFile(getProjectConfigPath(cwd));
101
+
102
+ const scopedSection = extractSection(config, section);
103
+ return shallowMerge(defaults, scopedSection);
104
+ }
105
+
106
+ export interface SupiConfigLocation {
107
+ section: string;
108
+ scope: "global" | "project";
109
+ cwd: string;
110
+ }
111
+
112
+ /**
113
+ * Write config values for a given extension section.
114
+ */
115
+ export function writeSupiConfig(
116
+ loc: SupiConfigLocation,
117
+ value: Record<string, unknown>,
118
+ options?: SupiConfigOptions,
119
+ ): void {
120
+ const configPath =
121
+ loc.scope === "global" ? getGlobalConfigPath(options?.homeDir) : getProjectConfigPath(loc.cwd);
122
+
123
+ const dir = path.dirname(configPath);
124
+ fs.mkdirSync(dir, { recursive: true });
125
+
126
+ const existing = readJsonFile(configPath) ?? {};
127
+ existing[loc.section] = {
128
+ ...((existing[loc.section] as Record<string, unknown>) ?? {}),
129
+ ...value,
130
+ };
131
+
132
+ fs.writeFileSync(configPath, `${JSON.stringify(existing, null, 2)}\n`, "utf-8");
133
+ }
134
+
135
+ /**
136
+ * Remove a key from a config section.
137
+ * Used by `interval default` to remove the project override.
138
+ */
139
+ export function removeSupiConfigKey(
140
+ loc: SupiConfigLocation,
141
+ key: string,
142
+ options?: SupiConfigOptions,
143
+ ): void {
144
+ const configPath =
145
+ loc.scope === "global" ? getGlobalConfigPath(options?.homeDir) : getProjectConfigPath(loc.cwd);
146
+
147
+ const existing = readJsonFile(configPath);
148
+ if (!existing) return;
149
+
150
+ const sectionData = existing[loc.section] as Record<string, unknown> | undefined;
151
+ if (!sectionData) return;
152
+
153
+ delete sectionData[key];
154
+
155
+ if (Object.keys(sectionData).length === 0) {
156
+ delete existing[loc.section];
157
+ }
158
+
159
+ const dir = path.dirname(configPath);
160
+ fs.mkdirSync(dir, { recursive: true });
161
+
162
+ const content = Object.keys(existing).length > 0 ? `${JSON.stringify(existing, null, 2)}\n` : "";
163
+
164
+ if (content) {
165
+ // Directory guaranteed to exist since we just read from it
166
+ fs.writeFileSync(configPath, content, "utf-8");
167
+ } else {
168
+ try {
169
+ fs.unlinkSync(configPath);
170
+ } catch {
171
+ // File may not exist
172
+ }
173
+ }
174
+ }
175
+
176
+ function extractSection(
177
+ config: Record<string, unknown> | null,
178
+ section: string,
179
+ ): Record<string, unknown> | null {
180
+ if (!config) return null;
181
+ const data = config[section];
182
+ if (typeof data === "object" && data !== null && !Array.isArray(data)) {
183
+ return data as Record<string, unknown>;
184
+ }
185
+ return null;
186
+ }
187
+
188
+ /**
189
+ * Shorthand for {@link loadSupiConfig} that infers the return type from defaults.
190
+ *
191
+ * Reduces boilerplate when a package only needs the merged runtime config.
192
+ *
193
+ * @example
194
+ * ```ts
195
+ * const config = loadSectionConfig("my-ext", cwd, { enabled: true, timeout: 30 });
196
+ * // config is typed as { enabled: boolean; timeout: number }
197
+ * ```
198
+ */
199
+ export function loadSectionConfig<T extends Record<string, unknown>>(
200
+ section: string,
201
+ cwd: string,
202
+ defaults: T,
203
+ options?: SupiConfigOptions,
204
+ ): T {
205
+ return loadSupiConfig(section, cwd, defaults, options);
206
+ }