@iinm/plain-agent 1.0.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 (79) hide show
  1. package/.config/agents.library/code-simplifier.md +5 -0
  2. package/.config/agents.library/qa-engineer.md +74 -0
  3. package/.config/agents.library/software-architect.md +278 -0
  4. package/.config/agents.predefined/worker.md +3 -0
  5. package/.config/config.predefined.json +825 -0
  6. package/.config/prompts.library/code-review.md +8 -0
  7. package/.config/prompts.library/feature-dev.md +6 -0
  8. package/.config/prompts.predefined/shortcuts/commit-by-user.md +9 -0
  9. package/.config/prompts.predefined/shortcuts/commit.md +10 -0
  10. package/.config/prompts.predefined/shortcuts/general-question.md +6 -0
  11. package/LICENSE +21 -0
  12. package/README.md +624 -0
  13. package/bin/plain +3 -0
  14. package/bin/plain-interrupt +6 -0
  15. package/bin/plain-notify-desktop +19 -0
  16. package/bin/plain-notify-terminal-bell +3 -0
  17. package/package.json +57 -0
  18. package/sandbox/bin/plain-sandbox +972 -0
  19. package/src/agent.d.ts +48 -0
  20. package/src/agent.mjs +159 -0
  21. package/src/agentLoop.mjs +369 -0
  22. package/src/agentState.mjs +41 -0
  23. package/src/cliArgs.mjs +45 -0
  24. package/src/cliFormatter.mjs +217 -0
  25. package/src/cliInteractive.mjs +739 -0
  26. package/src/config.d.ts +48 -0
  27. package/src/config.mjs +168 -0
  28. package/src/context/consumeInterruptMessage.mjs +30 -0
  29. package/src/context/loadAgentRoles.mjs +272 -0
  30. package/src/context/loadPrompts.mjs +312 -0
  31. package/src/context/loadUserMessageContext.mjs +147 -0
  32. package/src/env.mjs +46 -0
  33. package/src/main.mjs +202 -0
  34. package/src/mcp.mjs +202 -0
  35. package/src/model.d.ts +109 -0
  36. package/src/modelCaller.mjs +29 -0
  37. package/src/modelDefinition.d.ts +73 -0
  38. package/src/prompt.mjs +128 -0
  39. package/src/providers/anthropic.d.ts +248 -0
  40. package/src/providers/anthropic.mjs +596 -0
  41. package/src/providers/gemini.d.ts +208 -0
  42. package/src/providers/gemini.mjs +752 -0
  43. package/src/providers/openai.d.ts +281 -0
  44. package/src/providers/openai.mjs +551 -0
  45. package/src/providers/openaiCompatible.d.ts +147 -0
  46. package/src/providers/openaiCompatible.mjs +658 -0
  47. package/src/providers/platform/azure.mjs +42 -0
  48. package/src/providers/platform/bedrock.mjs +74 -0
  49. package/src/providers/platform/googleCloud.mjs +34 -0
  50. package/src/subagent.mjs +247 -0
  51. package/src/tmpfile.mjs +27 -0
  52. package/src/tool.d.ts +74 -0
  53. package/src/toolExecutor.mjs +236 -0
  54. package/src/toolInputValidator.mjs +183 -0
  55. package/src/toolUseApprover.mjs +98 -0
  56. package/src/tools/askGoogle.mjs +135 -0
  57. package/src/tools/delegateToSubagent.d.ts +4 -0
  58. package/src/tools/delegateToSubagent.mjs +48 -0
  59. package/src/tools/execCommand.d.ts +22 -0
  60. package/src/tools/execCommand.mjs +200 -0
  61. package/src/tools/fetchWebPage.mjs +96 -0
  62. package/src/tools/patchFile.d.ts +4 -0
  63. package/src/tools/patchFile.mjs +96 -0
  64. package/src/tools/reportAsSubagent.d.ts +3 -0
  65. package/src/tools/reportAsSubagent.mjs +44 -0
  66. package/src/tools/tavilySearch.d.ts +6 -0
  67. package/src/tools/tavilySearch.mjs +57 -0
  68. package/src/tools/tmuxCommand.d.ts +14 -0
  69. package/src/tools/tmuxCommand.mjs +194 -0
  70. package/src/tools/writeFile.d.ts +4 -0
  71. package/src/tools/writeFile.mjs +56 -0
  72. package/src/utils/evalJSONConfig.mjs +48 -0
  73. package/src/utils/matchValue.d.ts +6 -0
  74. package/src/utils/matchValue.mjs +40 -0
  75. package/src/utils/noThrow.mjs +31 -0
  76. package/src/utils/notify.mjs +28 -0
  77. package/src/utils/parseFileRange.mjs +18 -0
  78. package/src/utils/readFileRange.mjs +33 -0
  79. package/src/utils/retryOnError.mjs +41 -0
@@ -0,0 +1,96 @@
1
+ /**
2
+ * @import { Tool } from '../tool'
3
+ */
4
+
5
+ import { writeTmpFile } from "../tmpfile.mjs";
6
+ import { noThrow } from "../utils/noThrow.mjs";
7
+
8
+ const MAX_CONTENT_LENGTH = 1024 * 8;
9
+
10
+ /** @type {Tool} */
11
+ export const fetchWebPageTool = {
12
+ def: {
13
+ name: "fetch_web_page",
14
+ description:
15
+ "Fetch and extract web page content from a given URL, returning it as Markdown.",
16
+ inputSchema: {
17
+ type: "object",
18
+ properties: {
19
+ url: {
20
+ type: "string",
21
+ },
22
+ },
23
+ required: ["url"],
24
+ },
25
+ },
26
+
27
+ /**
28
+ * @param {Record<string, unknown>} input
29
+ * @returns {Record<string, unknown>}
30
+ */
31
+ maskApprovalInput: (input) => {
32
+ try {
33
+ const url = new URL(String(input.url));
34
+ return { url: url.hostname };
35
+ } catch {
36
+ return input;
37
+ }
38
+ },
39
+
40
+ /**
41
+ * @param {{url: string}} input
42
+ * @returns {Promise<string | Error>}
43
+ */
44
+ impl: async (input) =>
45
+ await noThrow(async () => {
46
+ const { Readability } = await import("@mozilla/readability");
47
+ const { JSDOM } = await import("jsdom");
48
+ const TurndownService = (await import("turndown")).default;
49
+
50
+ const response = await fetch(input.url, {
51
+ signal: AbortSignal.timeout(30 * 1000),
52
+ });
53
+ const html = await response.text();
54
+ const dom = new JSDOM(html, { url: input.url });
55
+ const reader = new Readability(dom.window.document);
56
+ const article = reader.parse();
57
+
58
+ if (!article?.content) {
59
+ return "";
60
+ }
61
+
62
+ const turndownService = new TurndownService({
63
+ headingStyle: "atx",
64
+ bulletListMarker: "-",
65
+ codeBlockStyle: "fenced",
66
+ });
67
+
68
+ const markdown = turndownService.turndown(article.content);
69
+ const trimmedMarkdown = markdown.trim();
70
+
71
+ if (trimmedMarkdown.length <= MAX_CONTENT_LENGTH) {
72
+ return trimmedMarkdown;
73
+ }
74
+
75
+ const filePath = await writeTmpFile(
76
+ trimmedMarkdown,
77
+ "read_web_page",
78
+ "md",
79
+ );
80
+
81
+ const lineCount = trimmedMarkdown.split("\n").length;
82
+
83
+ return [
84
+ `Content is large (${trimmedMarkdown.length} characters, ${lineCount} lines) and saved to ${filePath}`,
85
+ "- Use rg / awk to read specific parts",
86
+ ].join("\n");
87
+ }),
88
+ };
89
+
90
+ // Playground
91
+ // (async () => {
92
+ // const input = {
93
+ // url: "https://devin.ai/agents101",
94
+ // };
95
+ // console.log(await fetchWebPageTool.impl(input));
96
+ // })();
@@ -0,0 +1,4 @@
1
+ export type PatchFileInput = {
2
+ filePath: string;
3
+ diff: string;
4
+ };
@@ -0,0 +1,96 @@
1
+ /**
2
+ * @import { Tool } from '../tool'
3
+ * @import { PatchFileInput } from './patchFile'
4
+ */
5
+
6
+ import fs from "node:fs/promises";
7
+ import { noThrow } from "../utils/noThrow.mjs";
8
+
9
+ /** @type {Tool} */
10
+ export const patchFileTool = {
11
+ def: {
12
+ name: "patch_file",
13
+ description:
14
+ "Modify a file by replacing specific content with new content.",
15
+ inputSchema: {
16
+ type: "object",
17
+ properties: {
18
+ filePath: {
19
+ type: "string",
20
+ },
21
+ diff: {
22
+ description: `
23
+ - Content is searched as an exact match including indentation and line breaks.
24
+ - The first match found will be replaced if there are multiple matches.
25
+ - Use multiple SEARCH/REPLACE blocks to replace multiple contents.
26
+
27
+ Format:
28
+ <<<<<<< SEARCH
29
+ old content
30
+ =======
31
+ new content
32
+ >>>>>>> REPLACE
33
+
34
+ <<<<<<< SEARCH
35
+ other old content
36
+ =======
37
+ other new content
38
+ >>>>>>> REPLACE
39
+ `.trim(),
40
+ type: "string",
41
+ },
42
+ },
43
+ required: ["filePath", "diff"],
44
+ },
45
+ },
46
+
47
+ /**
48
+ * @param {PatchFileInput} input
49
+ * @returns {Promise<string | Error>}
50
+ */
51
+ impl: async (input) =>
52
+ await noThrow(async () => {
53
+ const { filePath, diff } = input;
54
+
55
+ const content = await fs.readFile(filePath, "utf8");
56
+ const matches = Array.from(
57
+ diff.matchAll(
58
+ /<<<<<<< SEARCH\n(.*?)\n?=======\n(.*?)\n?>>>>>>> REPLACE/gs,
59
+ ),
60
+ );
61
+ if (matches.length === 0) {
62
+ throw new Error("No matches found in diff.");
63
+ }
64
+ let newContent = content;
65
+ for (const match of matches) {
66
+ const [_, search, replace] = match;
67
+ if (!newContent.includes(search)) {
68
+ throw new Error(
69
+ JSON.stringify(`Search content not found: ${search}`),
70
+ );
71
+ }
72
+ // Escape $ characters in replacement string to prevent interpretation of $& $1 $$ patterns
73
+ const escapedReplace = replace.replace(/\$/g, "$$$$");
74
+ if (replace === "" && newContent.includes(`${search}\n`)) {
75
+ newContent = newContent.replace(`${search}\n`, "");
76
+ } else if (replace === "" && newContent.includes(`\n${search}`)) {
77
+ newContent = newContent.replace(`\n${search}`, "");
78
+ } else {
79
+ newContent = newContent.replace(search, escapedReplace);
80
+ }
81
+ }
82
+ await fs.writeFile(filePath, newContent);
83
+ return `Patched file: ${filePath}`;
84
+ }),
85
+
86
+ /**
87
+ * @param {Record<string, unknown>} input
88
+ * @returns {Record<string, unknown>}
89
+ */
90
+ maskApprovalInput: (input) => {
91
+ const patchFileInput = /** @type {PatchFileInput} */ (input);
92
+ return {
93
+ filePath: patchFileInput.filePath,
94
+ };
95
+ },
96
+ };
@@ -0,0 +1,3 @@
1
+ export type ReportAsSubagentInput = {
2
+ memoryPath: string;
3
+ };
@@ -0,0 +1,44 @@
1
+ /**
2
+ * @import { Tool, ToolImplementation } from '../tool'
3
+ */
4
+
5
+ export const reportAsSubagentToolName = "report_as_subagent";
6
+
7
+ /** @returns {Tool} */
8
+ export function createReportAsSubagentTool() {
9
+ /** @type {ToolImplementation} */
10
+ let impl = async () => {
11
+ throw new Error("Not implemented");
12
+ };
13
+
14
+ /** @type {Tool} */
15
+ const tool = {
16
+ def: {
17
+ name: reportAsSubagentToolName,
18
+ description:
19
+ "End the subagent role and report the result to the main agent.",
20
+ inputSchema: {
21
+ type: "object",
22
+ properties: {
23
+ memoryPath: {
24
+ type: "string",
25
+ description:
26
+ "Path to the memory file containing the result of the subagent's task.",
27
+ },
28
+ },
29
+ required: ["memoryPath"],
30
+ },
31
+ },
32
+
33
+ // Implementation will be injected by the agent to access its state
34
+ get impl() {
35
+ return impl;
36
+ },
37
+
38
+ injectImpl(fn) {
39
+ impl = fn;
40
+ },
41
+ };
42
+
43
+ return tool;
44
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * @doc https://docs.tavily.com/documentation/api-reference/endpoint/search
3
+ */
4
+ export type TavilySearchInput = {
5
+ query: string;
6
+ };
@@ -0,0 +1,57 @@
1
+ /**
2
+ * @import { Tool } from '../tool'
3
+ * @import { TavilySearchInput } from './tavilySearch'
4
+ */
5
+
6
+ import { noThrow } from "../utils/noThrow.mjs";
7
+
8
+ /**
9
+ * @param {{apiKey?: string}} config
10
+ * @returns {Tool}
11
+ */
12
+ export function createTavilySearchTool(config) {
13
+ return {
14
+ def: {
15
+ name: "search_web",
16
+ description: "Search the web for information",
17
+ inputSchema: {
18
+ type: "object",
19
+ properties: {
20
+ query: {
21
+ type: "string",
22
+ },
23
+ },
24
+ required: ["query"],
25
+ },
26
+ },
27
+
28
+ /**
29
+ * @param {TavilySearchInput} input
30
+ * @returns {Promise<string | Error>}
31
+ */
32
+ impl: async (input) =>
33
+ await noThrow(async () => {
34
+ const response = await fetch("https://api.tavily.com/search", {
35
+ method: "POST",
36
+ headers: {
37
+ Authorization: `Bearer ${config.apiKey}`,
38
+ "Content-Type": "application/json",
39
+ },
40
+ body: JSON.stringify({
41
+ ...input,
42
+ max_results: 5,
43
+ }),
44
+ signal: AbortSignal.timeout(120 * 1000),
45
+ });
46
+
47
+ if (!response.ok) {
48
+ return new Error(
49
+ `Failed to search: status=${response.status}, body=${await response.text()}`,
50
+ );
51
+ }
52
+
53
+ const body = await response.json();
54
+ return JSON.stringify(body);
55
+ }),
56
+ };
57
+ }
@@ -0,0 +1,14 @@
1
+ import type { Tool } from "../tool";
2
+ import type { ExecCommandSanboxConfig } from "./execCommand";
3
+
4
+ export type TmuxCommandInput = {
5
+ command: string;
6
+ args?: string[];
7
+ };
8
+
9
+ export type TmuxCommandConfig = {
10
+ sandbox?: ExecCommandSanboxConfig;
11
+ };
12
+
13
+ export function createTmuxCommandTool(config?: TmuxCommandConfig): Tool;
14
+ export const tmuxCommandTool: Tool;
@@ -0,0 +1,194 @@
1
+ /**
2
+ * @import { Tool } from '../tool'
3
+ * @import { TmuxCommandConfig, TmuxCommandInput } from './tmuxCommand'
4
+ */
5
+
6
+ import { execFile } from "node:child_process";
7
+ import { noThrow } from "../utils/noThrow.mjs";
8
+
9
+ const OUTPUT_MAX_LENGTH = 1024 * 8;
10
+
11
+ /**
12
+ + * Sandbox-aware tmux command tool
13
+ + * @param {TmuxCommandConfig=} config
14
+ + * @returns {Tool}
15
+ + */
16
+ export function createTmuxCommandTool(config) {
17
+ /** @type {Tool} */
18
+ return {
19
+ def: {
20
+ name: "tmux_command",
21
+ description: "Run a tmux command",
22
+ inputSchema: {
23
+ type: "object",
24
+ properties: {
25
+ command: {
26
+ description: "The tmux command to run",
27
+ type: "string",
28
+ },
29
+ args: {
30
+ description: "Arguments to pass to the tmux command",
31
+ type: "array",
32
+ items: {
33
+ type: "string",
34
+ },
35
+ },
36
+ },
37
+ required: ["command"],
38
+ },
39
+ },
40
+
41
+ /**
42
+ * @param {TmuxCommandInput} input
43
+ * @returns {Promise<string | Error>}
44
+ */
45
+ impl: async (input) =>
46
+ await noThrow(async () => {
47
+ const { command } = input;
48
+ const args = input.args || [];
49
+
50
+ // tmuxはセミコロンを複数コマンドの区切りとして扱うためエスケープが必要
51
+ // LLMがこのルールを無視するのでここでエスケープする
52
+ if (command === "send-keys") {
53
+ for (let i = 1; i < args.length; i++) {
54
+ const arg = args[i];
55
+ if (arg.endsWith(";") && !arg.endsWith("\\;")) {
56
+ args[i] = `${arg.slice(0, -1)}\\;`;
57
+ }
58
+ }
59
+ }
60
+
61
+ const execFileOptions = {
62
+ shell: false,
63
+ env: {
64
+ PWD: process.env.PWD,
65
+ PATH: process.env.PATH,
66
+ HOME: process.env.HOME,
67
+ },
68
+ };
69
+
70
+ /**
71
+ * @param {{command: string, args: string[]}} input
72
+ * @returns {{command: string, args: string[]}}
73
+ */
74
+ const useSandbox = ({ command, args }) => {
75
+ if (config?.sandbox) {
76
+ return {
77
+ command: config.sandbox.command,
78
+ args: [...(config.sandbox.args || []), command, ...args],
79
+ };
80
+ }
81
+ return { command, args };
82
+ };
83
+
84
+ const execFileTmuxCommandInput = useSandbox({
85
+ command: "tmux",
86
+ args: [command, ...args],
87
+ });
88
+
89
+ return new Promise((resolve, _reject) => {
90
+ execFile(
91
+ execFileTmuxCommandInput.command,
92
+ execFileTmuxCommandInput.args,
93
+ execFileOptions,
94
+ async (err, stdout, stderr) => {
95
+ // capture-pane の結果に空白の行が含まれることがあるためtrim する
96
+ const stdoutTruncated = stdout.trim().slice(-OUTPUT_MAX_LENGTH);
97
+ const isStdoutTruncated =
98
+ stdout.trim().length > OUTPUT_MAX_LENGTH;
99
+ const stderrTruncated = stderr.trim().slice(-OUTPUT_MAX_LENGTH);
100
+ const isStderrTruncated =
101
+ stderr.trim().length > OUTPUT_MAX_LENGTH;
102
+ const result = [
103
+ stdoutTruncated
104
+ ? `<stdout>\n${isStdoutTruncated ? "(Output truncated) ..." : ""}${stdoutTruncated}\n</stdout>`
105
+ : "<stdout></stdout>",
106
+ "",
107
+ stderrTruncated
108
+ ? `<stderr>\n${isStderrTruncated ? "(Output truncated) ..." : ""}${stderrTruncated}\n</stderr>`
109
+ : "<stderr></stderr>",
110
+ ];
111
+ if (err) {
112
+ const errMessageTruncated = err.message.slice(
113
+ 0,
114
+ OUTPUT_MAX_LENGTH,
115
+ );
116
+ const isErrMessageTruncated =
117
+ err.message.length > OUTPUT_MAX_LENGTH;
118
+ result.push(
119
+ `\n<error>\n${err.name}: ${errMessageTruncated}${isErrMessageTruncated ? "... (Message truncated)" : ""}</error>`,
120
+ );
121
+ }
122
+
123
+ if (["new-session", "new", "new-window"].includes(command)) {
124
+ // show window list after creating a new session or window
125
+ const targetPosition = command.includes("window")
126
+ ? args.indexOf("-t") + 1
127
+ : args.indexOf("-s") + 1;
128
+ const target = args[targetPosition];
129
+
130
+ const execFileTmuxListWindowInput = useSandbox({
131
+ command: "tmux",
132
+ args: ["list-windows", "-t", target],
133
+ });
134
+ const listWindowResult = await new Promise(
135
+ (resolve, _reject) => {
136
+ execFile(
137
+ execFileTmuxListWindowInput.command,
138
+ execFileTmuxListWindowInput.args,
139
+ execFileOptions,
140
+ (err, stdout, _stderr) => {
141
+ if (err) {
142
+ console.error(
143
+ `Failed to list tmux windows: ${err.message}, stack=${err.stack}`,
144
+ );
145
+ }
146
+ return resolve(stdout);
147
+ },
148
+ );
149
+ },
150
+ );
151
+ result.push(
152
+ `\n<tmux:list-windows>\n${listWindowResult}</tmux:list-windows>`,
153
+ );
154
+ }
155
+
156
+ if (command === "send-keys") {
157
+ // capture the pane after sending keys
158
+ // wait for the command to be executed
159
+ await new Promise((resolve) => setTimeout(resolve, 2000));
160
+ const targetPosition = args.indexOf("-t") + 1;
161
+ const target = args[targetPosition];
162
+ const execFileTmuxCapturePaneInput = useSandbox({
163
+ command: "tmux",
164
+ args: ["capture-pane", "-p", "-t", target],
165
+ });
166
+ const captured = await new Promise((resolve, _reject) => {
167
+ execFile(
168
+ execFileTmuxCapturePaneInput.command,
169
+ execFileTmuxCapturePaneInput.args,
170
+ execFileOptions,
171
+ (err, stdout, _stderr) => {
172
+ if (err) {
173
+ console.error(
174
+ `Failed to capture tmux pane: ${err.message}, stack=${err.stack}`,
175
+ );
176
+ }
177
+ return resolve(stdout.trim());
178
+ },
179
+ );
180
+ });
181
+ const capturedTruncated = captured.slice(-OUTPUT_MAX_LENGTH);
182
+ const isCapturedTruncated = captured.length > OUTPUT_MAX_LENGTH;
183
+ result.push(
184
+ `\n<tmux:capture-pane target="${target}"">\n${isCapturedTruncated ? "(Output truncated) ..." : ""}${capturedTruncated}\n</tmux:capture-pane>`,
185
+ );
186
+ }
187
+
188
+ return resolve(result.join("\n"));
189
+ },
190
+ );
191
+ });
192
+ }),
193
+ };
194
+ }
@@ -0,0 +1,4 @@
1
+ export type WriteFileInput = {
2
+ filePath: string;
3
+ content: string;
4
+ };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * @import { Tool } from '../tool'
3
+ * @import { WriteFileInput } from './writeFile'
4
+ */
5
+
6
+ import fs from "node:fs/promises";
7
+ import path from "node:path";
8
+ import { noThrow } from "../utils/noThrow.mjs";
9
+
10
+ /** @type {Tool} */
11
+ export const writeFileTool = {
12
+ def: {
13
+ name: "write_file",
14
+ description: "Write a file",
15
+ inputSchema: {
16
+ type: "object",
17
+ properties: {
18
+ filePath: {
19
+ type: "string",
20
+ },
21
+ content: {
22
+ type: "string",
23
+ },
24
+ },
25
+ required: ["filePath", "content"],
26
+ },
27
+ },
28
+
29
+ /**
30
+ * @param {WriteFileInput} input
31
+ * @returns {Promise<string | Error>}
32
+ */
33
+ impl: async (input) =>
34
+ await noThrow(async () => {
35
+ const { filePath, content } = input;
36
+
37
+ const absFilePath = path.resolve(filePath);
38
+
39
+ // Ensure the destination directory exists before writing
40
+ const dir = path.dirname(absFilePath);
41
+ await fs.mkdir(dir, { recursive: true });
42
+ await fs.writeFile(absFilePath, content, "utf8");
43
+ return `Wrote to file: ${filePath}`;
44
+ }),
45
+
46
+ /**
47
+ * @param {Record<string, unknown>} input
48
+ * @returns {Record<string, unknown>}
49
+ */
50
+ maskApprovalInput: (input) => {
51
+ const writeFileInput = /** @type {WriteFileInput} */ (input);
52
+ return {
53
+ filePath: writeFileInput.filePath,
54
+ };
55
+ },
56
+ };
@@ -0,0 +1,48 @@
1
+ /**
2
+ * @param {unknown} configItem
3
+ * @returns {unknown}
4
+ */
5
+ export function evalJSONConfig(configItem) {
6
+ if (Array.isArray(configItem)) {
7
+ return configItem.map((item) => evalJSONConfig(item));
8
+ }
9
+
10
+ if (typeof configItem === "object" && configItem !== null) {
11
+ if (
12
+ Object.keys(configItem).length === 1 &&
13
+ "$regex" in configItem &&
14
+ typeof configItem.$regex === "string"
15
+ ) {
16
+ return new RegExp(configItem.$regex);
17
+ }
18
+
19
+ if (Object.keys(configItem).length === 1 && "$has" in configItem) {
20
+ const pattern = evalJSONConfig(configItem.$has);
21
+ /** @param {unknown} value */
22
+ return (value) => {
23
+ if (!Array.isArray(value)) return false;
24
+ return value.some((item) => {
25
+ if (typeof pattern === "string") {
26
+ return item === pattern;
27
+ }
28
+ if (pattern instanceof RegExp) {
29
+ return typeof item === "string" && pattern.test(item);
30
+ }
31
+ if (typeof pattern === "function") {
32
+ return pattern(item);
33
+ }
34
+ return false;
35
+ });
36
+ };
37
+ }
38
+
39
+ /** @type {Record<string,unknown>} */
40
+ const clone = {};
41
+ for (const [k, v] of Object.entries(configItem)) {
42
+ clone[k] = evalJSONConfig(v);
43
+ }
44
+ return clone;
45
+ }
46
+
47
+ return configItem;
48
+ }
@@ -0,0 +1,6 @@
1
+ export type ValuePattern =
2
+ | string
3
+ | RegExp
4
+ | ((value: unknown) => boolean)
5
+ | ValuePattern[]
6
+ | { [key: string]: ValuePattern };
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @import { ValuePattern } from "./matchValue"
3
+ */
4
+
5
+ /**
6
+ * @param {unknown} value
7
+ * @param {ValuePattern} pattern
8
+ * @returns {boolean}
9
+ */
10
+ export function matchValue(value, pattern) {
11
+ if (typeof pattern === "string") {
12
+ return typeof value === "string" && value === pattern;
13
+ }
14
+
15
+ if (pattern instanceof RegExp) {
16
+ return typeof value === "string" && pattern.test(value);
17
+ }
18
+
19
+ if (typeof pattern === "function") {
20
+ return pattern(value);
21
+ }
22
+
23
+ if (Array.isArray(pattern)) {
24
+ return (
25
+ Array.isArray(value) && pattern.every((p, i) => matchValue(value[i], p))
26
+ );
27
+ }
28
+
29
+ if (typeof pattern === "object") {
30
+ return (
31
+ typeof value === "object" &&
32
+ value !== null &&
33
+ Object.entries(pattern).every(([k, p]) =>
34
+ matchValue(value[/** @type {keyof value} */ (k)], p),
35
+ )
36
+ );
37
+ }
38
+
39
+ return false;
40
+ }