@mrclrchtr/supi-tree-sitter 1.3.1 → 1.5.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 (37) hide show
  1. package/README.md +58 -39
  2. package/node_modules/@mrclrchtr/supi-core/README.md +107 -0
  3. package/node_modules/@mrclrchtr/supi-core/package.json +44 -0
  4. package/node_modules/@mrclrchtr/supi-core/src/api.ts +85 -0
  5. package/node_modules/@mrclrchtr/supi-core/src/config/config-settings.ts +76 -0
  6. package/node_modules/@mrclrchtr/supi-core/src/config/config.ts +186 -0
  7. package/node_modules/@mrclrchtr/supi-core/src/context/context-messages.ts +119 -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/debug-registry.ts +255 -0
  11. package/node_modules/@mrclrchtr/supi-core/src/extension.ts +1 -0
  12. package/node_modules/@mrclrchtr/supi-core/src/index.ts +85 -0
  13. package/node_modules/@mrclrchtr/supi-core/src/path-utils.ts +40 -0
  14. package/node_modules/@mrclrchtr/supi-core/src/project-roots.ts +170 -0
  15. package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +86 -0
  16. package/node_modules/@mrclrchtr/supi-core/src/session-utils.ts +29 -0
  17. package/node_modules/@mrclrchtr/supi-core/src/settings/settings-command.ts +15 -0
  18. package/node_modules/@mrclrchtr/supi-core/src/settings/settings-registry.ts +41 -0
  19. package/node_modules/@mrclrchtr/supi-core/src/settings/settings-ui.ts +226 -0
  20. package/node_modules/@mrclrchtr/supi-core/src/terminal.ts +60 -0
  21. package/package.json +8 -3
  22. package/src/api.ts +6 -2
  23. package/src/index.ts +6 -2
  24. package/src/{runtime.ts → session/runtime.ts} +6 -5
  25. package/src/session/service-registry.ts +30 -0
  26. package/src/{session.ts → session/session.ts} +20 -12
  27. package/src/tool/action-specs.ts +92 -0
  28. package/src/{callees.ts → tool/callees.ts} +3 -3
  29. package/src/{exports.ts → tool/exports.ts} +4 -4
  30. package/src/{formatting.ts → tool/formatting.ts} +1 -1
  31. package/src/tool/guidance.ts +31 -0
  32. package/src/{imports.ts → tool/imports.ts} +4 -4
  33. package/src/{node-at.ts → tool/node-at.ts} +3 -3
  34. package/src/{outline.ts → tool/outline.ts} +3 -3
  35. package/src/tree-sitter.ts +118 -91
  36. package/src/types.ts +13 -2
  37. /package/src/{structure.ts → tool/structure.ts} +0 -0
package/README.md CHANGED
@@ -1,72 +1,91 @@
1
1
  # @mrclrchtr/supi-tree-sitter
2
2
 
3
- Structural code analysis for PI your agent parses code, not just text.
3
+ Adds a `tree_sitter` tool to the [pi coding agent](https://github.com/earendil-works/pi) for parser-based structural code analysis.
4
4
 
5
- Grep matches strings. Tree-sitter parses structure — functions, classes, imports, call chains. The agent stops pattern-matching and starts understanding your code.
5
+ ## Install
6
6
 
7
- ## What you get
7
+ ```bash
8
+ pi install npm:@mrclrchtr/supi-tree-sitter
9
+ ```
8
10
 
9
- ### See code structure
11
+ For local development:
10
12
 
11
- Extract functions, classes, interfaces, and methods from any file. The agent knows what lives where without reading every line.
13
+ ```bash
14
+ pi install ./packages/supi-tree-sitter
15
+ ```
12
16
 
13
- ### Trace call chains
17
+ After editing the source, run `/reload`.
14
18
 
15
- Find every function call from a given location. The agent follows the code's actual shape instead of guessing from text proximity.
19
+ ## What you get
16
20
 
17
- ### 14 languages
21
+ After install, pi gets one tool:
18
22
 
19
- JavaScript, TypeScript, Python, Rust, Go, C, C++, Java, Kotlin, Ruby, Bash, HTML, R, SQL — get structural analysis for every project you touch.
23
+ - `tree_sitter` inspect code structure through Tree-sitter parsers instead of plain text search
20
24
 
21
- Works standalone or alongside LSP. Grammar files are vendored — no native toolchain required at install time.
25
+ ## `tree_sitter` actions
22
26
 
23
- ## Install
27
+ | Action | What it is for | Current language coverage |
28
+ | --- | --- | --- |
29
+ | `outline` | List structural declarations such as functions, classes, interfaces, and methods | JavaScript / TypeScript only |
30
+ | `imports` | List import statements | JavaScript / TypeScript only |
31
+ | `exports` | List export declarations, re-exports, and export assignments | JavaScript / TypeScript only |
32
+ | `node_at` | Show the syntax node at a position, including ancestry | Any supported grammar |
33
+ | `query` | Run a custom Tree-sitter query against a file | Any supported grammar |
34
+ | `callees` | Find outgoing calls from the enclosing function or method at a position | Supported for most grammars, but not all |
24
35
 
25
- ```bash
26
- pi install npm:@mrclrchtr/supi-tree-sitter
27
- ```
36
+ Coordinates use **1-based** line and character columns. Character positions use UTF-16 code units. Relative paths resolve from the session cwd, and a leading `@` on file paths is stripped.
28
37
 
29
- ## Quick look
38
+ ## Supported file families
30
39
 
31
- The agent gets a `tree_sitter` tool with these actions:
40
+ The current tool description covers:
32
41
 
33
- | Action | What it does |
34
- |--------|-------------|
35
- | `outline` | List functions, classes, interfaces in a file |
36
- | `callees` | Find all function calls from a position |
37
- | `imports` / `exports` | See what a file imports and exports |
38
- | `node_at` | Inspect the AST node at any line/column |
39
- | `query` | Run a custom Tree-sitter query |
40
-
41
- `outline`, `imports`, and `exports` are currently JavaScript/TypeScript only. `node_at`, `query`, and `callees` work across all 14 supported languages. Coordinates are 1-based, matching the `lsp` tool convention.
42
+ - JavaScript / TypeScript (`.js`, `.jsx`, `.ts`, `.tsx`, `.mjs`, `.cjs`, `.mts`, `.cts`)
43
+ - Python (`.py`, `.pyi`)
44
+ - Rust (`.rs`)
45
+ - Go (`.go`, `.mod`)
46
+ - C / C++ (`.c`, `.h`, `.cpp`, `.hpp`, `.cc`, `.cxx`, `.hxx`, `.c++`, `.h++`)
47
+ - Java (`.java`)
48
+ - Kotlin (`.kt`, `.kts`)
49
+ - Ruby (`.rb`)
50
+ - Bash / shell (`.sh`, `.bash`, `.zsh`)
51
+ - HTML (`.html`, `.htm`, `.xhtml`)
52
+ - R (`.r`)
53
+ - SQL (`.sql`)
42
54
 
43
55
  ## Package surfaces
44
56
 
45
- - `@mrclrchtr/supi-tree-sitter/api` — reusable parsing/session API
57
+ - `@mrclrchtr/supi-tree-sitter/api` — reusable parsing session factory, shared session-scoped structural service access, and shared result types
46
58
  - `@mrclrchtr/supi-tree-sitter/extension` — pi extension entrypoint
47
59
 
48
- `pi.extensions` still points at the real file path `./src/extension.ts` inside the package. The `/api` and `/extension` paths are consumer-facing package exports, not manifest aliases.
49
-
50
- ## For extension developers
51
-
52
- This package exports a reusable session-scoped parsing service:
60
+ Owned session example:
53
61
 
54
62
  ```ts
55
63
  import { createTreeSitterSession } from "@mrclrchtr/supi-tree-sitter/api";
56
64
 
57
65
  const session = createTreeSitterSession("/project");
58
66
 
59
- // Check if a file is parseable
60
- const result = await session.canParse("src/index.ts");
61
-
62
- // Get structural outline
67
+ const parseable = await session.canParse("src/index.ts");
63
68
  const outline = await session.outline("src/index.ts");
64
-
65
- // Trace outgoing calls from a position
66
69
  const callees = await session.calleesAt("src/index.ts", 42, 10);
67
70
 
68
- // Always clean up
69
71
  session.dispose();
70
72
  ```
71
73
 
72
- Call `dispose()` when done.
74
+ Shared session-scoped service example:
75
+
76
+ ```ts
77
+ import { getSessionTreeSitterService } from "@mrclrchtr/supi-tree-sitter/api";
78
+
79
+ const state = getSessionTreeSitterService("/project");
80
+ if (state.kind === "ready") {
81
+ const outline = await state.service.outline("src/index.ts");
82
+ }
83
+ ```
84
+
85
+ ## Source
86
+
87
+ - `src/tree-sitter.ts` — tool registration and action handling
88
+ - `src/session/runtime.ts` — parser and query runtime
89
+ - `src/session/session.ts` — runtime-backed service helpers and owned session API
90
+ - `src/session/service-registry.ts` — shared session-scoped structural service registry
91
+ - `src/tool/outline.ts`, `src/tool/imports.ts`, `src/tool/exports.ts`, `src/tool/node-at.ts`, `src/tool/callees.ts` — structural analyses
@@ -0,0 +1,107 @@
1
+ # @mrclrchtr/supi-core
2
+
3
+ Shared infrastructure for SuPi extensions.
4
+
5
+ This package is mainly for extension authors. It gives you a common config system, settings plumbing, context helpers, registries, and a small extension surface that registers `/supi-settings`.
6
+
7
+ ## Install
8
+
9
+ ### As a dependency for another extension
10
+
11
+ ```bash
12
+ pnpm add @mrclrchtr/supi-core
13
+ ```
14
+
15
+ ### As a pi package
16
+
17
+ ```bash
18
+ pi install npm:@mrclrchtr/supi-core
19
+ ```
20
+
21
+ Installing it as a pi package adds the minimal `/supi-settings` extension surface.
22
+
23
+ ## Package surfaces
24
+
25
+ - `@mrclrchtr/supi-core/api` — reusable helpers for other packages and extensions
26
+ - `@mrclrchtr/supi-core/extension` — minimal pi extension that registers `/supi-settings`
27
+
28
+ ## What you get from the API
29
+
30
+ ### Config helpers
31
+
32
+ - `loadSupiConfig()` — merged config with resolution order `defaults <- global <- project`
33
+ - `loadSupiConfigForScope()` — load one scope at a time for settings UIs
34
+ - `writeSupiConfig()` — persist values
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()` — register an arbitrary settings section
45
+ - `registerConfigSettings()` — register a config-backed settings section with scoped persistence helpers
46
+ - `registerSettingsCommand()` — register `/supi-settings`
47
+ - `openSettingsOverlay()` — open the shared settings UI directly
48
+ - `createInputSubmenu()` — helper for simple text-entry submenus
49
+
50
+ The built-in settings UI supports:
51
+
52
+ - project/global scope toggle
53
+ - grouped extension sections
54
+ - searchable setting lists
55
+
56
+ ### Context helpers
57
+
58
+ - `wrapExtensionContext()` — wrap injected text in SuPi's `<extension-context>` tag
59
+ - `findLastUserMessageIndex()`
60
+ - `getContextToken()`
61
+ - `getPromptContent()`
62
+ - `pruneAndReorderContextMessages()`
63
+ - `restorePromptContent()`
64
+
65
+ ### Shared registries
66
+
67
+ - context-provider registry for `/supi-context`
68
+ - debug-event registry for producers that want shared debug capture
69
+ - settings registry used by `/supi-settings`
70
+
71
+ ### Project and session helpers
72
+
73
+ - project-root detection and directory walking helpers such as `findProjectRoot()` and `walkProject()`
74
+ - active-branch session helper: `getActiveBranchEntries()`
75
+ - terminal helpers such as `formatTitle()`, `signalWaiting()`, and `signalDone()`
76
+
77
+ ## Example
78
+
79
+ ```ts
80
+ import { loadSupiConfig, registerConfigSettings, wrapExtensionContext } from "@mrclrchtr/supi-core/api";
81
+
82
+ const config = loadSupiConfig("my-extension", process.cwd(), {
83
+ enabled: true,
84
+ });
85
+
86
+ registerConfigSettings({
87
+ id: "my-extension",
88
+ label: "My Extension",
89
+ section: "my-extension",
90
+ defaults: { enabled: true },
91
+ buildItems: () => [],
92
+ persistChange: () => {},
93
+ });
94
+
95
+ const message = wrapExtensionContext("my-extension", "hello", {
96
+ file: "CLAUDE.md",
97
+ turn: 1,
98
+ });
99
+ ```
100
+
101
+ ## Source
102
+
103
+ - `src/api.ts` — exported library surface
104
+ - `src/extension.ts` — minimal `/supi-settings` entrypoint
105
+ - `src/config.ts` — shared config loading and writing
106
+ - `src/config-settings.ts` — config-backed settings registration helper
107
+ - `src/settings-ui.ts` — shared settings overlay
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@mrclrchtr/supi-core",
3
+ "version": "1.5.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
+ "peerDependenciesMeta": {
26
+ "@earendil-works/pi-coding-agent": {
27
+ "optional": true
28
+ },
29
+ "@earendil-works/pi-tui": {
30
+ "optional": true
31
+ }
32
+ },
33
+ "main": "src/api.ts",
34
+ "exports": {
35
+ "./api": "./src/api.ts",
36
+ "./extension": "./src/extension.ts",
37
+ "./package.json": "./package.json"
38
+ },
39
+ "pi": {
40
+ "extensions": [
41
+ "./src/extension.ts"
42
+ ]
43
+ }
44
+ }
@@ -0,0 +1,85 @@
1
+ // supi-core — shared infrastructure for SuPi extensions.
2
+ // Provides XML context tag wrapping, unified config system, context-message utilities,
3
+ // and settings registry for supi-wide TUI settings.
4
+
5
+ export type { SupiConfigLocation, SupiConfigOptions } from "./config/config.ts";
6
+ export {
7
+ loadSupiConfig,
8
+ loadSupiConfigForScope,
9
+ removeSupiConfigKey,
10
+ writeSupiConfig,
11
+ } from "./config/config.ts";
12
+ export type { ConfigSettingsHelpers, ConfigSettingsOptions } from "./config/config-settings.ts";
13
+ export { registerConfigSettings } from "./config/config-settings.ts";
14
+ export type { ContextMessageLike } from "./context/context-messages.ts";
15
+ export {
16
+ findLastUserMessageIndex,
17
+ getContextToken,
18
+ getPromptContent,
19
+ pruneAndReorderContextMessages,
20
+ restorePromptContent,
21
+ } from "./context/context-messages.ts";
22
+ export type { ContextProvider } from "./context/context-provider-registry.ts";
23
+ export {
24
+ clearRegisteredContextProviders,
25
+ getRegisteredContextProviders,
26
+ registerContextProvider,
27
+ } from "./context/context-provider-registry.ts";
28
+ export { wrapExtensionContext } from "./context/context-tag.ts";
29
+ export type {
30
+ DebugAgentAccess,
31
+ DebugEvent,
32
+ DebugEventInput,
33
+ DebugEventQuery,
34
+ DebugEventQueryResult,
35
+ DebugEventView,
36
+ DebugLevel,
37
+ DebugNotifyLevel,
38
+ DebugRegistryConfig,
39
+ DebugSummary,
40
+ } from "./debug-registry.ts";
41
+ export {
42
+ clearDebugEvents,
43
+ configureDebugRegistry,
44
+ DEBUG_REGISTRY_DEFAULTS,
45
+ getDebugEvents,
46
+ getDebugRegistryConfig,
47
+ getDebugSummary,
48
+ recordDebugEvent,
49
+ redactDebugData,
50
+ resetDebugRegistry,
51
+ } from "./debug-registry.ts";
52
+ export { fileToUri, resolveToolPath, stripToolPathPrefix, uriToFile } from "./path-utils.ts";
53
+ export type { KnownRootEntry } from "./project-roots.ts";
54
+ export {
55
+ buildKnownRootsMap,
56
+ byPathDepth,
57
+ dedupeTopmostRoots,
58
+ findProjectRoot,
59
+ isWithin,
60
+ isWithinOrEqual,
61
+ mergeKnownRoots,
62
+ resolveKnownRoot,
63
+ segmentCount,
64
+ sortRootsBySpecificity,
65
+ walkProject,
66
+ } from "./project-roots.ts";
67
+ export { createRegistry, createSessionStateRegistry } from "./registry-utils.ts";
68
+ export { getActiveBranchEntries } from "./session-utils.ts";
69
+ export { registerSettingsCommand } from "./settings/settings-command.ts";
70
+ export type { SettingsScope, SettingsSection } from "./settings/settings-registry.ts";
71
+ export {
72
+ clearRegisteredSettings,
73
+ getRegisteredSettings,
74
+ registerSettings,
75
+ } from "./settings/settings-registry.ts";
76
+ export { createInputSubmenu, openSettingsOverlay } from "./settings/settings-ui.ts";
77
+ export type { TitleTarget } from "./terminal.ts";
78
+ export {
79
+ DONE_SYMBOL,
80
+ formatTitle,
81
+ signalBell,
82
+ signalDone,
83
+ signalWaiting,
84
+ WAITING_SYMBOL,
85
+ } from "./terminal.ts";
@@ -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 type { SettingsScope } from "../settings/settings-registry.ts";
6
+ import { registerSettings } from "../settings/settings-registry.ts";
7
+ import { loadSupiConfigForScope, removeSupiConfigKey, writeSupiConfig } from "./config.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
+ }