@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,48 @@
1
+ import { ModelDefinition, PlatformConfig } from "./modelDefinition";
2
+ import { ToolUsePattern } from "./tool";
3
+ import { ExecCommandSanboxConfig } from "./tools/execCommand";
4
+
5
+ export type ClaudeCodePluginConfig = {
6
+ name: string;
7
+ path: string;
8
+ };
9
+
10
+ export type AppConfig = {
11
+ model?: string;
12
+ models?: ModelDefinition[];
13
+ platforms?: PlatformConfig[];
14
+ autoApproval?: {
15
+ patterns?: ToolUsePattern[];
16
+ maxApprovals?: number;
17
+ defaultAction?: "deny" | "ask";
18
+ };
19
+ sandbox?: ExecCommandSanboxConfig;
20
+ tools?: {
21
+ tavily?: {
22
+ apiKey?: string;
23
+ };
24
+ /**
25
+ * - Vertex AI: requires baseURL and account
26
+ * - AI Studio: requires apiKey
27
+ */
28
+ askGoogle?: {
29
+ platform?: "vertex-ai";
30
+ baseURL?: string;
31
+ account?: string;
32
+ apiKey?: string;
33
+ model?: string;
34
+ };
35
+ };
36
+ mcpServers?: Record<string, MCPServerConfig>;
37
+ notifyCmd?: string;
38
+ claudeCodePlugins?: ClaudeCodePluginConfig[];
39
+ };
40
+
41
+ export type MCPServerConfig = {
42
+ command: string;
43
+ args: string[];
44
+ env?: Record<string, string>;
45
+ options?: {
46
+ enabledTools?: string[];
47
+ };
48
+ };
package/src/config.mjs ADDED
@@ -0,0 +1,168 @@
1
+ /**
2
+ * @import { AppConfig } from "./config";
3
+ */
4
+
5
+ import crypto from "node:crypto";
6
+ import fs from "node:fs/promises";
7
+ import path from "node:path";
8
+ import readline from "node:readline";
9
+ import { styleText } from "node:util";
10
+ import {
11
+ AGENT_PROJECT_METADATA_DIR,
12
+ AGENT_ROOT,
13
+ AGENT_USER_CONFIG_DIR,
14
+ TRUSTED_CONFIG_HASHES_DIR,
15
+ } from "./env.mjs";
16
+ import { evalJSONConfig } from "./utils/evalJSONConfig.mjs";
17
+
18
+ /**
19
+ * @returns {Promise<{appConfig: AppConfig, loadedConfigPath: string[]}>}
20
+ */
21
+ export async function loadAppConfig() {
22
+ const paths = [
23
+ `${AGENT_ROOT}/.config/config.predefined.json`,
24
+ `${AGENT_USER_CONFIG_DIR}/config.json`,
25
+ `${AGENT_USER_CONFIG_DIR}/config.local.json`,
26
+ `${AGENT_PROJECT_METADATA_DIR}/config.json`,
27
+ `${AGENT_PROJECT_METADATA_DIR}/config.local.json`,
28
+ ];
29
+
30
+ /** @type {string[]} */
31
+ const loadedConfigPath = [];
32
+ /** @type {AppConfig} */
33
+ let merged = {};
34
+
35
+ for (const filePath of paths) {
36
+ const config = await loadConfigFile(path.resolve(filePath));
37
+ if (Object.keys(config).length) {
38
+ loadedConfigPath.push(filePath);
39
+ }
40
+ merged = {
41
+ model: config.model || merged.model,
42
+ models: [...(config.models ?? []), ...(merged.models ?? [])],
43
+ platforms: [...(config.platforms ?? []), ...(merged.platforms ?? [])],
44
+ autoApproval: {
45
+ defaultAction:
46
+ config.autoApproval?.defaultAction ??
47
+ merged.autoApproval?.defaultAction,
48
+ patterns: [
49
+ ...(config.autoApproval?.patterns ?? []),
50
+ ...(merged.autoApproval?.patterns ?? []),
51
+ ],
52
+ maxApprovals:
53
+ config.autoApproval?.maxApprovals ??
54
+ merged.autoApproval?.maxApprovals,
55
+ },
56
+ sandbox: config.sandbox ?? merged.sandbox,
57
+ tools: {
58
+ tavily: {
59
+ ...(merged.tools?.tavily ?? {}),
60
+ ...(config.tools?.tavily ?? {}),
61
+ },
62
+ askGoogle: {
63
+ ...(merged.tools?.askGoogle ?? {}),
64
+ ...(config.tools?.askGoogle ?? {}),
65
+ },
66
+ },
67
+ mcpServers: {
68
+ ...(merged.mcpServers ?? {}),
69
+ ...(config.mcpServers ?? {}),
70
+ },
71
+ notifyCmd: config.notifyCmd || merged.notifyCmd,
72
+ claudeCodePlugins: [
73
+ ...(merged.claudeCodePlugins ?? []),
74
+ ...(config.claudeCodePlugins ?? []),
75
+ ],
76
+ };
77
+ }
78
+
79
+ return { appConfig: merged, loadedConfigPath };
80
+ }
81
+
82
+ /**
83
+ * @param {string} filePath
84
+ * @returns {Promise<AppConfig>}
85
+ */
86
+ export async function loadConfigFile(filePath) {
87
+ let content;
88
+ try {
89
+ content = await fs.readFile(filePath, "utf-8");
90
+ } catch (err) {
91
+ if (err instanceof Error && "code" in err && err.code === "ENOENT") {
92
+ return {};
93
+ }
94
+ throw err;
95
+ }
96
+
97
+ const hash = crypto.createHash("sha256").update(content).digest("hex");
98
+ const isTrusted = await isConfigHashTrusted(hash);
99
+
100
+ if (!isTrusted) {
101
+ if (!process.stdout.isTTY) {
102
+ console.warn(
103
+ styleText(
104
+ "yellow",
105
+ `WARNING: Config file found at '${filePath}' but cannot ask for approval without a TTY. Skipping.`,
106
+ ),
107
+ );
108
+ return {};
109
+ }
110
+
111
+ const rl = readline.createInterface({
112
+ input: process.stdin,
113
+ output: process.stdout,
114
+ });
115
+
116
+ const answer = await new Promise((resolve) => {
117
+ console.log(styleText("blue", `\nFound a config file at ${filePath}`));
118
+ rl.question(
119
+ styleText("yellow", "Do you want to load this file? (y/N) "),
120
+ (ans) => {
121
+ rl.close();
122
+ resolve(ans);
123
+ },
124
+ );
125
+ });
126
+
127
+ if (answer.toLowerCase() !== "y") {
128
+ console.log(styleText("yellow", "Skipping local config file."));
129
+ return {};
130
+ }
131
+
132
+ await trustConfigHash(hash);
133
+ }
134
+
135
+ try {
136
+ const commentRemovedContent = content.replace(/^ *\/\/.+$/gm, "");
137
+ const parsed = JSON.parse(commentRemovedContent);
138
+ return /** @type {AppConfig} */ (evalJSONConfig(parsed));
139
+ } catch (err) {
140
+ throw new Error(`Failed to parse JSON config at ${filePath}`, {
141
+ cause: err,
142
+ });
143
+ }
144
+ }
145
+
146
+ /**
147
+ * @param {string} hash
148
+ * @returns {Promise<boolean>}
149
+ */
150
+ async function isConfigHashTrusted(hash) {
151
+ try {
152
+ await fs.access(path.join(TRUSTED_CONFIG_HASHES_DIR, hash));
153
+ return true;
154
+ } catch (err) {
155
+ if (err instanceof Error && "code" in err && err.code === "ENOENT") {
156
+ return false;
157
+ }
158
+ throw err;
159
+ }
160
+ }
161
+
162
+ /**
163
+ * @param {string} hash
164
+ */
165
+ async function trustConfigHash(hash) {
166
+ await fs.mkdir(TRUSTED_CONFIG_HASHES_DIR, { recursive: true });
167
+ await fs.writeFile(path.join(TRUSTED_CONFIG_HASHES_DIR, hash), "");
168
+ }
@@ -0,0 +1,30 @@
1
+ import fs from "node:fs/promises";
2
+ import { AGENT_INTERRUPT_MESSAGE_FILE_PATH } from "../env.mjs";
3
+
4
+ /**
5
+ * @returns {Promise<string | undefined>}
6
+ */
7
+ export async function consumeInterruptMessage() {
8
+ try {
9
+ const content = await fs.readFile(
10
+ AGENT_INTERRUPT_MESSAGE_FILE_PATH,
11
+ "utf8",
12
+ );
13
+ await fs.truncate(AGENT_INTERRUPT_MESSAGE_FILE_PATH, 0);
14
+
15
+ if (content.trim() === "") {
16
+ return undefined;
17
+ }
18
+ return content;
19
+ } catch (err) {
20
+ if (
21
+ err instanceof Error &&
22
+ "code" in err &&
23
+ typeof err.code === "string" &&
24
+ err.code === "ENOENT"
25
+ ) {
26
+ return undefined;
27
+ }
28
+ throw err;
29
+ }
30
+ }
@@ -0,0 +1,272 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import yaml from "js-yaml";
5
+ import {
6
+ AGENT_CACHE_DIR,
7
+ AGENT_PROJECT_METADATA_DIR,
8
+ AGENT_ROOT,
9
+ AGENT_USER_CONFIG_DIR,
10
+ CLAUDE_CODE_PLUGIN_DIR,
11
+ } from "../env.mjs";
12
+
13
+ /**
14
+ * @typedef {Object} AgentRole
15
+ * @property {string} id
16
+ * @property {string} description
17
+ * @property {string} content
18
+ * @property {string} filePath
19
+ * @property {string} [import]
20
+ */
21
+
22
+ /**
23
+ * Load all agent roles from the predefined directories.
24
+ * @param {Array<{name: string, path: string}>} [claudeCodePlugins]
25
+ * @returns {Promise<Map<string, AgentRole>>}
26
+ */
27
+ export async function loadAgentRoles(claudeCodePlugins) {
28
+ const agentDirs = [
29
+ {
30
+ dir: path.resolve(AGENT_ROOT, ".config", "agents.predefined"),
31
+ idPrefix: "",
32
+ },
33
+ { dir: path.resolve(AGENT_USER_CONFIG_DIR, "agents"), idPrefix: "" },
34
+ { dir: path.resolve(AGENT_PROJECT_METADATA_DIR, "agents"), idPrefix: "" },
35
+ {
36
+ dir: path.resolve(process.cwd(), ".claude", "agents"),
37
+ idPrefix: "claude:",
38
+ },
39
+ ];
40
+
41
+ // Add plugin directories if provided
42
+ if (claudeCodePlugins) {
43
+ for (const plugin of claudeCodePlugins) {
44
+ const pluginBase = path.join(CLAUDE_CODE_PLUGIN_DIR, plugin.path);
45
+
46
+ agentDirs.push({
47
+ dir: path.join(pluginBase, "agents"),
48
+ idPrefix: `claude/${plugin.name}:`,
49
+ });
50
+ }
51
+ }
52
+
53
+ /** @type {Map<string, AgentRole>} */
54
+ const roles = new Map();
55
+
56
+ for (const { dir, idPrefix } of agentDirs) {
57
+ const files = await getMarkdownFiles(dir).catch((err) => {
58
+ if (err.code !== "ENOENT") {
59
+ console.warn(`Failed to list agent roles in ${dir}:`, err);
60
+ }
61
+ return [];
62
+ });
63
+
64
+ for (const file of files) {
65
+ const fullPath = path.join(dir, file);
66
+ const content = await fs.readFile(fullPath, "utf-8").catch((err) => {
67
+ console.warn(`Failed to read agent role file ${fullPath}:`, err);
68
+ return null;
69
+ });
70
+
71
+ if (content === null) continue;
72
+
73
+ let role = parseAgentRole(file, content, fullPath, idPrefix);
74
+ if (role.import) {
75
+ role = await mergeRemoteRole(role, file, fullPath);
76
+ }
77
+
78
+ roles.set(role.id, role);
79
+ }
80
+ }
81
+
82
+ return roles;
83
+ }
84
+
85
+ /**
86
+ * Merges a remote role into a local role.
87
+ * @param {AgentRole} localRole
88
+ * @param {string} relativePath
89
+ * @param {string} fullPath
90
+ * @returns {Promise<AgentRole>}
91
+ */
92
+ async function mergeRemoteRole(localRole, relativePath, fullPath) {
93
+ const importUrl = localRole.import;
94
+ if (!importUrl) {
95
+ return localRole;
96
+ }
97
+
98
+ const fetchedContent = await fetchAndCacheRole(importUrl).catch((err) => {
99
+ console.warn(`Failed to fetch agent role from ${importUrl}:`, err);
100
+ return null;
101
+ });
102
+
103
+ if (!fetchedContent) {
104
+ return localRole;
105
+ }
106
+
107
+ const remoteRole = parseAgentRole(relativePath, fetchedContent, fullPath);
108
+
109
+ return {
110
+ ...remoteRole,
111
+ ...localRole, // Local overrides
112
+ content: `${remoteRole.content}\n\n---\n\n${localRole.content}`.trim(),
113
+ description: localRole.description || remoteRole.description || "",
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Fetch an agent role from a URL and cache it.
119
+ * @param {string} url
120
+ * @returns {Promise<string>}
121
+ */
122
+ async function fetchAndCacheRole(url) {
123
+ const hash = crypto.createHash("sha256").update(url).digest("hex");
124
+ const cacheDir = path.join(AGENT_CACHE_DIR, "agents");
125
+ const cachePath = path.join(cacheDir, hash);
126
+
127
+ const cachedContent = await fs.readFile(cachePath, "utf-8").catch(() => null);
128
+ if (cachedContent !== null) {
129
+ return cachedContent;
130
+ }
131
+
132
+ const fetchedContent = await fetchContent(url);
133
+
134
+ // Attempt to cache, but don't block or fail on errors
135
+ fs.mkdir(cacheDir, { recursive: true })
136
+ .then(() => fs.writeFile(cachePath, fetchedContent, "utf-8"))
137
+ .catch((err) => {
138
+ console.warn(`Failed to write cache for ${url}:`, err);
139
+ });
140
+
141
+ return fetchedContent;
142
+ }
143
+
144
+ /**
145
+ * Fetch content from a URL.
146
+ * @param {string} url
147
+ * @returns {Promise<string>}
148
+ */
149
+ async function fetchContent(url) {
150
+ const githubMatch = url.match(
151
+ /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/blob\/([^/]+)\/(.+)$/,
152
+ );
153
+
154
+ if (githubMatch) {
155
+ const [, owner, repo, ref, path] = githubMatch;
156
+ const apiUrl = `repos/${owner}/${repo}/contents/${path}?ref=${ref}`;
157
+ try {
158
+ const { execFileSync } = await import("node:child_process");
159
+ return execFileSync(
160
+ "gh",
161
+ ["api", "-H", "Accept: application/vnd.github.v3.raw", apiUrl],
162
+ { encoding: "utf-8" },
163
+ );
164
+ } catch (err) {
165
+ throw new Error(`Failed to fetch from GitHub via gh CLI: ${err}`);
166
+ }
167
+ }
168
+
169
+ const response = await fetch(url);
170
+ if (!response.ok) {
171
+ throw new Error(
172
+ `Failed to fetch agent role from ${url}: ${response.status} ${response.statusText}`,
173
+ );
174
+ }
175
+ return response.text();
176
+ }
177
+
178
+ /**
179
+ * Recursively get all markdown files in a directory.
180
+ * @param {string} dir
181
+ * @param {string} [baseDir]
182
+ * @returns {Promise<string[]>}
183
+ */
184
+ async function getMarkdownFiles(dir, baseDir = dir) {
185
+ const entries = await fs.readdir(dir, { withFileTypes: true });
186
+ const files = [];
187
+
188
+ for (const entry of entries) {
189
+ const fullPath = path.join(dir, entry.name);
190
+ let isDirectory = entry.isDirectory();
191
+ let isFile = entry.isFile();
192
+
193
+ if (entry.isSymbolicLink()) {
194
+ const stat = await fs.stat(fullPath).catch(() => null);
195
+ if (!stat) continue;
196
+ isDirectory = stat.isDirectory();
197
+ isFile = stat.isFile();
198
+ }
199
+
200
+ if (isDirectory) {
201
+ files.push(...(await getMarkdownFiles(fullPath, baseDir)));
202
+ } else if (isFile && entry.name.endsWith(".md")) {
203
+ files.push(path.relative(baseDir, fullPath));
204
+ }
205
+ }
206
+
207
+ return files;
208
+ }
209
+
210
+ /**
211
+ * Parse an agent role file content.
212
+ * @param {string} relativePath
213
+ * @param {string} fileContent
214
+ * @param {string} fullPath
215
+ * @param {string} [idPrefix=""]
216
+ * @returns {AgentRole}
217
+ */
218
+ function parseAgentRole(relativePath, fileContent, fullPath, idPrefix = "") {
219
+ const rawId = relativePath.replace(/\.md$/, "");
220
+ const id = idPrefix + rawId;
221
+
222
+ // Match YAML frontmatter
223
+ const match = fileContent.match(
224
+ /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/,
225
+ );
226
+
227
+ if (!match) {
228
+ return {
229
+ id,
230
+ description: "",
231
+ content: fileContent.trim(),
232
+ filePath: fullPath,
233
+ };
234
+ }
235
+
236
+ /** @type {{description?:string; import?:string}} */
237
+ let frontmatter;
238
+ try {
239
+ frontmatter = /** @type {{description?:string; import?:string}} */ (
240
+ yaml.load(match[1])
241
+ );
242
+ } catch (_err) {
243
+ return {
244
+ id,
245
+ description: parseFrontmatterField(match[1], "description") ?? "",
246
+ content: fileContent.trim(),
247
+ filePath: fullPath,
248
+ };
249
+ }
250
+ const content = match[2].trim();
251
+
252
+ return {
253
+ id,
254
+ description: frontmatter.description ?? "",
255
+ content,
256
+ filePath: fullPath,
257
+ import: frontmatter.import,
258
+ };
259
+ }
260
+
261
+ /**
262
+ * Parse a field from YAML frontmatter.
263
+ * @param {string} frontmatter
264
+ * @param {string} field
265
+ * @returns {string | undefined}
266
+ */
267
+
268
+ function parseFrontmatterField(frontmatter, field) {
269
+ const regex = new RegExp(`^${field}:\\s*(.*)$`, "m");
270
+ const match = frontmatter.match(regex);
271
+ return match ? match[1].trim() : undefined;
272
+ }