@iinm/plain-agent 1.8.3 → 1.8.4

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 (86) hide show
  1. package/README.md +2 -2
  2. package/bin/plain +1 -1
  3. package/config/config.predefined.json +1 -1
  4. package/config/prompts.predefined/shortcuts/configure.md +1 -1
  5. package/dist/main.mjs +473 -0
  6. package/dist/main.mjs.map +7 -0
  7. package/package.json +5 -7
  8. package/src/agent.d.ts +0 -52
  9. package/src/agent.mjs +0 -204
  10. package/src/agentLoop.mjs +0 -419
  11. package/src/agentState.mjs +0 -41
  12. package/src/claudeCodePlugin.mjs +0 -164
  13. package/src/cliArgs.mjs +0 -175
  14. package/src/cliBatch.mjs +0 -147
  15. package/src/cliCommands.mjs +0 -283
  16. package/src/cliCompleter.mjs +0 -227
  17. package/src/cliCost.mjs +0 -309
  18. package/src/cliFormatter.mjs +0 -413
  19. package/src/cliInteractive.mjs +0 -529
  20. package/src/cliInterruptTransform.mjs +0 -51
  21. package/src/cliMuteTransform.mjs +0 -26
  22. package/src/cliPasteTransform.mjs +0 -183
  23. package/src/config.d.ts +0 -36
  24. package/src/config.mjs +0 -197
  25. package/src/context/loadAgentRoles.mjs +0 -283
  26. package/src/context/loadPrompts.mjs +0 -324
  27. package/src/context/loadUserMessageContext.mjs +0 -147
  28. package/src/costTracker.mjs +0 -210
  29. package/src/env.mjs +0 -44
  30. package/src/main.mjs +0 -279
  31. package/src/mcpClient.mjs +0 -351
  32. package/src/mcpIntegration.mjs +0 -160
  33. package/src/model.d.ts +0 -109
  34. package/src/modelCaller.mjs +0 -32
  35. package/src/modelDefinition.d.ts +0 -92
  36. package/src/prompt.mjs +0 -138
  37. package/src/providers/anthropic.d.ts +0 -248
  38. package/src/providers/anthropic.mjs +0 -587
  39. package/src/providers/bedrock.d.ts +0 -249
  40. package/src/providers/bedrock.mjs +0 -700
  41. package/src/providers/gemini.d.ts +0 -208
  42. package/src/providers/gemini.mjs +0 -754
  43. package/src/providers/openai.d.ts +0 -281
  44. package/src/providers/openai.mjs +0 -544
  45. package/src/providers/openaiCompatible.d.ts +0 -147
  46. package/src/providers/openaiCompatible.mjs +0 -652
  47. package/src/providers/platform/awsSigV4.mjs +0 -184
  48. package/src/providers/platform/azure.mjs +0 -42
  49. package/src/providers/platform/bedrock.mjs +0 -78
  50. package/src/providers/platform/googleCloud.mjs +0 -34
  51. package/src/subagent.mjs +0 -265
  52. package/src/tmpfile.mjs +0 -27
  53. package/src/tool.d.ts +0 -74
  54. package/src/toolExecutor.mjs +0 -236
  55. package/src/toolInputValidator.mjs +0 -183
  56. package/src/toolUseApprover.mjs +0 -99
  57. package/src/tools/askURL.mjs +0 -209
  58. package/src/tools/askWeb.mjs +0 -208
  59. package/src/tools/compactContext.d.ts +0 -4
  60. package/src/tools/compactContext.mjs +0 -87
  61. package/src/tools/delegateToSubagent.d.ts +0 -4
  62. package/src/tools/delegateToSubagent.mjs +0 -48
  63. package/src/tools/execCommand.d.ts +0 -22
  64. package/src/tools/execCommand.mjs +0 -200
  65. package/src/tools/patchFile.d.ts +0 -4
  66. package/src/tools/patchFile.mjs +0 -133
  67. package/src/tools/reportAsSubagent.d.ts +0 -3
  68. package/src/tools/reportAsSubagent.mjs +0 -44
  69. package/src/tools/tmuxCommand.d.ts +0 -14
  70. package/src/tools/tmuxCommand.mjs +0 -194
  71. package/src/tools/writeFile.d.ts +0 -4
  72. package/src/tools/writeFile.mjs +0 -56
  73. package/src/usageStore.mjs +0 -167
  74. package/src/utils/evalJSONConfig.mjs +0 -72
  75. package/src/utils/matchValue.d.ts +0 -6
  76. package/src/utils/matchValue.mjs +0 -40
  77. package/src/utils/noThrow.mjs +0 -31
  78. package/src/utils/notify.mjs +0 -29
  79. package/src/utils/parseFileRange.mjs +0 -18
  80. package/src/utils/readFileRange.mjs +0 -33
  81. package/src/utils/retryOnError.mjs +0 -41
  82. package/src/voiceInput.mjs +0 -61
  83. package/src/voiceInputGemini.mjs +0 -105
  84. package/src/voiceInputOpenAI.mjs +0 -104
  85. package/src/voiceInputSession.mjs +0 -543
  86. package/src/voiceToggleKey.mjs +0 -62
@@ -1,183 +0,0 @@
1
- import { Transform } from "node:stream";
2
-
3
- // Bracketed paste mode sequences
4
- const BRACKETED_PASTE_START = "\x1b[200~";
5
- const BRACKETED_PASTE_END = "\x1b[201~";
6
-
7
- // Time to wait for a continuation paste chunk before flushing the paste buffer.
8
- // Some terminals split large pastes into multiple bracketed paste sequences
9
- // (e.g. `\x1b[200~...\x1b[201~\x1b[200~...\x1b[201~`) that arrive back-to-back.
10
- // Holding the paste briefly lets us merge them into a single placeholder.
11
- const PASTE_MERGE_WINDOW_MS = 20;
12
-
13
- // Paste state machine:
14
- // IDLE - normal passthrough
15
- // PASTE - inside a BRACKETED_PASTE_START ... BRACKETED_PASTE_END sequence
16
- // PENDING - just saw an END; waiting to see if the next data continues the
17
- // paste (another START immediately follows) or not.
18
- /** @typedef {"IDLE" | "PASTE" | "PENDING"} PasteState */
19
-
20
- /**
21
- * Generate a short hash for paste reference.
22
- * @param {string} content
23
- * @returns {string}
24
- */
25
- function generatePasteHash(content) {
26
- let hash = 0;
27
- for (let i = 0; i < content.length; i++) {
28
- const char = content.charCodeAt(i);
29
- hash = (hash << 5) - hash + char;
30
- hash = hash & hash; // Convert to 32bit integer
31
- }
32
- return Math.abs(hash).toString(16).padStart(6, "0").slice(0, 6);
33
- }
34
-
35
- /**
36
- * @typedef {object} PasteHandler
37
- * @property {Transform} transform
38
- * Transform stream to pipe stdin through. Emits placeholders for multi-line
39
- * pastes and raw text for single-line pastes / typed input.
40
- * @property {(input: string) => string} resolvePlaceholders
41
- * Given a string containing placeholders produced by `transform`, append a
42
- * `<context id="pasted#HASH">...</context>` block for each referenced paste
43
- * and consume the stored content. Unknown placeholders are left untouched.
44
- */
45
-
46
- /**
47
- * Create a bracketed-paste handler. The handler owns its own content store so
48
- * pastes from one handler instance cannot interfere with another (and state
49
- * does not leak across tests).
50
- *
51
- * @returns {PasteHandler}
52
- */
53
- export function createPasteHandler() {
54
- /** @type {Map<string, string>} */
55
- const pastedContentStore = new Map();
56
-
57
- /** @type {PasteState} */
58
- let state = "IDLE";
59
- let pasteBuffer = "";
60
- /** @type {NodeJS.Timeout | null} */
61
- let mergeTimer = null;
62
- /** @type {Transform} */
63
- let transform;
64
-
65
- const clearMergeTimer = () => {
66
- if (mergeTimer) {
67
- clearTimeout(mergeTimer);
68
- mergeTimer = null;
69
- }
70
- };
71
-
72
- const flushPasteBuffer = () => {
73
- clearMergeTimer();
74
- if (pasteBuffer) {
75
- // Strip a trailing newline so a paste like "foo\n" is treated as single-line.
76
- const trimmed = pasteBuffer.replace(/\n$/, "");
77
- if (trimmed.includes("\n")) {
78
- // Multi-line: emit a placeholder and stash the content for later.
79
- const hash = generatePasteHash(pasteBuffer);
80
- pastedContentStore.set(hash, pasteBuffer);
81
- const lineCount = pasteBuffer.split("\n").length;
82
- transform.push(`[Pasted text #${hash}, ${lineCount} lines]`);
83
- } else {
84
- transform.push(trimmed);
85
- }
86
- }
87
- pasteBuffer = "";
88
- state = "IDLE";
89
- };
90
-
91
- transform = new Transform({
92
- transform(chunk, _encoding, callback) {
93
- /** @type {string} */
94
- let data = chunk.toString("utf8");
95
-
96
- while (data.length > 0) {
97
- if (state === "PASTE") {
98
- const endIdx = data.indexOf(BRACKETED_PASTE_END);
99
- if (endIdx === -1) {
100
- pasteBuffer += data;
101
- data = "";
102
- } else {
103
- // End of (this chunk of) paste. Hold briefly in case another paste
104
- // chunk follows immediately and should be merged.
105
- pasteBuffer += data.slice(0, endIdx);
106
- data = data.slice(endIdx + BRACKETED_PASTE_END.length);
107
- state = "PENDING";
108
- }
109
- } else if (state === "PENDING") {
110
- if (data.startsWith(BRACKETED_PASTE_START)) {
111
- // Continuation of the previous paste; keep appending to pasteBuffer.
112
- data = data.slice(BRACKETED_PASTE_START.length);
113
- clearMergeTimer();
114
- state = "PASTE";
115
- } else {
116
- // Not a continuation; flush, then re-process this data as IDLE.
117
- flushPasteBuffer();
118
- }
119
- } else {
120
- // IDLE
121
- const startIdx = data.indexOf(BRACKETED_PASTE_START);
122
- if (startIdx === -1) {
123
- this.push(data);
124
- data = "";
125
- } else {
126
- this.push(data.slice(0, startIdx));
127
- data = data.slice(startIdx + BRACKETED_PASTE_START.length);
128
- state = "PASTE";
129
- }
130
- }
131
- }
132
-
133
- // If the chunk ended while still waiting for a possible continuation,
134
- // schedule a short timer to flush the pending paste if nothing arrives.
135
- if (state === "PENDING" && !mergeTimer) {
136
- mergeTimer = setTimeout(() => {
137
- mergeTimer = null;
138
- flushPasteBuffer();
139
- }, PASTE_MERGE_WINDOW_MS);
140
- }
141
-
142
- callback();
143
- },
144
-
145
- flush(callback) {
146
- if (state === "PENDING") {
147
- flushPasteBuffer();
148
- }
149
- callback();
150
- },
151
- });
152
-
153
- /**
154
- * @param {string} input
155
- * @returns {string}
156
- */
157
- const resolvePlaceholders = (input) => {
158
- /** @type {string[]} */
159
- const contexts = [];
160
-
161
- // Collect paste content for context tags while keeping placeholders.
162
- const text = input.replace(
163
- /\[Pasted text #([a-f0-9]{6}),/g,
164
- (match, hash) => {
165
- const content = pastedContentStore.get(hash);
166
- if (content !== undefined) {
167
- pastedContentStore.delete(hash); // Clean up after use
168
- contexts.push(
169
- `<context id="pasted#${hash}">\n${content}\n</context>`,
170
- );
171
- }
172
- return match; // Keep placeholder in text
173
- },
174
- );
175
-
176
- if (contexts.length > 0) {
177
- return [text, ...contexts].join("\n\n");
178
- }
179
- return text;
180
- };
181
-
182
- return { transform, resolvePlaceholders };
183
- }
package/src/config.d.ts DELETED
@@ -1,36 +0,0 @@
1
- import { ClaudeCodePluginRepo } from "./claudeCodePlugin.mjs";
2
- import { ModelDefinition, PlatformConfig } from "./modelDefinition";
3
- import { ToolUsePattern } from "./tool";
4
- import { AskURLToolOptions } from "./tools/askURL.mjs";
5
- import { AskWebToolOptions } from "./tools/askWeb.mjs";
6
- import { ExecCommandSanboxConfig } from "./tools/execCommand";
7
- import { VoiceInputConfig } from "./voiceInput.mjs";
8
-
9
- export type AppConfig = {
10
- model?: string;
11
- models?: ModelDefinition[];
12
- platforms?: PlatformConfig[];
13
- autoApproval?: {
14
- patterns?: ToolUsePattern[];
15
- maxApprovals?: number;
16
- defaultAction?: "deny" | "ask";
17
- };
18
- sandbox?: ExecCommandSanboxConfig;
19
- tools?: {
20
- askWeb?: AskWebToolOptions;
21
- askURL?: AskURLToolOptions;
22
- };
23
- mcpServers?: Record<string, MCPServerConfig>;
24
- notifyCmd?: { command: string; args?: string[] };
25
- voiceInput?: VoiceInputConfig;
26
- claudeCodePlugins?: ClaudeCodePluginRepo[];
27
- };
28
-
29
- export type MCPServerConfig = {
30
- command: string;
31
- args?: string[];
32
- env?: Record<string, string>;
33
- options?: {
34
- enabledTools?: string[];
35
- };
36
- };
package/src/config.mjs DELETED
@@ -1,197 +0,0 @@
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
- * @typedef {Object} LoadAppConfigOptions
20
- * @property {boolean} [skipTrustCheck] - Skip trust check for config files
21
- * @property {string[]} [configFiles] - Additional config files to load (for batch mode)
22
- * @property {boolean} [skipUserConfig] - Skip default user config files (for batch mode)
23
- */
24
-
25
- /**
26
- * @param {LoadAppConfigOptions} [options]
27
- * @returns {Promise<{appConfig: AppConfig, loadedConfigPath: string[]}>}
28
- */
29
- export async function loadAppConfig(options = {}) {
30
- const {
31
- skipTrustCheck = false,
32
- configFiles = [],
33
- skipUserConfig = false,
34
- } = options;
35
-
36
- // Always load predefined config
37
- const paths = [`${AGENT_ROOT}/config/config.predefined.json`];
38
-
39
- if (!skipUserConfig) {
40
- paths.push(
41
- `${AGENT_USER_CONFIG_DIR}/config.json`,
42
- `${AGENT_USER_CONFIG_DIR}/config.local.json`,
43
- `${AGENT_PROJECT_METADATA_DIR}/config.json`,
44
- `${AGENT_PROJECT_METADATA_DIR}/config.local.json`,
45
- );
46
- }
47
-
48
- // Add explicitly specified config files
49
- paths.push(...configFiles);
50
-
51
- /** @type {string[]} */
52
- const loadedConfigPath = [];
53
- /** @type {AppConfig} */
54
- let merged = {};
55
-
56
- for (const filePath of paths) {
57
- const config = await loadConfigFile(path.resolve(filePath), skipTrustCheck);
58
- if (Object.keys(config).length) {
59
- loadedConfigPath.push(filePath);
60
- }
61
- merged = {
62
- model: config.model || merged.model,
63
- models: [...(config.models ?? []), ...(merged.models ?? [])],
64
- platforms: [...(config.platforms ?? []), ...(merged.platforms ?? [])],
65
- autoApproval: {
66
- defaultAction:
67
- config.autoApproval?.defaultAction ??
68
- merged.autoApproval?.defaultAction,
69
- patterns: [
70
- ...(config.autoApproval?.patterns ?? []),
71
- ...(merged.autoApproval?.patterns ?? []),
72
- ],
73
- maxApprovals:
74
- config.autoApproval?.maxApprovals ??
75
- merged.autoApproval?.maxApprovals,
76
- },
77
- sandbox: config.sandbox ?? merged.sandbox,
78
- tools: {
79
- askWeb: config.tools?.askWeb
80
- ? {
81
- ...(merged.tools?.askWeb ?? {}),
82
- ...config.tools.askWeb,
83
- }
84
- : merged.tools?.askWeb,
85
- askURL: config.tools?.askURL
86
- ? {
87
- ...(merged.tools?.askURL ?? {}),
88
- ...config.tools.askURL,
89
- }
90
- : merged.tools?.askWeb,
91
- },
92
- mcpServers: {
93
- ...(merged.mcpServers ?? {}),
94
- ...(config.mcpServers ?? {}),
95
- },
96
- notifyCmd: config.notifyCmd ?? merged.notifyCmd,
97
- claudeCodePlugins: [
98
- ...(merged.claudeCodePlugins ?? []),
99
- ...(config.claudeCodePlugins ?? []),
100
- ],
101
- voiceInput: config.voiceInput
102
- ? { ...(merged.voiceInput ?? {}), ...config.voiceInput }
103
- : merged.voiceInput,
104
- };
105
- }
106
-
107
- return { appConfig: merged, loadedConfigPath };
108
- }
109
-
110
- /**
111
- * @param {string} filePath
112
- * @param {boolean} [skipTrustCheck=false]
113
- * @returns {Promise<AppConfig>}
114
- */
115
- export async function loadConfigFile(filePath, skipTrustCheck = false) {
116
- let content;
117
- try {
118
- content = await fs.readFile(filePath, "utf-8");
119
- } catch (err) {
120
- if (err instanceof Error && "code" in err && err.code === "ENOENT") {
121
- return {};
122
- }
123
- throw err;
124
- }
125
-
126
- const hash = crypto.createHash("sha256").update(content).digest("hex");
127
- const isTrusted = skipTrustCheck || (await isConfigHashTrusted(hash));
128
-
129
- if (!isTrusted) {
130
- if (!process.stdout.isTTY) {
131
- console.warn(
132
- styleText(
133
- "yellow",
134
- `WARNING: Config file found at '${filePath}' but cannot ask for approval without a TTY. Skipping.`,
135
- ),
136
- );
137
- return {};
138
- }
139
-
140
- const rl = readline.createInterface({
141
- input: process.stdin,
142
- output: process.stdout,
143
- });
144
-
145
- const answer = await new Promise((resolve) => {
146
- console.log(styleText("blue", `\nFound a config file at ${filePath}`));
147
- rl.question(
148
- styleText("yellow", "Do you want to load this file? (y/N) "),
149
- (ans) => {
150
- rl.close();
151
- resolve(ans);
152
- },
153
- );
154
- });
155
-
156
- if (answer.toLowerCase() !== "y") {
157
- console.log(styleText("yellow", "Skipping local config file."));
158
- return {};
159
- }
160
-
161
- await trustConfigHash(hash);
162
- }
163
-
164
- try {
165
- const commentRemovedContent = content.replace(/^ *\/\/.+$/gm, "");
166
- const parsed = JSON.parse(commentRemovedContent);
167
- return /** @type {AppConfig} */ (evalJSONConfig(parsed));
168
- } catch (err) {
169
- throw new Error(`Failed to parse JSON config at ${filePath}`, {
170
- cause: err,
171
- });
172
- }
173
- }
174
-
175
- /**
176
- * @param {string} hash
177
- * @returns {Promise<boolean>}
178
- */
179
- async function isConfigHashTrusted(hash) {
180
- try {
181
- await fs.access(path.join(TRUSTED_CONFIG_HASHES_DIR, hash));
182
- return true;
183
- } catch (err) {
184
- if (err instanceof Error && "code" in err && err.code === "ENOENT") {
185
- return false;
186
- }
187
- throw err;
188
- }
189
- }
190
-
191
- /**
192
- * @param {string} hash
193
- */
194
- async function trustConfigHash(hash) {
195
- await fs.mkdir(TRUSTED_CONFIG_HASHES_DIR, { recursive: true });
196
- await fs.writeFile(path.join(TRUSTED_CONFIG_HASHES_DIR, hash), "");
197
- }
@@ -1,283 +0,0 @@
1
- /** @import { ClaudeCodePlugin } from "../claudeCodePlugin.mjs" */
2
-
3
- import crypto from "node:crypto";
4
- import fs from "node:fs/promises";
5
- import path from "node:path";
6
- import { parse as parseYaml } from "yaml";
7
- import {
8
- AGENT_CACHE_DIR,
9
- AGENT_PROJECT_METADATA_DIR,
10
- AGENT_ROOT,
11
- AGENT_USER_CONFIG_DIR,
12
- } from "../env.mjs";
13
-
14
- /**
15
- * @typedef {Object} AgentRole
16
- * @property {string} id
17
- * @property {string} description
18
- * @property {string} content
19
- * @property {string} filePath
20
- * @property {boolean} claudeOriginated
21
- * @property {string} [import]
22
- */
23
-
24
- /**
25
- * Load all agent roles from the predefined directories.
26
- * @param {ClaudeCodePlugin[]} [claudeCodePlugins]
27
- * @returns {Promise<Map<string, AgentRole>>}
28
- */
29
- export async function loadAgentRoles(claudeCodePlugins) {
30
- /** @type {Array<{dir: string, idPrefix: string, only?: RegExp}>} */
31
- const agentDirs = [
32
- {
33
- dir: path.resolve(AGENT_ROOT, "config", "agents.predefined"),
34
- idPrefix: "",
35
- },
36
- { dir: path.resolve(AGENT_USER_CONFIG_DIR, "agents"), idPrefix: "" },
37
- { dir: path.resolve(AGENT_PROJECT_METADATA_DIR, "agents"), idPrefix: "" },
38
- {
39
- dir: path.resolve(process.cwd(), ".claude", "agents"),
40
- idPrefix: "claude:",
41
- },
42
- ];
43
-
44
- // Add plugin directories if provided
45
- if (claudeCodePlugins) {
46
- for (const plugin of claudeCodePlugins) {
47
- agentDirs.push({
48
- dir: path.join(plugin.path, "agents"),
49
- idPrefix: `claude/${plugin.name}:`,
50
- only: plugin.only,
51
- });
52
- }
53
- }
54
-
55
- /** @type {Map<string, AgentRole>} */
56
- const roles = new Map();
57
-
58
- for (const { dir, idPrefix, only } of agentDirs) {
59
- const files = await getMarkdownFiles(dir).catch((err) => {
60
- if (err.code !== "ENOENT") {
61
- console.warn(`Failed to list agent roles in ${dir}:`, err);
62
- }
63
- return [];
64
- });
65
-
66
- for (const file of files) {
67
- const fullPath = path.join(dir, file);
68
- const content = await fs.readFile(fullPath, "utf-8").catch((err) => {
69
- console.warn(`Failed to read agent role file ${fullPath}:`, err);
70
- return null;
71
- });
72
-
73
- if (content === null) continue;
74
-
75
- // Filter by only pattern if specified
76
- if (only && !only.test(file)) {
77
- continue;
78
- }
79
-
80
- let role = parseAgentRole(file, content, fullPath, idPrefix);
81
- if (role.import) {
82
- role = await mergeRemoteRole(role, file, fullPath);
83
- }
84
-
85
- roles.set(role.id, role);
86
- }
87
- }
88
-
89
- return roles;
90
- }
91
-
92
- /**
93
- * Merges a remote role into a local role.
94
- * @param {AgentRole} localRole
95
- * @param {string} relativePath
96
- * @param {string} fullPath
97
- * @returns {Promise<AgentRole>}
98
- */
99
- async function mergeRemoteRole(localRole, relativePath, fullPath) {
100
- const importUrl = localRole.import;
101
- if (!importUrl) {
102
- return localRole;
103
- }
104
-
105
- const fetchedContent = await fetchAndCacheRole(importUrl).catch((err) => {
106
- console.warn(`Failed to fetch agent role from ${importUrl}:`, err);
107
- return null;
108
- });
109
-
110
- if (!fetchedContent) {
111
- return localRole;
112
- }
113
-
114
- const remoteRole = parseAgentRole(relativePath, fetchedContent, fullPath);
115
-
116
- return {
117
- ...remoteRole,
118
- ...localRole, // Local overrides
119
- content: `${remoteRole.content}\n\n---\n\n${localRole.content}`.trim(),
120
- description: localRole.description || remoteRole.description || "",
121
- };
122
- }
123
-
124
- /**
125
- * Fetch an agent role from a URL and cache it.
126
- * @param {string} url
127
- * @returns {Promise<string>}
128
- */
129
- async function fetchAndCacheRole(url) {
130
- const hash = crypto.createHash("sha256").update(url).digest("hex");
131
- const cacheDir = path.join(AGENT_CACHE_DIR, "agents");
132
- const cachePath = path.join(cacheDir, hash);
133
-
134
- const cachedContent = await fs.readFile(cachePath, "utf-8").catch(() => null);
135
- if (cachedContent !== null) {
136
- return cachedContent;
137
- }
138
-
139
- const fetchedContent = await fetchContent(url);
140
-
141
- // Attempt to cache, but don't block or fail on errors
142
- fs.mkdir(cacheDir, { recursive: true })
143
- .then(() => fs.writeFile(cachePath, fetchedContent, "utf-8"))
144
- .catch((err) => {
145
- console.warn(`Failed to write cache for ${url}:`, err);
146
- });
147
-
148
- return fetchedContent;
149
- }
150
-
151
- /**
152
- * Fetch content from a URL.
153
- * @param {string} url
154
- * @returns {Promise<string>}
155
- */
156
- async function fetchContent(url) {
157
- const githubMatch = url.match(
158
- /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/blob\/([^/]+)\/(.+)$/,
159
- );
160
-
161
- if (githubMatch) {
162
- const [, owner, repo, ref, path] = githubMatch;
163
- const apiUrl = `repos/${owner}/${repo}/contents/${path}?ref=${ref}`;
164
- try {
165
- const { execFileSync } = await import("node:child_process");
166
- return execFileSync(
167
- "gh",
168
- ["api", "-H", "Accept: application/vnd.github.v3.raw", apiUrl],
169
- { encoding: "utf-8" },
170
- );
171
- } catch (err) {
172
- throw new Error(`Failed to fetch from GitHub via gh CLI: ${err}`);
173
- }
174
- }
175
-
176
- const response = await fetch(url);
177
- if (!response.ok) {
178
- throw new Error(
179
- `Failed to fetch agent role from ${url}: ${response.status} ${response.statusText}`,
180
- );
181
- }
182
- return response.text();
183
- }
184
-
185
- /**
186
- * Recursively get all markdown files in a directory.
187
- * @param {string} dir
188
- * @param {string} [baseDir]
189
- * @returns {Promise<string[]>}
190
- */
191
- async function getMarkdownFiles(dir, baseDir = dir) {
192
- const entries = await fs.readdir(dir, { withFileTypes: true });
193
- const files = [];
194
-
195
- for (const entry of entries) {
196
- const fullPath = path.join(dir, entry.name);
197
- let isDirectory = entry.isDirectory();
198
- let isFile = entry.isFile();
199
-
200
- if (entry.isSymbolicLink()) {
201
- const stat = await fs.stat(fullPath).catch(() => null);
202
- if (!stat) continue;
203
- isDirectory = stat.isDirectory();
204
- isFile = stat.isFile();
205
- }
206
-
207
- if (isDirectory) {
208
- files.push(...(await getMarkdownFiles(fullPath, baseDir)));
209
- } else if (isFile && entry.name.endsWith(".md")) {
210
- files.push(path.relative(baseDir, fullPath));
211
- }
212
- }
213
-
214
- return files;
215
- }
216
-
217
- /**
218
- * Parse an agent role file content.
219
- * @param {string} relativePath
220
- * @param {string} fileContent
221
- * @param {string} fullPath
222
- * @param {string} [idPrefix=""]
223
- * @returns {AgentRole}
224
- */
225
- function parseAgentRole(relativePath, fileContent, fullPath, idPrefix = "") {
226
- const rawId = relativePath.replace(/\.md$/, "");
227
- const id = idPrefix + rawId;
228
- const claudeOriginated = idPrefix.startsWith("claude");
229
-
230
- // Match YAML frontmatter
231
- const match = fileContent.match(
232
- /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/,
233
- );
234
-
235
- if (!match) {
236
- return {
237
- id,
238
- description: "",
239
- content: fileContent.trim(),
240
- filePath: fullPath,
241
- claudeOriginated,
242
- };
243
- }
244
-
245
- /** @type {{description?:string; import?:string}} */
246
- let frontmatter;
247
- try {
248
- frontmatter = /** @type {{description?:string; import?:string}} */ (
249
- parseYaml(match[1])
250
- );
251
- } catch (_err) {
252
- return {
253
- id,
254
- description: parseFrontmatterField(match[1], "description") ?? "",
255
- content: fileContent.trim(),
256
- filePath: fullPath,
257
- claudeOriginated,
258
- };
259
- }
260
- const content = match[2].trim();
261
-
262
- return {
263
- id,
264
- description: frontmatter.description ?? "",
265
- content,
266
- filePath: fullPath,
267
- claudeOriginated,
268
- import: frontmatter.import,
269
- };
270
- }
271
-
272
- /**
273
- * Parse a field from YAML frontmatter.
274
- * @param {string} frontmatter
275
- * @param {string} field
276
- * @returns {string | undefined}
277
- */
278
-
279
- function parseFrontmatterField(frontmatter, field) {
280
- const regex = new RegExp(`^${field}:\\s*(.*)$`, "m");
281
- const match = frontmatter.match(regex);
282
- return match ? match[1].trim() : undefined;
283
- }