@mrclrchtr/supi-insights 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.
Files changed (31) hide show
  1. package/README.md +234 -0
  2. package/node_modules/@mrclrchtr/supi-core/README.md +90 -0
  3. package/node_modules/@mrclrchtr/supi-core/package.json +30 -0
  4. package/node_modules/@mrclrchtr/supi-core/src/config-settings.ts +76 -0
  5. package/node_modules/@mrclrchtr/supi-core/src/config.ts +186 -0
  6. package/node_modules/@mrclrchtr/supi-core/src/context-messages.ts +119 -0
  7. package/node_modules/@mrclrchtr/supi-core/src/context-provider-registry.ts +36 -0
  8. package/node_modules/@mrclrchtr/supi-core/src/context-tag.ts +31 -0
  9. package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +255 -0
  10. package/node_modules/@mrclrchtr/supi-core/src/index.ts +83 -0
  11. package/node_modules/@mrclrchtr/supi-core/src/project-roots.ts +170 -0
  12. package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +54 -0
  13. package/node_modules/@mrclrchtr/supi-core/src/session-utils.ts +29 -0
  14. package/node_modules/@mrclrchtr/supi-core/src/settings-command.ts +15 -0
  15. package/node_modules/@mrclrchtr/supi-core/src/settings-registry.ts +41 -0
  16. package/node_modules/@mrclrchtr/supi-core/src/settings-ui.ts +226 -0
  17. package/node_modules/@mrclrchtr/supi-core/src/terminal.ts +60 -0
  18. package/package.json +47 -0
  19. package/src/aggregator.ts +245 -0
  20. package/src/cache.ts +94 -0
  21. package/src/extractor.ts +189 -0
  22. package/src/generator.ts +395 -0
  23. package/src/html.ts +481 -0
  24. package/src/index.ts +1 -0
  25. package/src/insights.ts +416 -0
  26. package/src/parser.ts +373 -0
  27. package/src/report.css +411 -0
  28. package/src/report.js +35 -0
  29. package/src/scanner.ts +13 -0
  30. package/src/types.ts +114 -0
  31. package/src/utils.ts +265 -0
package/README.md ADDED
@@ -0,0 +1,234 @@
1
+ # @mrclrchtr/supi-insights
2
+
3
+ > Usage insights and analytics for [pi](https://pi.dev) sessions. Inspired by Claude Code's `/insights` command, rebuilt for pi's extension architecture.
4
+
5
+ Generate rich, shareable HTML reports analyzing your PI coding sessions — what you work on, how you interact with the agent, what works well, where friction happens, and what to try next.
6
+
7
+ ## What you get
8
+
9
+ Running `/supi-insights` produces a report with:
10
+
11
+ - **At a Glance** — high-level summary of what's working, what's hindering you, quick wins, and ambitious workflows for future models
12
+ - **What You Work On** — project areas with session counts and descriptions
13
+ - **How You Use PI** — narrative analysis of your interaction style and key patterns
14
+ - **Impressive Things You Did** — notable workflows and accomplishments
15
+ - **Where Things Go Wrong** — friction categories with concrete examples
16
+ - **Charts & Stats** — tool usage, languages, session types, outcomes, satisfaction, response times, time-of-day patterns, tool errors, multi-session usage
17
+ - **Suggestions** — CLAUDE.md additions, features to try, new usage patterns
18
+ - **On the Horizon** — ambitious workflows to prepare for as models improve
19
+
20
+ Reports are saved as self-contained HTML files you can open in any browser.
21
+
22
+ ## Installation
23
+
24
+ ```bash
25
+ pi install npm:@mrclrchtr/supi-insights
26
+ ```
27
+
28
+ > **🧪 Beta package** — not included in the `@mrclrchtr/supi` meta-package.
29
+ > Install directly when you need session analytics.
30
+
31
+ Or install from a local checkout with `pi install /path/to/packages/supi-insights`.
32
+
33
+ ## Usage
34
+
35
+ Type `/supi-insights` in the pi editor and press Enter.
36
+
37
+ ```
38
+ /supi-insights
39
+ ```
40
+
41
+ The extension will:
42
+
43
+ 1. **Scan** all historical pi sessions across projects
44
+ 2. **Extract metadata** — tool counts, languages, git activity, lines changed, response times, errors (cached for future runs)
45
+ 3. **Extract qualitative facets** — goals, outcomes, satisfaction, friction via LLM analysis (cached)
46
+ 4. **Generate narrative insights** — coaching-style analysis in 7 parallel sections
47
+ 5. **Render an HTML report** — saved to `~/.pi/agent/supi/insights/report-{timestamp}.html`
48
+ 6. **Show a summary** — in the PI chat with a link to the full report
49
+
50
+ ### First run
51
+
52
+ The first run may take a minute or two if you have many sessions, because it:
53
+ - Parses all session JSONL files
54
+ - Extracts metadata for each session
55
+ - Runs ~50 LLM facet extractions (batched, 50 concurrent)
56
+ - Generates ~8 LLM insight sections
57
+
58
+ Subsequent runs are fast — cached metadata and facets are reused.
59
+
60
+ ## Configuration
61
+
62
+ If your install surface includes `/supi-settings` (for example when also installing the `@mrclrchtr/supi` meta-package), this package contributes an **Insights** section there. You can also edit `~/.pi/agent/supi/config.json` directly:
63
+
64
+ | Setting | Description | Default |
65
+ |---------|-------------|---------|
66
+ | `enabled` | Enable or disable insights generation | `on` |
67
+ | `maxSessions` | Maximum sessions to fully parse and analyze | `200` |
68
+ | `maxFacets` | Maximum per-session LLM facet extractions | `50` |
69
+
70
+ Example config:
71
+
72
+ ```json
73
+ {
74
+ "insights": {
75
+ "enabled": true,
76
+ "maxSessions": 200,
77
+ "maxFacets": 50
78
+ }
79
+ }
80
+ ```
81
+
82
+ ## Architecture
83
+
84
+ ```
85
+ packages/supi-insights/src/
86
+ ├── insights.ts # Extension factory — registers /supi-insights and settings
87
+ ├── scanner.ts # Session discovery via SessionManager.listAll()
88
+ ├── parser.ts # JSONL parsing, transcript extraction, tool stat aggregation
89
+ ├── extractor.ts # LLM facet extraction via @earendil-works/pi-ai/complete()
90
+ ├── aggregator.ts # Pure data aggregation + multi-clauding detection
91
+ ├── generator.ts # Parallel narrative insight generation (7 sections)
92
+ ├── html.ts # HTML report renderer with CSS bar charts
93
+ ├── cache.ts # Facet and metadata caching
94
+ ├── utils.ts # Chart helpers, label mappings, text utilities
95
+ └── types.ts # Shared TypeScript types
96
+ ```
97
+
98
+ ### Data flow
99
+
100
+ ```
101
+ SessionManager.listAll()
102
+
103
+
104
+ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
105
+ │ scanner │────▶│ parser │────▶│ cache │
106
+ └─────────────┘ └─────────────┘ └─────────────┘
107
+ │ │
108
+ │ ┌─────────────┐ │
109
+ └────────▶│ extractor │◀───────────┘
110
+ │ (LLM facets)│
111
+ └─────────────┘
112
+
113
+
114
+ ┌─────────────┐
115
+ │ aggregator │
116
+ └─────────────┘
117
+
118
+
119
+ ┌─────────────┐
120
+ │ generator │
121
+ │ (insights) │
122
+ └─────────────┘
123
+
124
+
125
+ ┌─────────────┐
126
+ │ html │
127
+ │ report │
128
+ └─────────────┘
129
+ ```
130
+
131
+ ### Key design decisions
132
+
133
+ **Direct LLM access** — Uses `@earendil-works/pi-ai/complete()` and `ctx.modelRegistry.getApiKeyAndHeaders()` to make API calls with the user's already-configured keys. No external SDK needed.
134
+
135
+ **Aggressive caching** — Session metadata and LLM-extracted facets are cached in `~/.pi/agent/supi/insights/`. Cache keys include the session file path and modified timestamp, so branch files do not collide and resumed sessions are reprocessed.
136
+
137
+ **Branch deduplication** — pi session files are append-only trees. The extension analyzes the active branch path, then keeps only the branch/file with the most user messages per session ID to avoid double-counting.
138
+
139
+ **Substantive filtering** — Sessions with fewer than 2 user messages or lasting under 1 minute are skipped, as are sessions where the only goal is `warmup_minimal`.
140
+
141
+ **Parallel processing** — Facet extractions run in batches of 50 concurrent LLM calls. Insight sections run in parallel too, with `atAGlance` generated last (it consumes outputs from all other sections).
142
+
143
+ ## Caching
144
+
145
+ Cached data lives in `~/.pi/agent/supi/insights/`:
146
+
147
+ ```
148
+ ~/.pi/agent/supi/insights/
149
+ ├── meta/
150
+ │ ├── {session-id}_{path-hash}_{modified-hash}.json # Extracted metadata
151
+ │ └── ...
152
+ ├── facets/
153
+ │ ├── {session-id}_{path-hash}_{modified-hash}.json # LLM-extracted facets
154
+ │ └── ...
155
+ └── report-{timestamp}.html # Generated HTML reports
156
+ ```
157
+
158
+ - **Metadata cache** includes: tool counts, languages, git activity, tokens, lines changed, response times, errors, feature flags
159
+ - **Facet cache** includes: goals, outcomes, satisfaction, friction, success factors, brief summaries
160
+
161
+ To force a full re-analysis, delete the cache directory:
162
+
163
+ ```bash
164
+ rm -rf ~/.pi/agent/supi/insights/meta ~/.pi/agent/supi/insights/facets
165
+ ```
166
+
167
+ ## Multi-session detection
168
+
169
+ The extension detects when you run multiple pi sessions simultaneously ("multi-clauding") using a sliding-window algorithm:
170
+
171
+ - Collects all user message timestamps across sessions
172
+ - Looks for the pattern `sessionA → sessionB → sessionA` within a 30-minute window
173
+ - Reports overlap events, sessions involved, and percentage of messages during overlaps
174
+
175
+ ## Statistics tracked
176
+
177
+ ### Per-session
178
+ - Tool usage counts
179
+ - Programming languages used (from file paths in edit/write tool calls)
180
+ - Git commits and pushes
181
+ - Input/output tokens
182
+ - Lines added/removed (via diff)
183
+ - Files modified
184
+ - User response times (time between assistant message and next user message)
185
+ - Tool errors with categorization (Command Failed, Edit Failed, User Rejected, etc.)
186
+ - User interruptions
187
+ - Feature usage (task agents, MCP, web search, web fetch)
188
+ - Message timestamps for time-of-day analysis
189
+
190
+ ### Aggregated
191
+ - Total sessions, messages, duration, tokens
192
+ - Days active, messages per day
193
+ - Top tools, languages, goals, outcomes
194
+ - Satisfaction and helpfulness distributions
195
+ - Friction types and success factors
196
+ - Response time histograms
197
+ - Time-of-day patterns
198
+ - Multi-session overlap events
199
+
200
+ ## Compared to Claude Code `/insights`
201
+
202
+ | Feature | Claude Code | /supi-insights |
203
+ |---------|-------------|---------------|
204
+ | Session discovery | Manual filesystem scan | `SessionManager.listAll()` |
205
+ | LLM access | Internal `queryWithModel()` | `@earendil-works/pi-ai/complete()` |
206
+ | Output | HTML report + browser | HTML report + browser |
207
+ | Caching | Custom `~/.claude/usage-data/` | `~/.pi/agent/supi/insights/` |
208
+ | Multi-clauding | ✅ | ✅ |
209
+ | Remote host collection | ✅ (ant-only, SCP) | ❌ (not applicable) |
210
+ | Team feedback (ant-only) | ✅ | ❌ |
211
+ | TUI dashboard | ❌ | Planned |
212
+ | Live tracking | ❌ | Planned |
213
+
214
+ ## Development
215
+
216
+ ```bash
217
+ # Typecheck
218
+ pnpm exec tsc --noEmit -p packages/supi-insights/tsconfig.json
219
+
220
+ # Test
221
+ pnpm vitest run packages/supi-insights/
222
+ ```
223
+
224
+ ## Roadmap
225
+
226
+ - [ ] **Live tracking** — accumulate stats via `tool_call`, `turn_end`, `model_select` events instead of only scanning historical sessions
227
+ - [ ] **TUI overlay dashboard** — native PI terminal UI with ASCII bar charts, keyboard-navigable sections
228
+ - [ ] **Export formats** — Markdown, JSON, CSV
229
+ - [ ] **Trend comparison** — compare current report with previous reports
230
+ - [ ] **Session drill-down** — `/supi-insights --session <id>` to analyze a specific session
231
+
232
+ ## License
233
+
234
+ MIT
@@ -0,0 +1,90 @@
1
+ # @mrclrchtr/supi-core
2
+
3
+ Shared infrastructure for SuPi packages.
4
+
5
+ ## Install
6
+
7
+ Use it as a dependency in another extension package:
8
+
9
+ ```bash
10
+ pnpm add @mrclrchtr/supi-core
11
+ ```
12
+
13
+ ## Package role
14
+
15
+ `@mrclrchtr/supi-core` is a library package. It does **not** register a pi extension and is not meant to be installed as a standalone pi package.
16
+
17
+ ## What it provides
18
+
19
+ Current exports cover:
20
+
21
+ - shared config loading, scoped reads, writes, and key removal
22
+ - config-backed settings registration helpers for `/supi-settings`
23
+ - the shared settings registry, overlay UI, and `registerSettingsCommand()` helper
24
+ - XML `<extension-context>` wrapping plus context-message utilities
25
+ - context-provider and debug-event registries reused across SuPi packages
26
+ - project root and path helpers reused by packages such as `supi-lsp`
27
+
28
+ ## Config system
29
+
30
+ Config resolution order:
31
+
32
+ ```text
33
+ defaults <- global <- project
34
+ ```
35
+
36
+ Config file locations:
37
+
38
+ - global: `~/.pi/agent/supi/config.json`
39
+ - project: `.pi/supi/config.json`
40
+
41
+ Main helpers:
42
+
43
+ - `loadSupiConfig()` — effective merged config (`defaults <- global <- project`)
44
+ - `loadSupiConfigForScope()` — raw single-scope config for settings UIs (`defaults <- selected scope`)
45
+ - `writeSupiConfig()`
46
+ - `removeSupiConfigKey()`
47
+ - `registerConfigSettings()`
48
+
49
+ ## Context and settings helpers
50
+
51
+ - `wrapExtensionContext()`
52
+ - `findLastUserMessageIndex()`
53
+ - `getContextToken()`
54
+ - `pruneAndReorderContextMessages()`
55
+ - `registerSettings()`
56
+ - `registerSettingsCommand()`
57
+ - `openSettingsOverlay()`
58
+
59
+ ## Example
60
+
61
+ ```ts
62
+ import { loadSupiConfig, registerConfigSettings, wrapExtensionContext } from "@mrclrchtr/supi-core";
63
+
64
+ const config = loadSupiConfig("my-extension", process.cwd(), {
65
+ enabled: true,
66
+ });
67
+
68
+ registerConfigSettings({
69
+ id: "my-extension",
70
+ label: "My Extension",
71
+ section: "my-extension",
72
+ defaults: { enabled: true },
73
+ buildItems: () => [],
74
+ persistChange: () => {},
75
+ });
76
+
77
+ const message = wrapExtensionContext("my-extension", "hello", {
78
+ turn: 1,
79
+ file: "CLAUDE.md",
80
+ });
81
+ ```
82
+
83
+ ## Requirements
84
+
85
+ - `@earendil-works/pi-coding-agent`
86
+ - `@earendil-works/pi-tui`
87
+
88
+ ## Source
89
+
90
+ - Main exports: `src/index.ts`
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@mrclrchtr/supi-core",
3
+ "version": "0.1.0",
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-coding-agent": "*",
23
+ "@earendil-works/pi-tui": "*"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^25.6.0",
27
+ "vitest": "^4.1.4"
28
+ },
29
+ "main": "src/index.ts"
30
+ }
@@ -0,0 +1,76 @@
1
+ // Config-aware settings helper for SuPi config-backed settings sections.
2
+ // Wraps registerSettings() and centralizes selected-scope loading + scoped persistence.
3
+
4
+ import type { SettingItem } from "@earendil-works/pi-tui";
5
+ import { loadSupiConfigForScope, removeSupiConfigKey, writeSupiConfig } from "./config.ts";
6
+ import type { SettingsScope } from "./settings-registry.ts";
7
+ import { registerSettings } from "./settings-registry.ts";
8
+
9
+ export interface ConfigSettingsHelpers {
10
+ /** Write a key to the selected scope's config section. */
11
+ set(key: string, value: unknown): void;
12
+ /** Remove a key from the selected scope's config section. */
13
+ unset(key: string): void;
14
+ }
15
+
16
+ export interface ConfigSettingsOptions<T> {
17
+ /** Extension identifier — e.g. "lsp", "claude-md" */
18
+ id: string;
19
+ /** Human-readable label shown in the UI */
20
+ label: string;
21
+ /** SuPi config section name — e.g. "lsp", "claude-md" */
22
+ section: string;
23
+ /** Default config values */
24
+ defaults: T;
25
+ /** Build SettingItem[] from scoped config. Called by loadValues. */
26
+ buildItems: (settings: T, scope: SettingsScope, cwd: string) => SettingItem[];
27
+ /** Handle a settings change with scoped persistence helpers. */
28
+ persistChange: (
29
+ scope: SettingsScope,
30
+ cwd: string,
31
+ settingId: string,
32
+ value: string,
33
+ helpers: ConfigSettingsHelpers,
34
+ ) => void;
35
+ /** Optional home directory for config resolution (testing). */
36
+ homeDir?: string;
37
+ }
38
+
39
+ /**
40
+ * Register a config-backed settings section.
41
+ *
42
+ * Loads display values from the selected scope only (`defaults <- selected scope`)
43
+ * instead of merged effective runtime config. Provides scoped `set` / `unset`
44
+ * persistence helpers so extensions don't need to wire `writeSupiConfig` /
45
+ * `removeSupiConfigKey` by hand.
46
+ */
47
+ export function registerConfigSettings<T>(options: ConfigSettingsOptions<T>): void {
48
+ registerSettings({
49
+ id: options.id,
50
+ label: options.label,
51
+ loadValues: (scope, cwd) => {
52
+ const settings = loadSupiConfigForScope(options.section, cwd, options.defaults, {
53
+ scope,
54
+ homeDir: options.homeDir,
55
+ });
56
+ return options.buildItems(settings, scope, cwd);
57
+ },
58
+ persistChange: (scope, cwd, settingId, value) => {
59
+ const helpers: ConfigSettingsHelpers = {
60
+ set: (key, val) => {
61
+ writeSupiConfig(
62
+ { section: options.section, scope, cwd },
63
+ { [key]: val },
64
+ { homeDir: options.homeDir },
65
+ );
66
+ },
67
+ unset: (key) => {
68
+ removeSupiConfigKey({ section: options.section, scope, cwd }, key, {
69
+ homeDir: options.homeDir,
70
+ });
71
+ },
72
+ };
73
+ options.persistChange(scope, cwd, settingId, value, helpers);
74
+ },
75
+ });
76
+ }
@@ -0,0 +1,186 @@
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
+ 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
+ }