@mrclrchtr/supi-antigravity 6.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CLAUDE.md +21 -0
  2. package/CONTEXT.md +53 -0
  3. package/README.md +75 -0
  4. package/docs/adr/0001-use-an-isolated-antigravity-home.md +5 -0
  5. package/node_modules/@mrclrchtr/supi-core/README.md +118 -0
  6. package/node_modules/@mrclrchtr/supi-core/package.json +76 -0
  7. package/node_modules/@mrclrchtr/supi-core/src/api.ts +40 -0
  8. package/node_modules/@mrclrchtr/supi-core/src/config/config.ts +232 -0
  9. package/node_modules/@mrclrchtr/supi-core/src/config/prompt-surface.ts +363 -0
  10. package/node_modules/@mrclrchtr/supi-core/src/config.ts +12 -0
  11. package/node_modules/@mrclrchtr/supi-core/src/context/context-provider-registry.ts +36 -0
  12. package/node_modules/@mrclrchtr/supi-core/src/context/context-tag.ts +31 -0
  13. package/node_modules/@mrclrchtr/supi-core/src/context.ts +8 -0
  14. package/node_modules/@mrclrchtr/supi-core/src/debug-identity.ts +11 -0
  15. package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +308 -0
  16. package/node_modules/@mrclrchtr/supi-core/src/debug-timing.ts +120 -0
  17. package/node_modules/@mrclrchtr/supi-core/src/debug.ts +14 -0
  18. package/node_modules/@mrclrchtr/supi-core/src/evidence-badge.ts +41 -0
  19. package/node_modules/@mrclrchtr/supi-core/src/footer-registry.ts +57 -0
  20. package/node_modules/@mrclrchtr/supi-core/src/index.ts +34 -0
  21. package/node_modules/@mrclrchtr/supi-core/src/llm.ts +201 -0
  22. package/node_modules/@mrclrchtr/supi-core/src/model-selection.ts +134 -0
  23. package/node_modules/@mrclrchtr/supi-core/src/path-utils.ts +44 -0
  24. package/node_modules/@mrclrchtr/supi-core/src/path.ts +2 -0
  25. package/node_modules/@mrclrchtr/supi-core/src/project-roots.ts +170 -0
  26. package/node_modules/@mrclrchtr/supi-core/src/project.ts +15 -0
  27. package/node_modules/@mrclrchtr/supi-core/src/prompt-surface.ts +4 -0
  28. package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +93 -0
  29. package/node_modules/@mrclrchtr/supi-core/src/report.ts +121 -0
  30. package/node_modules/@mrclrchtr/supi-core/src/session-utils.ts +71 -0
  31. package/node_modules/@mrclrchtr/supi-core/src/session.ts +8 -0
  32. package/node_modules/@mrclrchtr/supi-core/src/settings/settings-registry.ts +105 -0
  33. package/node_modules/@mrclrchtr/supi-core/src/settings/settings-schema.ts +453 -0
  34. package/node_modules/@mrclrchtr/supi-core/src/settings.ts +36 -0
  35. package/node_modules/@mrclrchtr/supi-core/src/spinner-frames.ts +11 -0
  36. package/node_modules/@mrclrchtr/supi-core/src/status-spinner.ts +68 -0
  37. package/node_modules/@mrclrchtr/supi-core/src/terminal.ts +60 -0
  38. package/package.json +79 -0
  39. package/scripts/live-probe.ts +161 -0
  40. package/src/activity.ts +22 -0
  41. package/src/availability.ts +231 -0
  42. package/src/catalogue.ts +21 -0
  43. package/src/config.ts +26 -0
  44. package/src/conversation/handles.ts +183 -0
  45. package/src/extension.ts +22 -0
  46. package/src/isolated-home.ts +346 -0
  47. package/src/process/environment.ts +33 -0
  48. package/src/process/event-values.ts +279 -0
  49. package/src/process/events.ts +365 -0
  50. package/src/process/hooks.ts +219 -0
  51. package/src/process/ndjson.ts +120 -0
  52. package/src/process/protocol.ts +69 -0
  53. package/src/process/runner.ts +147 -0
  54. package/src/process/subprocess.ts +278 -0
  55. package/src/process/usage.ts +50 -0
  56. package/src/runtime.ts +118 -0
  57. package/src/settings.ts +38 -0
  58. package/src/structured-output.ts +58 -0
  59. package/src/tool/antigravity_run/evidence.ts +169 -0
  60. package/src/tool/antigravity_run/execute.ts +225 -0
  61. package/src/tool/antigravity_run/guidance.ts +3 -0
  62. package/src/tool/antigravity_run/input.ts +106 -0
  63. package/src/tool/antigravity_run/register.ts +36 -0
  64. package/src/tool/antigravity_run/render.ts +236 -0
  65. package/src/tool/antigravity_run/result.ts +186 -0
  66. package/src/tool/antigravity_run/spec.ts +20 -0
  67. package/src/types.ts +107 -0
package/CLAUDE.md ADDED
@@ -0,0 +1,21 @@
1
+ # CLAUDE.md
2
+
3
+ ## Scope
4
+
5
+ `@mrclrchtr/supi-antigravity` owns the `antigravity_run` PI tool, Antigravity CLI availability, the Isolated Antigravity Home, Consultation Workspace, evidence classification, and Conversation Handle state.
6
+
7
+ ## Design rules
8
+
9
+ - Keep the Model Catalogue immutable until the next session start or reload.
10
+ - Register `antigravity_run` only after `agy` version and curated-model discovery succeeds.
11
+ - Use the Isolated Antigravity Home for every Antigravity process. On macOS, create and unlock its private keychain, exposed as the login keychain, before starting `agy`. Do not copy the normal Antigravity profile, credentials, or PI provider environment.
12
+ - Spawn `agy` directly with an argument array. Keep stdout, stderr, tool parameters, tool output, and transcript data bounded and out of PI state.
13
+ - Treat the Inspection Permission Set as an Antigravity policy, not an operating-system sandbox. Project hooks and accepted `read_url(*)` targets remain explicit limitations.
14
+ - Keep new and follow-up inputs exact-one. Follow-ups inherit model and workspace from their Conversation Handle.
15
+ - Reject same-handle overlap before process startup. Retire a handle only after a follow-up has acquired it and started process work.
16
+ - Build model-visible result content in `tool/antigravity_run/result.ts`; renderers use structured details and never parse result content for chrome.
17
+ - Keep sources and workspace paths bounded. Distinguish observed references from claimed references.
18
+
19
+ ## Verification
20
+
21
+ Use focused tests and typechecking while changing this package. Then run `pnpm verify:ai` and package staging checks before completion. The live probe requires an explicit `SUPI_ANTIGRAVITY_LIVE=1` variable and must not run in normal tests or CI.
package/CONTEXT.md ADDED
@@ -0,0 +1,53 @@
1
+ # supi-antigravity
2
+
3
+ Runs bounded Antigravity tasks from PI while keeping workspace exposure and observed evidence explicit.
4
+
5
+ ## Language
6
+
7
+ **Antigravity Run**:
8
+ One bounded request to Antigravity that returns one answer and execution evidence. It is external to PI and is not a PI Agent Run.
9
+ _Avoid_: Agent Run, child session, Antigravity session
10
+
11
+ **Workspace Access**:
12
+ The caller's decision to expose the current PI workspace to an Antigravity Run. A run without Workspace Access uses the Consultation Workspace.
13
+ _Avoid_: consult mode, explore mode, repository mode
14
+
15
+ **Consultation Workspace**:
16
+ A package-managed empty project that lets an Antigravity Run operate without access to the current PI workspace. Its stable project identity supports user-owned Antigravity permissions without a global permission change.
17
+ _Avoid_: temporary workspace, sandbox, current workspace
18
+
19
+ **Web Capability**:
20
+ Antigravity's permission-controlled ability to retrieve external sources during an Antigravity Run. SuPi exposes and observes this capability but does not grant it.
21
+ _Avoid_: SuPi web search, automatic web access, web mode
22
+
23
+ **Isolated Antigravity Home**:
24
+ The package-owned Antigravity profile that keeps configuration, authentication, customizations, and conversation state separate from the user's normal Antigravity profile. On macOS, it also owns a private keychain exposed as the login keychain for Antigravity credentials. It is stable across Antigravity Runs so Conversation Handles can continue.
25
+ _Avoid_: user home, temporary home, sandbox
26
+
27
+ **Inspection Permission Set**:
28
+ The rules in the Isolated Antigravity Home that allow external reads and deny known file, command, MCP, and browser-actuation paths. It limits Antigravity tools but is not an operating-system sandbox, and project-local hooks remain outside this guarantee.
29
+ _Avoid_: read-only mode, safe mode, SuPi permissions
30
+
31
+ **Model Catalogue**:
32
+ The package-owned model choices that are also available to the current Antigravity account. A new Antigravity Run selects one catalogue entry explicitly.
33
+ _Avoid_: all Antigravity models, PI model list, default model
34
+
35
+ **Observed Evidence**:
36
+ Bounded execution facts derived from Antigravity tool events and validated result references. Model claims alone are not Observed Evidence.
37
+ _Avoid_: claimed evidence, answer citations, raw event stream
38
+
39
+ **Web Evidence**:
40
+ Observed Evidence that contains successful web tool activity and at least one validated source URL.
41
+ _Avoid_: source list, cited answer, web claim
42
+
43
+ **Workspace Evidence**:
44
+ Observed Evidence that contains successful workspace inspection activity and validated workspace-relative paths.
45
+ _Avoid_: code claim, file list, exploration output
46
+
47
+ **Conversation Handle**:
48
+ An opaque SuPi identifier for one Antigravity conversation that retains its original model and Workspace Access. A handle can continue only the conversation that `supi-antigravity` observed and recorded.
49
+ _Avoid_: conversation ID, session ID, resume token
50
+
51
+ **Walking Skeleton**:
52
+ The smallest production-shaped `supi-antigravity` package that can complete and verify one Antigravity Run through its intended PI tool interface. It is retained and hardened rather than discarded as prototype code.
53
+ _Avoid_: throwaway prototype, experiment, complete package
package/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # @mrclrchtr/supi-antigravity
2
+
3
+ `@mrclrchtr/supi-antigravity` adds the beta `antigravity_run` tool to Pi. It runs bounded Antigravity consultations with an explicit Model Catalogue, optional current-workspace access, controlled web use, and observed evidence.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pi install npm:@mrclrchtr/supi-antigravity
9
+ ```
10
+
11
+ The package is not part of the recommended or full SuPi installer. Install it only when you want the Antigravity integration.
12
+
13
+ Run `/reload` after installation.
14
+
15
+ ## First sign-in
16
+
17
+ The package never uses the normal Antigravity profile. It creates an Isolated Antigravity Home under the Pi agent directory and a stable empty Consultation Workspace.
18
+
19
+ Before the tool can appear, Pi shows a command like this:
20
+
21
+ ```bash
22
+ cd "<consultation-workspace>" &&
23
+ HOME="<isolated-home>" AGY_CLI_DISABLE_AUTO_UPDATE=true agy
24
+ ```
25
+
26
+ On macOS, the package also creates and unlocks a private keychain inside the Isolated Antigravity Home. This avoids the macOS warning about a missing default keychain without prompting for the normal keychain password. Run the command, sign in, exit Antigravity, and reload Pi. The installed `agy` version must be at least `1.1.24`.
27
+
28
+ If macOS displays `antigravity.` in the warning, the final `.` is sentence punctuation. It is not part of a keychain name.
29
+
30
+ ## `antigravity_run`
31
+
32
+ A new run has this shape:
33
+
34
+ ```json
35
+ {
36
+ "prompt": "Explain this API design.",
37
+ "new": {
38
+ "workspace": false,
39
+ "model": "gemini-3.8-flash-low"
40
+ }
41
+ }
42
+ ```
43
+
44
+ Set `workspace` to `true` to expose the current PI workspace. Set it to `false` to use the empty Consultation Workspace. A follow-up must use exactly one returned Conversation Handle:
45
+
46
+ ```json
47
+ {
48
+ "prompt": "Now compare the alternatives.",
49
+ "continue": { "handle": "agy_..." }
50
+ }
51
+ ```
52
+
53
+ Follow-ups keep the original model and workspace. A handle is opaque, branch-aware, and retired after a started follow-up fails or is canceled.
54
+
55
+ The result reports web use, workspace use, observed and claimed references, warnings, token use, and the Conversation Handle. Antigravity can return a claimed reference without producing matching tool activity; SuPi labels it as claimed instead of observed.
56
+
57
+ ## Security limits
58
+
59
+ The Isolated Antigravity Home enforces an Inspection Permission Set. It allows `read_url(*)` and denies file writes, commands, unsandboxed actions, MCP, and URL execution. These rules are not an operating-system sandbox.
60
+
61
+ For workspace runs, project-local hooks can run commands, read the Antigravity transcript, and cause side effects outside these permission rules. SuPi probes hook state before the paid process and warns when hooks are active or unknown.
62
+
63
+ `read_url(*)` can reach local, private-network, and link-local endpoints that Antigravity accepts. It does not enforce public-internet-only access.
64
+
65
+ Use `/supi-settings` to disable the tool. Disabling it skips availability checks and removes `antigravity_run` from the active tool list.
66
+
67
+ ## Live probe
68
+
69
+ The live probe is opt-in and never runs during normal tests or CI:
70
+
71
+ ```bash
72
+ SUPI_ANTIGRAVITY_LIVE=1 pnpm exec jiti packages/supi-antigravity/scripts/live-probe.ts
73
+ ```
74
+
75
+ It uses the isolated profile and checks web evidence, follow-up context, workspace evidence, and byte-identical fixture state.
@@ -0,0 +1,5 @@
1
+ # Use an Isolated Antigravity Home
2
+
3
+ Antigravity Runs use a stable Isolated Antigravity Home under PI agent state instead of the user's normal Antigravity profile or a new home for each run. This lets `supi-antigravity` enforce its Inspection Permission Set and exclude global customizations without changing user settings, while stable conversation state supports Conversation Handles; the trade-off is one separate sign-in and explicit manual cleanup.
4
+
5
+ On macOS, `agy` uses the system keychain for authentication. An isolated `HOME` has no default keychain until the package creates one. The package creates a package-owned `antigravity.keychain-db` with an empty password and exposes it as `Library/Keychains/login.keychain-db` through a symlink. It unlocks this private keychain before each `agy` process, without prompting for a password. It does not copy or use the normal profile's keychain.
@@ -0,0 +1,118 @@
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/report` — shared text/report rendering helpers for TUI and plain-text summaries
25
+
26
+ ## What you get from the API
27
+
28
+ ### Config helpers
29
+
30
+ - `loadSupiConfig()` — merged config with resolution order `defaults <- global <- project`
31
+ - `loadSupiConfigForScope()` — load one scope at a time for settings UIs
32
+ - `writeSupiConfig()` — persist values
33
+ - `replaceSupiConfigSection()` — replace one nested section while preserving other sections
34
+ - `removeSupiConfigKey()` — remove a key or override
35
+
36
+ Config file locations:
37
+
38
+ - global: `~/.pi/agent/supi/config.json`
39
+ - project: `.pi/supi/config.json`
40
+
41
+ ### Settings helpers
42
+
43
+ - `registerSettings(pi, module)` — register one canonical asynchronous settings module
44
+ - `defineConfigSettings(options)` — adapt a fixed SuPi config section to that module interface
45
+ - settings registry helpers and types for the `@mrclrchtr/supi-settings` configuration surface
46
+
47
+ ### Context helpers
48
+
49
+ - `wrapExtensionContext()` — wrap injected text in SuPi's `<extension-context>` tag
50
+
51
+ ### Shared registries
52
+
53
+ - context-provider registry for `/supi-context`
54
+ - debug-event registry and monotonic phase timers for producers that want shared debug capture
55
+ - optional Debug Operation IDs for exact, directly owned public Tool-call correlation; ambient events stay uncorrelated
56
+ - settings registry used by `/supi-settings`
57
+
58
+ ### Project and session helpers
59
+
60
+ - project-root detection and directory walking helpers such as `findProjectRoot()` and `walkProject()`
61
+ - active-branch session helper: `getActiveBranchEntries()`
62
+ - terminal helpers such as `formatTitle()`, `signalWaiting()`, and `signalDone()`
63
+
64
+ ### Report helpers
65
+
66
+ - `clampReportWidth()` — enforce a minimum readable report width
67
+ - `formatReportTitle()` / `formatSectionHeader()` — shared themed headers
68
+ - `formatDimLine()` / `formatKeyValueLine()` — common summary rows
69
+ - `formatOverflowHint()` — consistent preview-overflow hints
70
+ - `wrapReportText()` — ANSI-aware wrapped report blocks with optional indentation
71
+
72
+ ## Example
73
+
74
+ ```ts
75
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
76
+ import {
77
+ defineConfigSettings,
78
+ loadSupiConfig,
79
+ registerSettings,
80
+ wrapExtensionContext,
81
+ } from "@mrclrchtr/supi-core/api";
82
+
83
+ export default function myExtension(pi: ExtensionAPI) {
84
+ const defaults = { enabled: true };
85
+ const config = loadSupiConfig("my-extension", process.cwd(), defaults);
86
+
87
+ registerSettings(
88
+ pi,
89
+ defineConfigSettings({
90
+ id: "my-extension",
91
+ label: "My Extension",
92
+ section: "my-extension",
93
+ defaults,
94
+ fields: [
95
+ {
96
+ kind: "boolean" as const,
97
+ key: "enabled",
98
+ label: "Enabled",
99
+ },
100
+ ],
101
+ }),
102
+ );
103
+
104
+ const message = wrapExtensionContext("my-extension", "hello", {
105
+ enabled: config.enabled,
106
+ });
107
+ void message;
108
+ }
109
+ ```
110
+
111
+ ## Source
112
+
113
+ - `src/api.ts` — exported library surface
114
+ - `src/config.ts` — shared config loading and writing
115
+ - `src/debug-registry.ts` — Debug domain surface, event state, retention, redaction, listeners, and queries
116
+ - `src/debug-timing.ts` — monotonic total and phase timers for Debug Event Producers
117
+ - `src/settings/` — settings registry, schema, scope resolution, and persistence
118
+ - `src/report.ts` — shared text/report rendering helpers
@@ -0,0 +1,76 @@
1
+ {
2
+ "name": "@mrclrchtr/supi-core",
3
+ "version": "6.4.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
+ "devDependencies": {
52
+ "@types/node": "25.9.5",
53
+ "vitest": "4.1.11"
54
+ },
55
+ "main": "src/api.ts",
56
+ "exports": {
57
+ "./api": "./src/api.ts",
58
+ "./config": "./src/config.ts",
59
+ "./context": "./src/context.ts",
60
+ "./debug": "./src/debug.ts",
61
+ "./evidence-badge": "./src/evidence-badge.ts",
62
+ "./footer-registry": "./src/footer-registry.ts",
63
+ "./llm": "./src/llm.ts",
64
+ "./model-selection": "./src/model-selection.ts",
65
+ "./package.json": "./package.json",
66
+ "./path": "./src/path.ts",
67
+ "./project": "./src/project.ts",
68
+ "./prompt-surface": "./src/prompt-surface.ts",
69
+ "./report": "./src/report.ts",
70
+ "./session": "./src/session.ts",
71
+ "./settings": "./src/settings.ts",
72
+ "./spinner-frames": "./src/spinner-frames.ts",
73
+ "./status-spinner": "./src/status-spinner.ts",
74
+ "./terminal": "./src/terminal.ts"
75
+ }
76
+ }
@@ -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
+ }