pi-lean-portal 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 (55) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +608 -0
  3. package/backends/chromium/index.ts +50 -0
  4. package/backends/chromium-py/bridge.py +67 -0
  5. package/backends/firefox/index.ts +60 -0
  6. package/backends/firefox-py/bridge.py +64 -0
  7. package/backends/playwright-base/playwright-plugin.ts +1294 -0
  8. package/backends/python-adapter.ts +1141 -0
  9. package/backends/python-base/pi_browser_bridge/__init__.py +71 -0
  10. package/backends/python-base/pi_browser_bridge/accessibility.py +408 -0
  11. package/backends/python-base/pi_browser_bridge/bot_detection.py +115 -0
  12. package/backends/python-base/pi_browser_bridge/bridge.py +598 -0
  13. package/backends/python-base/pi_browser_bridge/playwright_base.py +1222 -0
  14. package/backends/python-base/pi_browser_bridge/transport.py +167 -0
  15. package/backends/python-base/pyproject.toml +15 -0
  16. package/browser-cookies.ts +88 -0
  17. package/browser-profile.ts +260 -0
  18. package/browser-status.ts +84 -0
  19. package/browser-toggle.ts +527 -0
  20. package/core/fetch-backend.ts +466 -0
  21. package/core/guides.ts +467 -0
  22. package/core/plugin-api.ts +302 -0
  23. package/core/plugin-config.ts +388 -0
  24. package/core/plugin-registry.ts +263 -0
  25. package/core/router.ts +1186 -0
  26. package/core/shared/accessibility-tree.ts +408 -0
  27. package/core/shared/bot-detection.ts +187 -0
  28. package/core/shared/browser-events.ts +111 -0
  29. package/core/shared/dom-extractor.ts +550 -0
  30. package/core/shared/nav-settle.ts +187 -0
  31. package/core/shared/paths.ts +56 -0
  32. package/core/shared/session-manager.ts +258 -0
  33. package/core/shared/settings-reader.ts +63 -0
  34. package/core/shared/snapshot-cache.ts +231 -0
  35. package/core/shared/storage-state.ts +560 -0
  36. package/core/shared/task-id.ts +77 -0
  37. package/core/shared/url-safety.ts +164 -0
  38. package/index.ts +253 -0
  39. package/package.json +63 -0
  40. package/ship-manifest.test.ts +12 -0
  41. package/tools/browser-back.ts +50 -0
  42. package/tools/browser-click.ts +74 -0
  43. package/tools/browser-console.ts +160 -0
  44. package/tools/browser-inspect.ts +136 -0
  45. package/tools/browser-navigate.ts +254 -0
  46. package/tools/browser-press.ts +80 -0
  47. package/tools/browser-scroll.ts +56 -0
  48. package/tools/browser-snapshot.ts +90 -0
  49. package/tools/browser-type.ts +60 -0
  50. package/tools/index.ts +19 -0
  51. package/tools/utils.ts +157 -0
  52. package/tools/web-fetch.ts +147 -0
  53. package/tools/web-guide.ts +55 -0
  54. package/tools/web-learn.ts +128 -0
  55. package/verify-ship-manifest.ts +126 -0
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Navigation settle detection for the browser extension.
3
+ *
4
+ * After a user interaction (click, press) that may or may not trigger
5
+ * a page navigation, this module provides a reliable way to wait for
6
+ * the page to settle before reading URL / title / snapshot.
7
+ *
8
+ * The core insight: instead of a fixed sleep (e.g. `waitForTimeout(300)`)
9
+ * that races against navigation commit, we listen for the actual
10
+ * `framenavigated` event and wait for page readiness (load + networkidle)
11
+ * only when navigation has actually started.
12
+ *
13
+ * This eliminates the URL / DOM mismatch that causes stale-@e-ref and
14
+ * mismatched URL/content bugs.
15
+ */
16
+
17
+ // ─── Public types ───────────────────────────────────────────────────
18
+
19
+ /**
20
+ * Minimal Page-like interface sufficient for navigation settle detection.
21
+ *
22
+ * The Playwright `Page` type structurally satisfies this interface, so
23
+ * callers can pass a real `Page` directly. Mocks in unit tests implement
24
+ * this interface to avoid importing Playwright.
25
+ */
26
+ export interface NavigationSettlePage {
27
+ url(): string;
28
+ mainFrame(): unknown;
29
+ on(event: string, handler: (...args: unknown[]) => void): void;
30
+ off(event: string, handler: (...args: unknown[]) => void): void;
31
+ waitForTimeout(ms: number): Promise<void>;
32
+ waitForLoadState(
33
+ state?: "load" | "domcontentloaded" | "networkidle",
34
+ options?: { timeout?: number },
35
+ ): Promise<void>;
36
+ }
37
+
38
+ /** Options for `waitForNavigationSettle`. */
39
+ export interface NavigationSettleOptions {
40
+ /**
41
+ * Maximum time (ms) to wait for page readiness (load + networkidle)
42
+ * when a navigation is detected. Shared budget for both waits.
43
+ * Default: 5000.
44
+ */
45
+ navTimeoutMs?: number;
46
+ /**
47
+ * Short settle delay (ms) when no navigation occurs. This allows
48
+ * client-side rerenders / animations to finish. Default: 400.
49
+ */
50
+ settleTimeoutMs?: number;
51
+ }
52
+
53
+ /** Result of `waitForNavigationSettle`. */
54
+ export interface NavigationSettleResult {
55
+ /** Whether a `framenavigated` event was observed on the main frame. */
56
+ navigated: boolean;
57
+ /** The page URL after settling (guaranteed consistent with DOM). */
58
+ url: string;
59
+ }
60
+
61
+ // ─── Helpers ────────────────────────────────────────────────────────
62
+
63
+ /**
64
+ * Wait for the page to be fully ready after a navigation.
65
+ *
66
+ * Calls `waitForLoadState("load")` for the document + sub-resources,
67
+ * then also waits for network idle so that SPA same-document route
68
+ * changes (pushState) settle before we snapshot. Errors/timeouts
69
+ * are swallowed — the caller proceeds with whatever state it has.
70
+ */
71
+ async function waitForPageReady(
72
+ page: NavigationSettlePage,
73
+ timeout: number,
74
+ ): Promise<void> {
75
+ // Cross-document or same-document navigation started.
76
+ // waitForLoadState("load") resolves after the document and its
77
+ // sub-resources load; for same-document pushState it resolves
78
+ // immediately since "load" already fired.
79
+ await page.waitForLoadState("load", { timeout }).catch(() => {
80
+ // Timeout or error — continue anyway. The caller gets the
81
+ // current URL and can decide how to proceed.
82
+ });
83
+
84
+ // For SPA route changes (pushState/replaceState), "load" is already
85
+ // satisfied and resolves instantly — the SPA may still be fetching
86
+ // data. A bounded networkidle wait catches the XHR/fetch burst
87
+ // without hanging on long-polling sites.
88
+ await page.waitForLoadState("networkidle", { timeout }).catch(() => {
89
+ // Timeout or error — continue regardless.
90
+ });
91
+ }
92
+
93
+ // ─── Public API ────────────────────────────────────────────────────
94
+
95
+ /**
96
+ * Wait for navigation to settle after a user interaction (click, press,
97
+ * etc.) that may or may not trigger a page navigation.
98
+ *
99
+ * **Usage:**
100
+ * ```ts
101
+ * const urlBefore = page.url();
102
+ * await locator.click();
103
+ * const { navigated, url } = await waitForNavigationSettle(page, urlBefore);
104
+ * ```
105
+ *
106
+ * **How it works:**
107
+ * 1. Registers a `framenavigated` listener on the page *before* settling.
108
+ * 2. Races a brief window (~150 ms) against the `framenavigated` event
109
+ * so that instant navigations don't pay the full window.
110
+ * 3. If the main frame navigated (cross-document or same-document via
111
+ * `pushState`), calls `waitForPageReady` for load + bounded networkidle
112
+ * (capped at `navTimeoutMs` each).
113
+ * 4. Falls back to URL-change detection if the URL changed without a
114
+ * `framenavigated` event (defense-in-depth).
115
+ * 5. Otherwise, waits `settleTimeoutMs` for client-side rerenders.
116
+ * 6. A late-arrival gate catches navigations that start during the settle
117
+ * window (e.g. an async click handler with setTimeout).
118
+ *
119
+ * The returned URL is always read **after** settling, guaranteeing
120
+ * consistency between the URL and the current DOM.
121
+ *
122
+ * @param page - A Page-like object (real Playwright Page or mock).
123
+ * @param urlBefore - The page URL before the interaction.
124
+ * @param opts - Optional timeout overrides.
125
+ * @returns `{ navigated, url }` after the page has settled.
126
+ */
127
+ export async function waitForNavigationSettle(
128
+ page: NavigationSettlePage,
129
+ urlBefore: string,
130
+ opts?: NavigationSettleOptions,
131
+ ): Promise<NavigationSettleResult> {
132
+ const navTimeout = opts?.navTimeoutMs ?? 5000;
133
+ const settleTimeout = opts?.settleTimeoutMs ?? 400;
134
+
135
+ let navigated = false;
136
+
137
+ // Deferred promise that resolves when framenavigated fires on the main
138
+ // frame. We race this against the detection window so that instant
139
+ // navigations don't pay the full 150ms.
140
+ let navResolve: () => void = () => {};
141
+ const navStarted = new Promise<void>((resolve) => {
142
+ navResolve = resolve;
143
+ });
144
+
145
+ const onNav = (frame: unknown) => {
146
+ if (frame === page.mainFrame()) {
147
+ navigated = true;
148
+ navResolve();
149
+ }
150
+ };
151
+
152
+ page.on("framenavigated", onNav);
153
+
154
+ try {
155
+ // Race the detection window against the framenavigated event.
156
+ // Most link clicks and Enter presses trigger navigation within one
157
+ // event-loop tick; the race means we don't waste time when nav
158
+ // starts immediately.
159
+ await Promise.race([page.waitForTimeout(150), navStarted]);
160
+
161
+ let waitedForLoad = false;
162
+ if (navigated) {
163
+ await waitForPageReady(page, navTimeout);
164
+ waitedForLoad = true;
165
+ } else if (page.url() !== urlBefore) {
166
+ // URL changed without a framenavigated event (possible edge case).
167
+ await waitForPageReady(page, navTimeout);
168
+ waitedForLoad = true;
169
+ } else {
170
+ // No navigation detected yet — allow client-side rerenders /
171
+ // scrolling to settle before reading the snapshot.
172
+ await page.waitForTimeout(settleTimeout);
173
+ }
174
+
175
+ // Late-arrival gate: a navigation may have started during the settle
176
+ // timeout (e.g. an async click handler with a 200ms setTimeout). If
177
+ // we didn't already call waitForPageReady but navigated has since
178
+ // become true (or the URL changed), wait for readiness now.
179
+ if (!waitedForLoad && (navigated || page.url() !== urlBefore)) {
180
+ await waitForPageReady(page, navTimeout);
181
+ }
182
+ } finally {
183
+ page.off("framenavigated", onNav);
184
+ }
185
+
186
+ return { navigated, url: page.url() };
187
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Shared Path & Filename Utilities — single source of truth for temp
3
+ * file directories and task ID sanitization.
4
+ *
5
+ * Previously these constants and helpers were duplicated across
6
+ * snapshot-cache.ts, fetch-backend.ts, and router.ts. Consolidating
7
+ * them here prevents divergence and simplifies changes.
8
+ *
9
+ * @module paths
10
+ */
11
+
12
+ import { tmpdir, homedir } from "node:os";
13
+ import { mkdirSync } from "node:fs";
14
+ import { join } from "node:path";
15
+
16
+ /** Base temp directory for all pi-lean-portal ephemeral files. */
17
+ export const BROWSER_TEMP_DIR = `${tmpdir()}/pi-lean-portal`;
18
+
19
+ /**
20
+ * Portal-owned data root under ~/.pi/agent/, namespaced by package name.
21
+ * Houses user-authored guides, browser profiles, and any other runtime
22
+ * user data that must survive package upgrades.
23
+ * Siblings: sessions/ (pi-core owned — DO NOT move under this).
24
+ */
25
+ export const PORTAL_DATA_DIR = join(
26
+ homedir(),
27
+ ".pi",
28
+ "agent",
29
+ "pi-lean-portal",
30
+ );
31
+
32
+ /**
33
+ * Sanitize a taskId (or any string) for use in filenames.
34
+ * Replaces any character that is not alphanumeric or hyphen with `_`.
35
+ *
36
+ * Note: `taskId()` from `task-id.ts` always returns filename-safe values
37
+ * (`browser-N` or `browser-default`), so callers can skip this on its
38
+ * output. This function is useful for sanitizing arbitrary strings such
39
+ * as raw pi session IDs.
40
+ */
41
+ export function safeTaskId(taskId: string): string {
42
+ return taskId.replace(/[^a-zA-Z0-9-]/g, "_");
43
+ }
44
+
45
+ /**
46
+ * Ensure the browser temp directory exists (best-effort).
47
+ * Creates the directory recursively. Silently ignores errors so that
48
+ * callers don't need their own try-catch blocks.
49
+ */
50
+ export function ensureBrowserTempDir(): void {
51
+ try {
52
+ mkdirSync(BROWSER_TEMP_DIR, { recursive: true });
53
+ } catch {
54
+ /* best-effort */
55
+ }
56
+ }
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Session manager — tracks browser session lifecycle per task_id.
3
+ *
4
+ * Design: sessions track metadata; browsers and contexts are managed
5
+ * entirely by the plugin. The session manager is Playwright-agnostic.
6
+ */
7
+
8
+ /** Runtime state of a single browsing session */
9
+ export interface BrowserSession {
10
+ taskId: string;
11
+ /** Name of the plugin this session is bound to (set once, never changes) */
12
+ pluginName: string;
13
+ /** The URL currently loaded in this session */
14
+ currentUrl?: string;
15
+ /** Page title if available */
16
+ currentTitle?: string;
17
+ /** Stable hash of the last snapshot's accessibility tree (for DOM-change detection) */
18
+ currentSnapshotFingerprint?: string;
19
+ /** Timestamp when element cache was last populated (for staleness detection) */
20
+ cachePopulatedAt?: number;
21
+ /** Timestamp of the last interaction that may have mutated the DOM */
22
+ lastInteractionAt?: number;
23
+ /** Timestamp of last activity */
24
+ lastActive: number;
25
+ /** Whether the session has crashed and needs recovery */
26
+ crashed: boolean;
27
+ /** Whether to auto-save storage state on cleanup */
28
+ persistState?: boolean;
29
+ /** Profile name used for this session (undefined = default) */
30
+ profileName?: string;
31
+ /** pi session ID for session-scoped profiles */
32
+ piSessionId?: string;
33
+ }
34
+
35
+ /** Stored last navigation for a task (used to auto-recover sessions) */
36
+ interface LastNavEntry {
37
+ url: string;
38
+ title: string;
39
+ /** Plugin name that was used for the original navigation */
40
+ pluginName: string;
41
+ /** Profile name active during this navigation (for restoring state on recovery) */
42
+ profileName?: string;
43
+ }
44
+
45
+ class SessionManager {
46
+ #sessions = new Map<string, BrowserSession>();
47
+ /** Last navigation URL per task (survives session removal, cleared explicitly) */
48
+ #lastNav = new Map<string, LastNavEntry>();
49
+
50
+ createSession(taskId: string, pluginName: string): BrowserSession {
51
+ const existing = this.#sessions.get(taskId);
52
+ if (existing) {
53
+ existing.pluginName = pluginName;
54
+ delete existing.currentUrl;
55
+ delete existing.currentTitle;
56
+ existing.lastActive = Date.now();
57
+ existing.crashed = false;
58
+ return existing;
59
+ }
60
+ const session: BrowserSession = {
61
+ taskId,
62
+ pluginName,
63
+ lastActive: Date.now(),
64
+ crashed: false,
65
+ };
66
+ this.#sessions.set(taskId, session);
67
+ return session;
68
+ }
69
+
70
+ getSession(taskId: string): BrowserSession | undefined {
71
+ return this.#sessions.get(taskId);
72
+ }
73
+
74
+ updateSession(
75
+ taskId: string,
76
+ updates: Partial<
77
+ Pick<
78
+ BrowserSession,
79
+ | "currentUrl"
80
+ | "currentTitle"
81
+ | "pluginName"
82
+ | "crashed"
83
+ | "currentSnapshotFingerprint"
84
+ | "cachePopulatedAt"
85
+ | "lastInteractionAt"
86
+ | "persistState"
87
+ | "profileName"
88
+ | "piSessionId"
89
+ >
90
+ >,
91
+ ): void {
92
+ const session = this.#sessions.get(taskId);
93
+ if (session) {
94
+ if (updates.currentUrl !== undefined)
95
+ session.currentUrl = updates.currentUrl;
96
+ if (updates.currentTitle !== undefined)
97
+ session.currentTitle = updates.currentTitle;
98
+ if (updates.pluginName !== undefined)
99
+ session.pluginName = updates.pluginName;
100
+ if (updates.crashed !== undefined) session.crashed = updates.crashed;
101
+ if (updates.currentSnapshotFingerprint !== undefined)
102
+ session.currentSnapshotFingerprint = updates.currentSnapshotFingerprint;
103
+ if (updates.cachePopulatedAt !== undefined)
104
+ session.cachePopulatedAt = updates.cachePopulatedAt;
105
+ if (updates.lastInteractionAt !== undefined)
106
+ session.lastInteractionAt = updates.lastInteractionAt;
107
+ if (updates.persistState !== undefined)
108
+ session.persistState = updates.persistState;
109
+ if (updates.profileName !== undefined)
110
+ session.profileName = updates.profileName;
111
+ if (updates.piSessionId !== undefined)
112
+ session.piSessionId = updates.piSessionId;
113
+ session.lastActive = Date.now();
114
+ }
115
+ }
116
+
117
+ // ─── Last navigation storage (for session auto-recovery) ───
118
+
119
+ setLastNav(
120
+ taskId: string,
121
+ url: string,
122
+ title: string,
123
+ pluginName: string,
124
+ profileName?: string,
125
+ ): void {
126
+ const entry: LastNavEntry = { url, title, pluginName };
127
+ if (profileName !== undefined) {
128
+ entry.profileName = profileName;
129
+ }
130
+ this.#lastNav.set(taskId, entry);
131
+ }
132
+
133
+ getLastNav(taskId: string): LastNavEntry | undefined {
134
+ return this.#lastNav.get(taskId);
135
+ }
136
+
137
+ clearLastNav(taskId: string): void {
138
+ this.#lastNav.delete(taskId);
139
+ }
140
+
141
+ // ─── Session lifecycle ────────────────────────────────────────────
142
+
143
+ removeSession(taskId: string): void {
144
+ this.#sessions.delete(taskId);
145
+ this.#lastNav.delete(taskId);
146
+ }
147
+
148
+ async removeAll(): Promise<void> {
149
+ this.#sessions.clear();
150
+ this.#lastNav.clear();
151
+ }
152
+
153
+ /**
154
+ * Get a display symbol for a plugin name.
155
+ * Known plugins get short symbols; unknown plugins get the first 3 chars.
156
+ */
157
+ pluginSymbol(pluginName: string): string {
158
+ switch (pluginName) {
159
+ case "chromium":
160
+ return "PW";
161
+ case "firefox":
162
+ return "FF";
163
+ default:
164
+ // Return up to 3 uppercase chars for custom plugins
165
+ return pluginName.slice(0, 3).toUpperCase();
166
+ }
167
+ }
168
+
169
+ getStatus(): string {
170
+ const active = this.getActiveSessions();
171
+ const crashed = Array.from(this.#sessions.values()).filter(
172
+ (s) => s.crashed,
173
+ );
174
+
175
+ if (active.length === 0) {
176
+ if (crashed.length > 0) {
177
+ return `${crashed.length} crashed`;
178
+ }
179
+ return "idle";
180
+ }
181
+ if (active.length === 1) {
182
+ const s = active[0]!;
183
+ const domain = s.currentUrl ? extractDomain(s.currentUrl) : undefined;
184
+ const sym = this.pluginSymbol(s.pluginName);
185
+ const profileTag = s.profileName
186
+ ? ` [${profileDisplayName(s.profileName)}]`
187
+ : "";
188
+ let status = domain
189
+ ? `${sym}: ${domain}${profileTag}`
190
+ : `${sym}${profileTag}`;
191
+ if (crashed.length > 0) {
192
+ status += ` · ${crashed.length} crashed`;
193
+ }
194
+ return status;
195
+ }
196
+ // Multiple active sessions — group by profile
197
+ const byProfile = new Map<string, number>();
198
+ for (const s of active) {
199
+ const key = s.profileName ?? "ephemeral";
200
+ byProfile.set(key, (byProfile.get(key) ?? 0) + 1);
201
+ }
202
+ const profileParts = Array.from(byProfile.entries()).map(([name, count]) =>
203
+ count > 1
204
+ ? `${profileDisplayName(name)}×${count}`
205
+ : profileDisplayName(name),
206
+ );
207
+ const sym = this.pluginSymbol(active[0]!.pluginName);
208
+ let status = `${active.length} active (${sym}): ${profileParts.join(", ")}`;
209
+ if (crashed.length > 0) {
210
+ status += ` · ${crashed.length} crashed`;
211
+ }
212
+ return status;
213
+ }
214
+
215
+ getActiveSessions(): BrowserSession[] {
216
+ return Array.from(this.#sessions.values()).filter(
217
+ (s) => s.currentUrl && !s.crashed,
218
+ );
219
+ }
220
+
221
+ get activeCount(): number {
222
+ return this.getActiveSessions().length;
223
+ }
224
+
225
+ /**
226
+ * Find the taskId for a given pi session ID.
227
+ * Used by command handlers (like `/web cookies`) to resolve the correct
228
+ * taskId without duplicating index.ts's monotonic counter logic.
229
+ */
230
+ getTaskIdForPiSessionId(piSessionId: string): string | undefined {
231
+ for (const [taskId, session] of this.#sessions.entries()) {
232
+ if (session.piSessionId === piSessionId) return taskId;
233
+ }
234
+ return undefined;
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Short display name for a profile in the TUI status bar.
240
+ * Session-scoped profiles render as a "📋 session" badge;
241
+ * named profiles show their name;
242
+ * "ephemeral" (no profile) label shows as-is.
243
+ */
244
+ function profileDisplayName(name: string): string {
245
+ if (name.startsWith("_session-")) return "📋 session";
246
+ if (name === "ephemeral") return "ephemeral";
247
+ return name;
248
+ }
249
+
250
+ function extractDomain(url: string): string {
251
+ try {
252
+ return new URL(url).hostname;
253
+ } catch {
254
+ return url;
255
+ }
256
+ }
257
+
258
+ export const sessionManager = new SessionManager();
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Shared settings.json reader — used by multiple modules that need to
3
+ * read pi's settings.json files (global and project-local).
4
+ *
5
+ * Provides a unified `readSettingsFile()` function and canonical path
6
+ * constants to prevent drift across the codebase.
7
+ *
8
+ * @module
9
+ */
10
+
11
+ import { existsSync, readFileSync } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { join } from "node:path";
14
+
15
+ // ─── Config paths ─────────────────────────────────────────────────
16
+
17
+ /** Global pi settings path. */
18
+ export const GLOBAL_SETTINGS_PATH = join(
19
+ homedir(),
20
+ ".pi",
21
+ "agent",
22
+ "settings.json",
23
+ );
24
+
25
+ /** Project pi settings path (relative to cwd). */
26
+ export const PROJECT_SETTINGS_PATH = ".pi/settings.json";
27
+
28
+ // ─── Reader ────────────────────────────────────────────────────────
29
+
30
+ /**
31
+ * Read and parse a JSON settings file. Returns {} on any failure.
32
+ */
33
+ export function readSettingsFile(path: string): Record<string, unknown> {
34
+ try {
35
+ if (!existsSync(path)) return {};
36
+ const raw = readFileSync(path, "utf-8");
37
+ const parsed = JSON.parse(raw);
38
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
39
+ return parsed as Record<string, unknown>;
40
+ }
41
+ return {};
42
+ } catch {
43
+ return {};
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Read global + project settings and merge them (project overrides global).
49
+ *
50
+ * Convenience wrapper so consumers don't duplicate the merge pattern.
51
+ *
52
+ * @param globalPath - Path to the global settings file (defaults to GLOBAL_SETTINGS_PATH)
53
+ * @param projectPath - Path to the project settings file (defaults to PROJECT_SETTINGS_PATH)
54
+ * @returns Merged settings object with project values taking precedence
55
+ */
56
+ export function readMergedSettings(
57
+ globalPath: string = GLOBAL_SETTINGS_PATH,
58
+ projectPath: string = PROJECT_SETTINGS_PATH,
59
+ ): Record<string, unknown> {
60
+ const global = readSettingsFile(globalPath);
61
+ const project = readSettingsFile(projectPath);
62
+ return { ...global, ...project };
63
+ }