@oh-my-pi/pi-coding-agent 12.7.6 → 12.8.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 (56) hide show
  1. package/CHANGELOG.md +37 -37
  2. package/README.md +9 -1052
  3. package/package.json +7 -7
  4. package/src/cli/args.ts +1 -0
  5. package/src/cli/update-cli.ts +49 -35
  6. package/src/cli/web-search-cli.ts +3 -2
  7. package/src/commands/web-search.ts +1 -0
  8. package/src/config/model-registry.ts +6 -0
  9. package/src/config/settings-schema.ts +25 -3
  10. package/src/config/settings.ts +1 -0
  11. package/src/extensibility/extensions/wrapper.ts +20 -13
  12. package/src/extensibility/slash-commands.ts +12 -91
  13. package/src/lsp/client.ts +24 -27
  14. package/src/lsp/index.ts +92 -42
  15. package/src/mcp/config-writer.ts +33 -0
  16. package/src/mcp/config.ts +6 -1
  17. package/src/mcp/types.ts +1 -0
  18. package/src/modes/components/custom-editor.ts +8 -5
  19. package/src/modes/components/settings-defs.ts +2 -1
  20. package/src/modes/controllers/command-controller.ts +12 -6
  21. package/src/modes/controllers/input-controller.ts +21 -186
  22. package/src/modes/controllers/mcp-command-controller.ts +60 -3
  23. package/src/modes/interactive-mode.ts +2 -2
  24. package/src/modes/types.ts +1 -1
  25. package/src/sdk.ts +23 -1
  26. package/src/secrets/index.ts +116 -0
  27. package/src/secrets/obfuscator.ts +269 -0
  28. package/src/secrets/regex.ts +21 -0
  29. package/src/session/agent-session.ts +143 -21
  30. package/src/session/compaction/branch-summarization.ts +2 -2
  31. package/src/session/compaction/compaction.ts +10 -3
  32. package/src/session/compaction/utils.ts +25 -1
  33. package/src/slash-commands/builtin-registry.ts +419 -0
  34. package/src/web/scrapers/github.ts +50 -12
  35. package/src/web/search/index.ts +5 -5
  36. package/src/web/search/provider.ts +13 -2
  37. package/src/web/search/providers/brave.ts +165 -0
  38. package/src/web/search/types.ts +1 -1
  39. package/docs/compaction.md +0 -436
  40. package/docs/config-usage.md +0 -176
  41. package/docs/custom-tools.md +0 -585
  42. package/docs/environment-variables.md +0 -257
  43. package/docs/extension-loading.md +0 -106
  44. package/docs/extensions.md +0 -1342
  45. package/docs/fs-scan-cache-architecture.md +0 -50
  46. package/docs/hooks.md +0 -906
  47. package/docs/models.md +0 -234
  48. package/docs/python-repl.md +0 -110
  49. package/docs/rpc.md +0 -1173
  50. package/docs/sdk.md +0 -1039
  51. package/docs/session-tree-plan.md +0 -84
  52. package/docs/session.md +0 -368
  53. package/docs/skills.md +0 -254
  54. package/docs/theme.md +0 -696
  55. package/docs/tree.md +0 -206
  56. package/docs/tui.md +0 -487
@@ -2,12 +2,12 @@ import * as fs from "node:fs/promises";
2
2
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
3
3
  import { copyToClipboard, readImageFromClipboard, sanitizeText } from "@oh-my-pi/pi-natives";
4
4
  import { $env } from "@oh-my-pi/pi-utils";
5
- import type { SettingPath, SettingValue } from "../../config/settings";
6
5
  import { settings } from "../../config/settings";
7
6
  import { theme } from "../../modes/theme/theme";
8
7
  import type { InteractiveModeContext } from "../../modes/types";
9
8
  import type { AgentSessionEvent } from "../../session/agent-session";
10
9
  import { SKILL_PROMPT_MESSAGE_TYPE, type SkillPromptDetails } from "../../session/messages";
10
+ import { executeBuiltinSlashCommand } from "../../slash-commands/builtin-registry";
11
11
  import { getEditorCommand, openInEditor } from "../../utils/external-editor";
12
12
  import { resizeImage } from "../../utils/image-resize";
13
13
  import { generateSessionTitle, setTerminalTitle } from "../../utils/title-generator";
@@ -24,6 +24,19 @@ export class InputController {
24
24
  constructor(private ctx: InteractiveModeContext) {}
25
25
 
26
26
  setupKeyHandlers(): void {
27
+ this.ctx.editor.shouldBypassAutocompleteOnEscape = () =>
28
+ Boolean(
29
+ this.ctx.loadingAnimation ||
30
+ this.ctx.session.isStreaming ||
31
+ this.ctx.session.isCompacting ||
32
+ this.ctx.session.isGeneratingHandoff ||
33
+ this.ctx.session.isBashRunning ||
34
+ this.ctx.session.isPythonRunning ||
35
+ this.ctx.autoCompactionLoader ||
36
+ this.ctx.retryLoader ||
37
+ this.ctx.autoCompactionEscapeHandler ||
38
+ this.ctx.retryEscapeHandler,
39
+ );
27
40
  this.ctx.editor.onEscape = () => {
28
41
  if (this.ctx.loadingAnimation) {
29
42
  this.restoreQueuedMessagesToEditor({ abort: true });
@@ -172,191 +185,13 @@ export class InputController {
172
185
 
173
186
  if (!text) return;
174
187
 
175
- // Handle slash commands
176
- if (text === "/settings") {
177
- this.ctx.showSettingsSelector();
178
- this.ctx.editor.setText("");
179
- return;
180
- }
181
- if (text === "/plan") {
182
- await this.ctx.handlePlanModeCommand();
183
- this.ctx.editor.setText("");
184
- return;
185
- }
186
- if (text === "/model" || text === "/models") {
187
- this.ctx.showModelSelector();
188
- this.ctx.editor.setText("");
189
- return;
190
- }
191
- if (text.startsWith("/export")) {
192
- await this.ctx.handleExportCommand(text);
193
- this.ctx.editor.setText("");
194
- return;
195
- }
196
- if (text === "/dump") {
197
- await this.ctx.handleDumpCommand();
198
- this.ctx.editor.setText("");
199
- return;
200
- }
201
- if (text === "/share") {
202
- await this.ctx.handleShareCommand();
203
- this.ctx.editor.setText("");
204
- return;
205
- }
206
- if (text === "/browser" || text.startsWith("/browser ")) {
207
- const arg = text.slice(8).trim().toLowerCase();
208
- const current = settings.get("browser.headless" as SettingPath) as boolean;
209
- let next = current;
210
- if (!(settings.get("browser.enabled" as SettingPath) as boolean)) {
211
- this.ctx.showWarning("Browser tool is disabled (enable in settings)");
212
- this.ctx.editor.setText("");
213
- return;
214
- }
215
- if (!arg) {
216
- next = !current;
217
- } else if (["headless", "hidden"].includes(arg)) {
218
- next = true;
219
- } else if (["visible", "show", "headful"].includes(arg)) {
220
- next = false;
221
- } else {
222
- this.ctx.showStatus("Usage: /browser [headless|visible]");
223
- this.ctx.editor.setText("");
224
- return;
225
- }
226
- settings.set("browser.headless" as SettingPath, next as SettingValue<SettingPath>);
227
- const tool = this.ctx.session.getToolByName("browser");
228
- if (tool && "restartForModeChange" in tool) {
229
- try {
230
- await (tool as { restartForModeChange: () => Promise<void> }).restartForModeChange();
231
- } catch (error) {
232
- this.ctx.showWarning(
233
- `Failed to restart browser: ${error instanceof Error ? error.message : String(error)}`,
234
- );
235
- this.ctx.editor.setText("");
236
- return;
237
- }
238
- }
239
- this.ctx.showStatus(`Browser mode: ${next ? "headless" : "visible"}`);
240
- this.ctx.editor.setText("");
241
- return;
242
- }
243
- if (text === "/copy") {
244
- await this.ctx.handleCopyCommand();
245
- this.ctx.editor.setText("");
246
- return;
247
- }
248
- if (text === "/session") {
249
- await this.ctx.handleSessionCommand();
250
- this.ctx.editor.setText("");
251
- return;
252
- }
253
- if (text === "/usage") {
254
- await this.ctx.handleUsageCommand();
255
- this.ctx.editor.setText("");
256
- return;
257
- }
258
- if (text === "/changelog") {
259
- await this.ctx.handleChangelogCommand();
260
- this.ctx.editor.setText("");
261
- return;
262
- }
263
- if (text === "/hotkeys") {
264
- this.ctx.handleHotkeysCommand();
265
- this.ctx.editor.setText("");
266
- return;
267
- }
268
- if (text === "/extensions" || text === "/status") {
269
- this.ctx.showExtensionsDashboard();
270
- this.ctx.editor.setText("");
271
- return;
272
- }
273
- if (text === "/branch") {
274
- if (settings.get("doubleEscapeAction") === "tree") {
275
- this.ctx.showTreeSelector();
276
- } else {
277
- this.ctx.showUserMessageSelector();
278
- }
279
- this.ctx.editor.setText("");
280
- return;
281
- }
282
- if (text === "/tree") {
283
- this.ctx.showTreeSelector();
284
- this.ctx.editor.setText("");
285
- return;
286
- }
287
- if (text === "/login") {
288
- this.ctx.showOAuthSelector("login");
289
- this.ctx.editor.setText("");
290
- return;
291
- }
292
- if (text === "/logout") {
293
- this.ctx.showOAuthSelector("logout");
294
- this.ctx.editor.setText("");
295
- return;
296
- }
297
- if (text === "/new") {
298
- this.ctx.editor.setText("");
299
- await this.ctx.handleClearCommand();
300
- return;
301
- }
302
- if (text === "/fork") {
303
- this.ctx.editor.setText("");
304
- await this.ctx.handleForkCommand();
305
- return;
306
- }
307
- if (text === "/move" || text.startsWith("/move ")) {
308
- const targetPath = text.slice(6).trim();
309
- if (!targetPath) {
310
- this.ctx.showError("Usage: /move <path>");
311
- this.ctx.editor.setText("");
312
- return;
313
- }
314
- this.ctx.editor.setText("");
315
- await this.ctx.handleMoveCommand(targetPath);
316
- return;
317
- }
318
- if (text === "/compact" || text.startsWith("/compact ")) {
319
- const customInstructions = text.startsWith("/compact ") ? text.slice(9).trim() : undefined;
320
- this.ctx.editor.setText("");
321
- await this.ctx.handleCompactCommand(customInstructions);
322
- return;
323
- }
324
- if (text === "/handoff" || text.startsWith("/handoff ")) {
325
- const customInstructions = text.startsWith("/handoff ") ? text.slice(9).trim() : undefined;
326
- this.ctx.editor.setText("");
327
- await this.ctx.handleHandoffCommand(customInstructions);
328
- return;
329
- }
330
- if (text === "/background" || text === "/bg") {
331
- this.ctx.editor.setText("");
332
- this.handleBackgroundCommand();
333
- return;
334
- }
335
- if (text === "/debug") {
336
- this.ctx.showDebugSelector();
337
- this.ctx.editor.setText("");
338
- return;
339
- }
340
- if (text === "/memory" || text.startsWith("/memory ")) {
341
- this.ctx.editor.setText("");
342
- await this.ctx.handleMemoryCommand(text);
343
- return;
344
- }
345
- if (text === "/resume") {
346
- this.ctx.showSessionSelector();
347
- this.ctx.editor.setText("");
348
- return;
349
- }
350
- if (text === "/quit" || text === "/exit") {
351
- this.ctx.editor.setText("");
352
- void this.ctx.shutdown();
353
- return;
354
- }
355
- // Handle MCP server management commands
356
- if (text === "/mcp" || text.startsWith("/mcp ")) {
357
- this.ctx.editor.addToHistory(text);
358
- this.ctx.editor.setText("");
359
- await this.ctx.handleMCPCommand(text);
188
+ // Handle built-in slash commands
189
+ if (
190
+ await executeBuiltinSlashCommand(text, {
191
+ ctx: this.ctx,
192
+ handleBackgroundCommand: () => this.handleBackgroundCommand(),
193
+ })
194
+ ) {
360
195
  return;
361
196
  }
362
197
 
@@ -8,7 +8,14 @@ import { getMCPConfigPath, getProjectDir } from "@oh-my-pi/pi-utils/dirs";
8
8
  import type { SourceMeta } from "../../capability/types";
9
9
  import { analyzeAuthError, discoverOAuthEndpoints, MCPManager } from "../../mcp";
10
10
  import { connectToServer, disconnectServer, listTools } from "../../mcp/client";
11
- import { addMCPServer, readMCPConfigFile, removeMCPServer, updateMCPServer } from "../../mcp/config-writer";
11
+ import {
12
+ addMCPServer,
13
+ readDisabledServers,
14
+ readMCPConfigFile,
15
+ removeMCPServer,
16
+ setServerDisabled,
17
+ updateMCPServer,
18
+ } from "../../mcp/config-writer";
12
19
  import { MCPOAuthFlow } from "../../mcp/oauth-flow";
13
20
  import type { MCPServerConfig, MCPServerConnection } from "../../mcp/types";
14
21
  import type { OAuthCredential } from "../../session/auth-storage";
@@ -774,7 +781,12 @@ export class MCPCommandController {
774
781
  }
775
782
  }
776
783
 
777
- if (userServers.length === 0 && projectServers.length === 0 && discoveredServers.length === 0) {
784
+ if (
785
+ userServers.length === 0 &&
786
+ projectServers.length === 0 &&
787
+ discoveredServers.length === 0 &&
788
+ (userConfig.disabledServers ?? []).length === 0
789
+ ) {
778
790
  this.#showMessage(
779
791
  [
780
792
  "",
@@ -868,6 +880,17 @@ export class MCPCommandController {
868
880
  lines.push("");
869
881
  }
870
882
  }
883
+
884
+ // Show servers disabled via /mcp disable (from third-party configs)
885
+ const disabledServers = await readDisabledServers(userPath);
886
+ const relevantDisabled = disabledServers.filter(n => !configServerNames.has(n));
887
+ if (relevantDisabled.length > 0) {
888
+ lines.push(theme.fg("accent", "Disabled") + theme.fg("muted", " (discovered servers):"));
889
+ for (const name of relevantDisabled) {
890
+ lines.push(` ${theme.fg("accent", name)}${theme.fg("warning", " ◌ disabled")}`);
891
+ }
892
+ lines.push("");
893
+ }
871
894
  this.#showMessage(lines.join("\n"));
872
895
  } catch (error) {
873
896
  this.ctx.showError(`Failed to list servers: ${error instanceof Error ? error.message : String(error)}`);
@@ -1061,7 +1084,41 @@ export class MCPCommandController {
1061
1084
  try {
1062
1085
  const found = await this.#findConfiguredServer(name);
1063
1086
  if (!found) {
1064
- this.ctx.showError(`Server "${name}" not found.`);
1087
+ // Check if this is a discovered server from a third-party config
1088
+ const userConfigPath = getMCPConfigPath("user", getProjectDir());
1089
+ const disabledServers = new Set(await readDisabledServers(userConfigPath));
1090
+ const isDiscovered = this.ctx.mcpManager?.getSource(name);
1091
+ const isCurrentlyDisabled = disabledServers.has(name);
1092
+ if (!isDiscovered && !isCurrentlyDisabled) {
1093
+ this.ctx.showError(`Server "${name}" not found.`);
1094
+ return;
1095
+ }
1096
+ if (isCurrentlyDisabled === !enabled) {
1097
+ this.#showMessage(
1098
+ ["", theme.fg("muted", `Server "${name}" is already ${enabled ? "enabled" : "disabled"}.`), ""].join(
1099
+ "\n",
1100
+ ),
1101
+ );
1102
+ return;
1103
+ }
1104
+ await setServerDisabled(userConfigPath, name, !enabled);
1105
+ if (enabled) {
1106
+ await this.#reloadMCP();
1107
+ const state = await this.#waitForServerConnectionWithAnimation(name);
1108
+ const status =
1109
+ state === "connected"
1110
+ ? theme.fg("success", "Connected")
1111
+ : state === "connecting"
1112
+ ? theme.fg("muted", "Connecting")
1113
+ : theme.fg("warning", "Not connected yet");
1114
+ this.#showMessage(
1115
+ ["", theme.fg("success", `\u2713 Enabled "${name}"`), "", ` Status: ${status}`, ""].join("\n"),
1116
+ );
1117
+ } else {
1118
+ await this.ctx.mcpManager?.disconnectServer(name);
1119
+ await this.ctx.session.refreshMCPTools(this.ctx.mcpManager?.getTools() ?? []);
1120
+ this.#showMessage(["", theme.fg("success", `\u2713 Disabled "${name}"`), ""].join("\n"));
1121
+ }
1065
1122
  return;
1066
1123
  }
1067
1124
 
@@ -911,8 +911,8 @@ export class InteractiveMode implements InteractiveModeContext {
911
911
  return this.#commandController.handleUsageCommand(reports);
912
912
  }
913
913
 
914
- async handleChangelogCommand(): Promise<void> {
915
- await this.#commandController.handleChangelogCommand();
914
+ async handleChangelogCommand(showFull = false): Promise<void> {
915
+ await this.#commandController.handleChangelogCommand(showFull);
916
916
  }
917
917
 
918
918
  handleHotkeysCommand(): void {
@@ -140,7 +140,7 @@ export interface InteractiveModeContext {
140
140
  handleCopyCommand(): Promise<void>;
141
141
  handleSessionCommand(): Promise<void>;
142
142
  handleUsageCommand(reports?: UsageReport[] | null): Promise<void>;
143
- handleChangelogCommand(): Promise<void>;
143
+ handleChangelogCommand(showFull?: boolean): Promise<void>;
144
144
  handleHotkeysCommand(): void;
145
145
  handleDumpCommand(): Promise<void>;
146
146
  handleClearCommand(): Promise<void>;
package/src/sdk.ts CHANGED
@@ -47,6 +47,7 @@ import {
47
47
  import { disposeAllKernelSessions } from "./ipy/executor";
48
48
  import { discoverAndLoadMCPTools, type MCPManager, type MCPToolsLoadResult } from "./mcp";
49
49
  import { buildMemoryToolDeveloperInstructions, startMemoryStartupTask } from "./memories";
50
+ import { collectEnvSecrets, loadSecrets, obfuscateMessages, SecretObfuscator } from "./secrets";
50
51
  import { AgentSession } from "./session/agent-session";
51
52
  import { AuthStorage } from "./session/auth-storage";
52
53
  import { convertToLlm } from "./session/messages";
@@ -1127,6 +1128,25 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1127
1128
  });
1128
1129
  };
1129
1130
 
1131
+ // Load and create secret obfuscator if secrets are enabled
1132
+ let obfuscator: SecretObfuscator | undefined;
1133
+ if (settings.get("secrets.enabled")) {
1134
+ const fileEntries = await loadSecrets(cwd, agentDir);
1135
+ const envEntries = collectEnvSecrets();
1136
+ const allEntries = [...envEntries, ...fileEntries];
1137
+ if (allEntries.length > 0) {
1138
+ obfuscator = new SecretObfuscator(allEntries);
1139
+ }
1140
+ time("loadSecrets");
1141
+ }
1142
+
1143
+ // Final convertToLlm: chain block-images filter with secret obfuscation
1144
+ const convertToLlmFinal = (messages: AgentMessage[]): Message[] => {
1145
+ const converted = convertToLlmWithBlockImages(messages);
1146
+ if (!obfuscator?.hasSecrets()) return converted;
1147
+ return obfuscateMessages(obfuscator, converted);
1148
+ };
1149
+
1130
1150
  const setToolUIContext = (uiContext: ExtensionUIContext, hasUI: boolean) => {
1131
1151
  toolContextStore.setUIContext(uiContext, hasUI);
1132
1152
  };
@@ -1146,7 +1166,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1146
1166
  thinkingLevel,
1147
1167
  tools: initialTools,
1148
1168
  },
1149
- convertToLlm: convertToLlmWithBlockImages,
1169
+ convertToLlm: convertToLlmFinal,
1150
1170
  sessionId: sessionManager.getSessionId(),
1151
1171
  transformContext: extensionRunner
1152
1172
  ? async messages => {
@@ -1171,6 +1191,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1171
1191
  return key;
1172
1192
  },
1173
1193
  cursorExecHandlers,
1194
+ transformToolCallArguments: obfuscator?.hasSecrets() ? args => obfuscator!.deobfuscateObject(args) : undefined,
1174
1195
  });
1175
1196
  cursorEventEmitter = event => agent.emitExternalEvent(event);
1176
1197
  debugStartup("sdk:createAgent");
@@ -1207,6 +1228,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1207
1228
  rebuildSystemPrompt,
1208
1229
  ttsrManager,
1209
1230
  forceCopilotAgentInitiator,
1231
+ obfuscator,
1210
1232
  });
1211
1233
  debugStartup("sdk:createAgentSession");
1212
1234
  time("createAgentSession");
@@ -0,0 +1,116 @@
1
+ import * as path from "node:path";
2
+ import { isEnoent, logger } from "@oh-my-pi/pi-utils";
3
+ import { YAML } from "bun";
4
+ import type { SecretEntry } from "./obfuscator";
5
+ import { compileSecretRegex } from "./regex";
6
+
7
+ export { obfuscateMessages, type SecretEntry, SecretObfuscator } from "./obfuscator";
8
+
9
+ /**
10
+ * Load secrets from project-local and global secrets.yml files.
11
+ * Project-local entries override global entries with matching content.
12
+ */
13
+ export async function loadSecrets(cwd: string, agentDir: string): Promise<SecretEntry[]> {
14
+ const projectPath = path.join(cwd, ".omp", "secrets.yml");
15
+ const globalPath = path.join(agentDir, "secrets.yml");
16
+
17
+ const globalEntries = await loadSecretsFile(globalPath);
18
+ const projectEntries = await loadSecretsFile(projectPath);
19
+
20
+ if (globalEntries.length === 0) return projectEntries;
21
+ if (projectEntries.length === 0) return globalEntries;
22
+
23
+ // Merge: project overrides global by content match
24
+ const projectContents = new Set(projectEntries.map(e => e.content));
25
+ const merged = [...globalEntries.filter(e => !projectContents.has(e.content)), ...projectEntries];
26
+ return merged;
27
+ }
28
+
29
+ /** Minimum env var value length to consider as a secret. */
30
+ const MIN_ENV_VALUE_LENGTH = 8;
31
+
32
+ /** Env var name patterns that indicate secret values. */
33
+ const SECRET_ENV_PATTERNS = /(?:KEY|SECRET|TOKEN|PASSWORD|PASS|AUTH|CREDENTIAL|PRIVATE|OAUTH)(?:_|$)/i;
34
+
35
+ /** Collect environment variable values that look like secrets. */
36
+ export function collectEnvSecrets(): SecretEntry[] {
37
+ const entries: SecretEntry[] = [];
38
+ const seen = new Set<string>();
39
+ for (const [name, value] of Object.entries(process.env)) {
40
+ if (!value || value.length < MIN_ENV_VALUE_LENGTH) continue;
41
+ if (!SECRET_ENV_PATTERNS.test(name)) continue;
42
+ if (seen.has(value)) continue;
43
+ seen.add(value);
44
+ entries.push({ type: "plain", content: value, mode: "obfuscate" });
45
+ }
46
+ return entries;
47
+ }
48
+
49
+ async function loadSecretsFile(filePath: string): Promise<SecretEntry[]> {
50
+ try {
51
+ const text = await Bun.file(filePath).text();
52
+ const raw = YAML.parse(text);
53
+ if (!Array.isArray(raw)) {
54
+ logger.warn("secrets.yml must be a YAML array", { path: filePath });
55
+ return [];
56
+ }
57
+ const entries: SecretEntry[] = [];
58
+ for (let i = 0; i < raw.length; i++) {
59
+ const entry = raw[i];
60
+ if (!validateEntry(entry, filePath, i)) continue;
61
+ entries.push({
62
+ type: entry.type,
63
+ content: entry.content,
64
+ mode: entry.mode ?? "obfuscate",
65
+ replacement: entry.replacement,
66
+ flags: entry.flags,
67
+ });
68
+ }
69
+ return entries;
70
+ } catch (err) {
71
+ if (isEnoent(err)) return [];
72
+ logger.warn("Failed to load secrets.yml", { path: filePath, error: String(err) });
73
+ return [];
74
+ }
75
+ }
76
+
77
+ function validateEntry(entry: unknown, filePath: string, index: number): entry is SecretEntry {
78
+ if (entry === null || typeof entry !== "object") {
79
+ logger.warn(`secrets.yml[${index}]: entry must be an object`, { path: filePath });
80
+ return false;
81
+ }
82
+ const e = entry as Record<string, unknown>;
83
+ if (e.type !== "plain" && e.type !== "regex") {
84
+ logger.warn(`secrets.yml[${index}]: type must be "plain" or "regex"`, { path: filePath });
85
+ return false;
86
+ }
87
+ if (typeof e.content !== "string" || e.content.length === 0) {
88
+ logger.warn(`secrets.yml[${index}]: content must be a non-empty string`, { path: filePath });
89
+ return false;
90
+ }
91
+ if (e.mode !== undefined && e.mode !== "obfuscate" && e.mode !== "replace") {
92
+ logger.warn(`secrets.yml[${index}]: mode must be "obfuscate" or "replace"`, { path: filePath });
93
+ return false;
94
+ }
95
+ if (e.replacement !== undefined && typeof e.replacement !== "string") {
96
+ logger.warn(`secrets.yml[${index}]: replacement must be a string`, { path: filePath });
97
+ return false;
98
+ }
99
+ if (e.flags !== undefined && typeof e.flags !== "string") {
100
+ logger.warn(`secrets.yml[${index}]: flags must be a string`, { path: filePath });
101
+ return false;
102
+ }
103
+ if (e.type === "regex") {
104
+ try {
105
+ compileSecretRegex(e.content as string, e.flags as string | undefined);
106
+ } catch (error) {
107
+ logger.warn(`secrets.yml[${index}]: invalid regex pattern`, {
108
+ path: filePath,
109
+ pattern: e.content,
110
+ error: String(error),
111
+ });
112
+ return false;
113
+ }
114
+ }
115
+ return true;
116
+ }