@mrclrchtr/supi-web 7.1.1 → 7.2.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 (49) hide show
  1. package/README.md +19 -5
  2. package/node_modules/@mrclrchtr/supi-core/README.md +127 -0
  3. package/node_modules/@mrclrchtr/supi-core/package.json +72 -0
  4. package/node_modules/@mrclrchtr/supi-core/src/api.ts +40 -0
  5. package/node_modules/@mrclrchtr/supi-core/src/config/config.ts +232 -0
  6. package/node_modules/@mrclrchtr/supi-core/src/config/prompt-surface.ts +363 -0
  7. package/node_modules/@mrclrchtr/supi-core/src/config.ts +12 -0
  8. package/node_modules/@mrclrchtr/supi-core/src/context/context-provider-registry.ts +36 -0
  9. package/node_modules/@mrclrchtr/supi-core/src/context/context-tag.ts +31 -0
  10. package/node_modules/@mrclrchtr/supi-core/src/context.ts +8 -0
  11. package/node_modules/@mrclrchtr/supi-core/src/debug-identity.ts +11 -0
  12. package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +308 -0
  13. package/node_modules/@mrclrchtr/supi-core/src/debug-timing.ts +120 -0
  14. package/node_modules/@mrclrchtr/supi-core/src/debug.ts +14 -0
  15. package/node_modules/@mrclrchtr/supi-core/src/evidence-badge.ts +41 -0
  16. package/node_modules/@mrclrchtr/supi-core/src/footer-registry.ts +64 -0
  17. package/node_modules/@mrclrchtr/supi-core/src/index.ts +36 -0
  18. package/node_modules/@mrclrchtr/supi-core/src/llm.ts +356 -0
  19. package/node_modules/@mrclrchtr/supi-core/src/model-selection.ts +134 -0
  20. package/node_modules/@mrclrchtr/supi-core/src/path-utils.ts +44 -0
  21. package/node_modules/@mrclrchtr/supi-core/src/path.ts +2 -0
  22. package/node_modules/@mrclrchtr/supi-core/src/project-roots.ts +170 -0
  23. package/node_modules/@mrclrchtr/supi-core/src/project.ts +15 -0
  24. package/node_modules/@mrclrchtr/supi-core/src/prompt-surface.ts +4 -0
  25. package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +93 -0
  26. package/node_modules/@mrclrchtr/supi-core/src/report.ts +121 -0
  27. package/node_modules/@mrclrchtr/supi-core/src/session-utils.ts +71 -0
  28. package/node_modules/@mrclrchtr/supi-core/src/session.ts +8 -0
  29. package/node_modules/@mrclrchtr/supi-core/src/settings/settings-registry.ts +105 -0
  30. package/node_modules/@mrclrchtr/supi-core/src/settings/settings-schema.ts +453 -0
  31. package/node_modules/@mrclrchtr/supi-core/src/settings.ts +36 -0
  32. package/node_modules/@mrclrchtr/supi-core/src/spinner-frames.ts +11 -0
  33. package/node_modules/@mrclrchtr/supi-core/src/status-spinner.ts +68 -0
  34. package/node_modules/@mrclrchtr/supi-core/src/terminal.ts +60 -0
  35. package/package.json +8 -3
  36. package/src/api.ts +1 -0
  37. package/src/config.ts +29 -0
  38. package/src/settings-registration.ts +63 -0
  39. package/src/tool/tool-specs.ts +21 -0
  40. package/src/tool/web_search/bx.ts +178 -0
  41. package/src/tool/web_search/execute.ts +39 -0
  42. package/src/tool/web_search/guidance.ts +6 -0
  43. package/src/tool/web_search/input.ts +91 -0
  44. package/src/tool/web_search/parse.ts +92 -0
  45. package/src/tool/web_search/register.ts +16 -0
  46. package/src/tool/web_search/render.ts +95 -0
  47. package/src/tool/web_search/result.ts +58 -0
  48. package/src/tool/web_search/spec.ts +18 -0
  49. package/src/web.ts +26 -3
package/README.md CHANGED
@@ -4,11 +4,11 @@
4
4
  </a>
5
5
  </div>
6
6
 
7
- # @mrclrchtr/supi-web — Web Fetch and Context7 for Pi
7
+ # @mrclrchtr/supi-web — Web Fetch, Search, and Context7 for Pi
8
8
 
9
9
  [![GitHub stars](https://img.shields.io/github/stars/mrclrchtr/supi)](https://github.com/mrclrchtr/supi/stargazers) [![npm downloads](https://img.shields.io/npm/dm/@mrclrchtr/supi-web)](https://www.npmjs.com/package/@mrclrchtr/supi-web)
10
10
 
11
- Adds web fetch and Context7 documentation tools to the [Pi coding agent](https://github.com/earendil-works/pi), without making you paste sources into chat.
11
+ Adds web fetch, public web search, and Context7 documentation tools to the [Pi coding agent](https://github.com/earendil-works/pi), without making you paste sources into chat.
12
12
 
13
13
  ## What your agent gets
14
14
 
@@ -18,6 +18,7 @@ After installation, keep asking Pi normal questions. The agent can:
18
18
  - **Prefer source Markdown when available** — detect Markdown responses and common Markdown siblings before converting HTML.
19
19
  - **Handle plain-text and source files** — wrap them in fenced code blocks with a language hint when the URL provides one.
20
20
  - **Look up current library docs** — search Context7 for the right project and version, then retrieve documentation focused on the task at hand.
21
+ - **Search public web sources** — return source titles, URLs, and source excerpts without generating an answer.
21
22
  - **Protect the context window** — return small results inline, move long pages to temporary files, and preserve full output when model-visible text must be truncated.
22
23
 
23
24
  Relative page links and images become absolute by default, so the agent can follow them without reconstructing URLs.
@@ -34,16 +35,29 @@ You do not need to learn the tool-call syntax. Try asking Pi:
34
35
 
35
36
  ## Agent tools
36
37
 
37
- The package adds three tools that Pi selects as needed:
38
+ The package adds four tools that Pi selects as needed:
38
39
 
39
40
  | Tool | What it lets the agent do |
40
41
  |---|---|
41
42
  | `web_fetch_md` | Fetch a public HTTP(S) page and return readable Markdown, fenced plain text, or a temporary file |
43
+ | `web_search` | Search the public web and return source titles, URLs, and source excerpts |
42
44
  | `web_docs_search` | Find matching Context7 library IDs, versions, trust scores, benchmark scores, and snippet counts |
43
45
  | `web_docs_fetch` | Retrieve focused documentation for a chosen Context7 library ID |
44
46
 
45
47
  Pi searches Context7 first when the library ID is unknown, then fetches docs with the selected ID. Narrow questions produce more useful documentation context than broad requests. Documentation returns as Markdown by default; the agent can request structured JSON snippets when needed.
46
48
 
49
+ ## Web Search
50
+
51
+ `web_search` uses the installed `bx` executable. The tool is enabled by default, but it is available only when `bx` is on `PATH`. Search freshness accepts `pd`, `pw`, `pm`, `py`, or an ordered `YYYY-MM-DDtoYYYY-MM-DD` range.
52
+
53
+ ### Set up bx
54
+
55
+ Install `bx` and configure it with the [Brave Search CLI instructions](https://github.com/brave/brave-search-cli). Start Pi from the shell where `command -v bx` finds the executable, then verify it with `bx --version`. `bx` handles its own credentials and configuration. Do not put credentials in Pi settings, prompts, or repository files.
56
+
57
+ To enable or disable the tool, open `/supi-settings`, choose **Web Search**, and change **Web Search tool**. Run `/reload` after the change. The setting applies only when the extension starts or reloads. If `bx` was not available, install or configure it, make sure it is on `PATH`, and run `/reload`.
58
+
59
+ Search results are provider-reported source material. A source date can be a publication or modification date and is not a publication-date guarantee.
60
+
47
61
  ## How page fetching works
48
62
 
49
63
  `web_fetch_md` chooses the cleanest representation it can find:
@@ -59,7 +73,7 @@ It follows redirects, removes scripts and styles, preserves useful structures su
59
73
 
60
74
  The default `auto` mode returns content inline up to 15,000 characters and writes larger pages to a temporary Markdown file. You can ask Pi to force inline or file output.
61
75
 
62
- All three tools cap model-visible inline output at Pi's 2,000-line / 50KB limit. If output is truncated, the complete result is saved to a temporary file for follow-up reads.
76
+ All tools cap model-visible inline output at Pi's 2,000-line / 50KB limit. If output is truncated, the complete result is saved to a temporary file for follow-up reads.
63
77
 
64
78
  Tool rows stay collapsed in Pi's TUI by default. Press `Ctrl+O` to expand their inline output.
65
79
 
@@ -91,4 +105,4 @@ pi
91
105
  - `web_fetch_md` is for public `http://` and `https://` sources. It does not authenticate to login-protected, private, or paywalled pages.
92
106
  - It fetches HTTP responses and does not run a browser, page JavaScript, or interactive flows; client-rendered pages may expose little useful content.
93
107
  - When the `gh` CLI is available, Pi is guided to use it instead for GitHub URLs.
94
- - Fetched pages are external, untrusted content. Treat them as source material, not as repository instructions.
108
+ - Fetched pages and search excerpts are external, untrusted content. Treat them as source material, not as repository instructions.
@@ -0,0 +1,127 @@
1
+ <div align="center">
2
+ <a href="https://github.com/mrclrchtr/supi/tree/main/packages/supi-core">
3
+ <img src="https://raw.githubusercontent.com/mrclrchtr/supi/main/packages/supi-core/assets/social-preview.png" alt="SuPi Core" width="100%">
4
+ </a>
5
+ </div>
6
+
7
+ # @mrclrchtr/supi-core
8
+
9
+ [![GitHub stars](https://img.shields.io/github/stars/mrclrchtr/supi)](https://github.com/mrclrchtr/supi/stargazers)
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
+ - `@mrclrchtr/supi-core/llm` — PI-owned direct model requests and JSON helpers
25
+ - `@mrclrchtr/supi-core/report` — shared text/report rendering helpers for TUI and plain-text summaries
26
+
27
+ ## What you get from the API
28
+
29
+ ### Config helpers
30
+
31
+ - `loadSupiConfig()` — merged config with resolution order `defaults <- global <- project`
32
+ - `loadSupiConfigForScope()` — load one scope at a time for settings UIs
33
+ - `writeSupiConfig()` — persist values
34
+ - `replaceSupiConfigSection()` — replace one nested section while preserving other sections
35
+ - `removeSupiConfigKey()` — remove a key or override
36
+
37
+ Config file locations:
38
+
39
+ - global: `~/.pi/agent/supi/config.json`
40
+ - project: `.pi/supi/config.json`
41
+
42
+ ### Settings helpers
43
+
44
+ - `registerSettings(pi, module)` — register one canonical asynchronous settings module
45
+ - `defineConfigSettings(options)` — adapt a fixed SuPi config section to that module interface
46
+ - settings registry helpers and types for the `@mrclrchtr/supi-settings` configuration surface
47
+
48
+ ### Context helpers
49
+
50
+ - `wrapExtensionContext()` — wrap injected text in SuPi's `<extension-context>` tag
51
+
52
+ ### Model requests
53
+
54
+ - `completeModelRequest(ctx, model, context, options)` — send API-specific options through PI's model registry.
55
+ - `completeSimpleModelRequest(ctx, model, context, options)` — send provider-neutral options through `streamSimple().result()` on PI 0.86.0 or later. PI supplies model-derived output limits and context guards.
56
+ - `callWithJsonResponse()` — retry a registry request, extract JSON, and validate it with TypeBox.
57
+
58
+ Both completion helpers require a stable `affinityScope`. PI owns auth and endpoint resolution. The helpers keep cache retention defaults, exclude prompt content from the affinity ID, and add OpenCode headers only when the provider or exact model endpoint matches OpenCode. Use the simple helper when PI should manage output limits; the API-specific helper forwards the caller's limit unchanged.
59
+
60
+ ### Shared registries
61
+
62
+ - context-provider registry for `/supi-context`
63
+ - debug-event registry and monotonic phase timers for producers that want shared debug capture
64
+ - optional Debug Operation IDs for exact, directly owned public Tool-call correlation; ambient events stay uncorrelated
65
+ - settings registry used by `/supi-settings`
66
+
67
+ ### Project and session helpers
68
+
69
+ - project-root detection and directory walking helpers such as `findProjectRoot()` and `walkProject()`
70
+ - active-branch session helper: `getActiveBranchEntries()`
71
+ - terminal helpers such as `formatTitle()`, `signalWaiting()`, and `signalDone()`
72
+
73
+ ### Report helpers
74
+
75
+ - `clampReportWidth()` — enforce a minimum readable report width
76
+ - `formatReportTitle()` / `formatSectionHeader()` — shared themed headers
77
+ - `formatDimLine()` / `formatKeyValueLine()` — common summary rows
78
+ - `formatOverflowHint()` — consistent preview-overflow hints
79
+ - `wrapReportText()` — ANSI-aware wrapped report blocks with optional indentation
80
+
81
+ ## Example
82
+
83
+ ```ts
84
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
85
+ import {
86
+ defineConfigSettings,
87
+ loadSupiConfig,
88
+ registerSettings,
89
+ wrapExtensionContext,
90
+ } from "@mrclrchtr/supi-core/api";
91
+
92
+ export default function myExtension(pi: ExtensionAPI) {
93
+ const defaults = { enabled: true };
94
+ const config = loadSupiConfig("my-extension", process.cwd(), defaults);
95
+
96
+ registerSettings(
97
+ pi,
98
+ defineConfigSettings({
99
+ id: "my-extension",
100
+ label: "My Extension",
101
+ section: "my-extension",
102
+ defaults,
103
+ fields: [
104
+ {
105
+ kind: "boolean" as const,
106
+ key: "enabled",
107
+ label: "Enabled",
108
+ },
109
+ ],
110
+ }),
111
+ );
112
+
113
+ const message = wrapExtensionContext("my-extension", "hello", {
114
+ enabled: config.enabled,
115
+ });
116
+ void message;
117
+ }
118
+ ```
119
+
120
+ ## Source
121
+
122
+ - `src/api.ts` — exported library surface
123
+ - `src/config.ts` — shared config loading and writing
124
+ - `src/debug-registry.ts` — Debug domain surface, event state, retention, redaction, listeners, and queries
125
+ - `src/debug-timing.ts` — monotonic total and phase timers for Debug Event Producers
126
+ - `src/settings/` — settings registry, schema, scope resolution, and persistence
127
+ - `src/report.ts` — shared text/report rendering helpers
@@ -0,0 +1,72 @@
1
+ {
2
+ "name": "@mrclrchtr/supi-core",
3
+ "version": "7.2.0",
4
+ "description": "Shared settings, configuration, reporting, and session infrastructure",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/mrclrchtr/supi.git",
9
+ "directory": "packages/supi-core"
10
+ },
11
+ "homepage": "https://github.com/mrclrchtr/supi/tree/main/packages/supi-core#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/mrclrchtr/supi/issues"
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "keywords": [
19
+ "pi",
20
+ "pi-coding-agent",
21
+ "supi",
22
+ "extension-utils",
23
+ "settings",
24
+ "configuration"
25
+ ],
26
+ "type": "module",
27
+ "files": [
28
+ "src/**/*.ts",
29
+ "!__tests__"
30
+ ],
31
+ "peerDependencies": {
32
+ "@earendil-works/pi-ai": "*",
33
+ "@earendil-works/pi-coding-agent": "*",
34
+ "@earendil-works/pi-tui": "*",
35
+ "typebox": "*"
36
+ },
37
+ "peerDependenciesMeta": {
38
+ "@earendil-works/pi-ai": {
39
+ "optional": true
40
+ },
41
+ "@earendil-works/pi-coding-agent": {
42
+ "optional": true
43
+ },
44
+ "@earendil-works/pi-tui": {
45
+ "optional": true
46
+ },
47
+ "typebox": {
48
+ "optional": true
49
+ }
50
+ },
51
+ "main": "src/api.ts",
52
+ "exports": {
53
+ "./api": "./src/api.ts",
54
+ "./config": "./src/config.ts",
55
+ "./context": "./src/context.ts",
56
+ "./debug": "./src/debug.ts",
57
+ "./evidence-badge": "./src/evidence-badge.ts",
58
+ "./footer-registry": "./src/footer-registry.ts",
59
+ "./llm": "./src/llm.ts",
60
+ "./model-selection": "./src/model-selection.ts",
61
+ "./package.json": "./package.json",
62
+ "./path": "./src/path.ts",
63
+ "./project": "./src/project.ts",
64
+ "./prompt-surface": "./src/prompt-surface.ts",
65
+ "./report": "./src/report.ts",
66
+ "./session": "./src/session.ts",
67
+ "./settings": "./src/settings.ts",
68
+ "./spinner-frames": "./src/spinner-frames.ts",
69
+ "./status-spinner": "./src/status-spinner.ts",
70
+ "./terminal": "./src/terminal.ts"
71
+ }
72
+ }
@@ -0,0 +1,40 @@
1
+ // supi-core — shared infrastructure for SuPi extensions.
2
+ // Provides XML context tag wrapping, unified config, prompt-surface resolution,
3
+ // and shared settings, reporting, and session utilities.
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.ts";
15
+ // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
16
+ export * from "./evidence-badge.ts";
17
+ // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
18
+ export * from "./footer-registry.ts";
19
+ // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
20
+ export * from "./llm.ts";
21
+ // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
22
+ export * from "./model-selection.ts";
23
+ // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
24
+ export * from "./path.ts";
25
+ // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
26
+ export * from "./project.ts";
27
+ // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
28
+ export * from "./prompt-surface.ts";
29
+ // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
30
+ export * from "./report.ts";
31
+ // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
32
+ export * from "./session.ts";
33
+ // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
34
+ export * from "./settings.ts";
35
+ // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
36
+ export * from "./spinner-frames.ts";
37
+ // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
38
+ export * from "./status-spinner.ts";
39
+ // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
40
+ export * from "./terminal.ts";
@@ -0,0 +1,232 @@
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
+ import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
11
+
12
+ const GLOBAL_CONFIG_DIR = `${CONFIG_DIR_NAME}/agent/supi`;
13
+ const PROJECT_CONFIG_DIR = `${CONFIG_DIR_NAME}/supi`;
14
+ const CONFIG_FILE = "config.json";
15
+
16
+ function getGlobalConfigPath(homeDir?: string): string {
17
+ return path.join(homeDir ?? os.homedir(), GLOBAL_CONFIG_DIR, CONFIG_FILE);
18
+ }
19
+
20
+ function getProjectConfigPath(cwd: string): string {
21
+ return path.join(cwd, PROJECT_CONFIG_DIR, CONFIG_FILE);
22
+ }
23
+
24
+ /** Return the SuPi config file path for one scope. */
25
+ export function getSupiConfigPath(
26
+ scope: "global" | "project",
27
+ cwd: string,
28
+ options?: SupiConfigOptions,
29
+ ): string {
30
+ return scope === "global" ? getGlobalConfigPath(options?.homeDir) : getProjectConfigPath(cwd);
31
+ }
32
+
33
+ export function readJsonFile(filePath: string): Record<string, unknown> | null {
34
+ let content: string;
35
+ try {
36
+ content = fs.readFileSync(filePath, "utf-8");
37
+ } catch {
38
+ // ENOENT or permission error — silent, file may not exist
39
+ return null;
40
+ }
41
+
42
+ let parsed: unknown;
43
+ try {
44
+ parsed = JSON.parse(content);
45
+ } catch {
46
+ // biome-ignore lint/suspicious/noConsole: deliberate config parse warning
47
+ console.warn(`[supi-core] Failed to parse config file, ignoring: ${filePath}`);
48
+ return null;
49
+ }
50
+
51
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
52
+ return parsed as Record<string, unknown>;
53
+ }
54
+
55
+ // biome-ignore lint/suspicious/noConsole: deliberate config parse warning
56
+ console.warn(`[supi-core] Config file root is not an object, ignoring: ${filePath}`);
57
+ return null;
58
+ }
59
+
60
+ function shallowMerge<T>(base: T, ...overrides: Array<Record<string, unknown> | null>): T {
61
+ let result = { ...base };
62
+ for (const override of overrides) {
63
+ if (!override) continue;
64
+ result = { ...result, ...override };
65
+ }
66
+ return result;
67
+ }
68
+
69
+ export interface SupiConfigOptions {
70
+ homeDir?: string;
71
+ }
72
+
73
+ /**
74
+ * Load and merge config for a given extension section.
75
+ *
76
+ * Resolution order: defaults ← global ← project
77
+ */
78
+ export function loadSupiConfig<T>(
79
+ section: string,
80
+ cwd: string,
81
+ defaults: T,
82
+ options?: SupiConfigOptions,
83
+ ): T {
84
+ const globalConfig = readJsonFile(getGlobalConfigPath(options?.homeDir));
85
+ const projectConfig = readJsonFile(getProjectConfigPath(cwd));
86
+
87
+ const globalSection = extractSection(globalConfig, section);
88
+ const projectSection = extractSection(projectConfig, section);
89
+
90
+ return shallowMerge(defaults, globalSection, projectSection);
91
+ }
92
+
93
+ /**
94
+ * Load config for a single scope only.
95
+ *
96
+ * Resolution order: defaults ← selected scope
97
+ *
98
+ * This is useful for settings UIs that need to show the raw values stored in
99
+ * one scope, rather than the effective merged config.
100
+ */
101
+ export function loadSupiConfigForScope<T>(
102
+ section: string,
103
+ cwd: string,
104
+ defaults: T,
105
+ options: { scope: "global" | "project" } & SupiConfigOptions,
106
+ ): T {
107
+ const config = readJsonFile(getSupiConfigPath(options.scope, cwd, { homeDir: options.homeDir }));
108
+
109
+ const scopedSection = extractSection(config, section);
110
+ return shallowMerge(defaults, scopedSection);
111
+ }
112
+
113
+ /** Load the raw object for one config section and one scope. */
114
+ export function loadSupiConfigSectionForScope(
115
+ section: string,
116
+ cwd: string,
117
+ options: { scope: "global" | "project" } & SupiConfigOptions,
118
+ ): Record<string, unknown> | null {
119
+ const config = readJsonFile(getSupiConfigPath(options.scope, cwd, { homeDir: options.homeDir }));
120
+ return extractSection(config, section);
121
+ }
122
+
123
+ export interface SupiConfigLocation {
124
+ section: string;
125
+ scope: "global" | "project";
126
+ cwd: string;
127
+ }
128
+
129
+ /**
130
+ * Write config values for a given extension section.
131
+ */
132
+ export function writeSupiConfig(
133
+ loc: SupiConfigLocation,
134
+ value: Record<string, unknown>,
135
+ options?: SupiConfigOptions,
136
+ ): void {
137
+ const configPath = getSupiConfigPath(loc.scope, loc.cwd, options);
138
+
139
+ const dir = path.dirname(configPath);
140
+ fs.mkdirSync(dir, { recursive: true });
141
+
142
+ const existing = readJsonFile(configPath) ?? {};
143
+ existing[loc.section] = {
144
+ ...((existing[loc.section] as Record<string, unknown>) ?? {}),
145
+ ...value,
146
+ };
147
+
148
+ fs.writeFileSync(configPath, `${JSON.stringify(existing, null, 2)}\n`, "utf-8");
149
+ }
150
+
151
+ /**
152
+ * Replace one complete config section while preserving other sections.
153
+ *
154
+ * This is useful for nested settings that must remove stale keys as part of
155
+ * one update. An empty section is removed from the config file.
156
+ */
157
+ export function replaceSupiConfigSection(
158
+ loc: SupiConfigLocation,
159
+ value: Record<string, unknown>,
160
+ options?: SupiConfigOptions,
161
+ ): void {
162
+ const configPath = getSupiConfigPath(loc.scope, loc.cwd, options);
163
+ const existing = readJsonFile(configPath) ?? {};
164
+
165
+ if (Object.keys(value).length > 0) existing[loc.section] = value;
166
+ else delete existing[loc.section];
167
+
168
+ const content = Object.keys(existing).length > 0 ? `${JSON.stringify(existing, null, 2)}\n` : "";
169
+ if (content) {
170
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
171
+ fs.writeFileSync(configPath, content, "utf-8");
172
+ return;
173
+ }
174
+
175
+ try {
176
+ fs.unlinkSync(configPath);
177
+ } catch {
178
+ // File may not exist.
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Remove a key from a config section.
184
+ * Used by `interval default` to remove the project override.
185
+ */
186
+ export function removeSupiConfigKey(
187
+ loc: SupiConfigLocation,
188
+ key: string,
189
+ options?: SupiConfigOptions,
190
+ ): void {
191
+ const configPath = getSupiConfigPath(loc.scope, loc.cwd, options);
192
+
193
+ const existing = readJsonFile(configPath);
194
+ if (!existing) return;
195
+
196
+ const sectionData = existing[loc.section] as Record<string, unknown> | undefined;
197
+ if (!sectionData) return;
198
+
199
+ delete sectionData[key];
200
+
201
+ if (Object.keys(sectionData).length === 0) {
202
+ delete existing[loc.section];
203
+ }
204
+
205
+ const dir = path.dirname(configPath);
206
+ fs.mkdirSync(dir, { recursive: true });
207
+
208
+ const content = Object.keys(existing).length > 0 ? `${JSON.stringify(existing, null, 2)}\n` : "";
209
+
210
+ if (content) {
211
+ // Directory guaranteed to exist since we just read from it
212
+ fs.writeFileSync(configPath, content, "utf-8");
213
+ } else {
214
+ try {
215
+ fs.unlinkSync(configPath);
216
+ } catch {
217
+ // File may not exist
218
+ }
219
+ }
220
+ }
221
+
222
+ function extractSection(
223
+ config: Record<string, unknown> | null,
224
+ section: string,
225
+ ): Record<string, unknown> | null {
226
+ if (!config) return null;
227
+ const data = config[section];
228
+ if (typeof data === "object" && data !== null && !Array.isArray(data)) {
229
+ return data as Record<string, unknown>;
230
+ }
231
+ return null;
232
+ }