@iinm/plain-agent 1.8.4 → 1.8.5
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.
- package/bin/plain +1 -1
- package/package.json +7 -5
- package/sandbox/bin/plain-sandbox +13 -0
- package/src/agent.d.ts +52 -0
- package/src/agent.mjs +204 -0
- package/src/agentLoop.mjs +419 -0
- package/src/agentState.mjs +41 -0
- package/src/claudeCodePlugin.mjs +164 -0
- package/src/cliArgs.mjs +175 -0
- package/src/cliBatch.mjs +147 -0
- package/src/cliCommands.mjs +283 -0
- package/src/cliCompleter.mjs +227 -0
- package/src/cliCost.mjs +309 -0
- package/src/cliFormatter.mjs +413 -0
- package/src/cliInteractive.mjs +529 -0
- package/src/cliInterruptTransform.mjs +51 -0
- package/src/cliMuteTransform.mjs +26 -0
- package/src/cliPasteTransform.mjs +183 -0
- package/src/config.d.ts +36 -0
- package/src/config.mjs +197 -0
- package/src/context/loadAgentRoles.mjs +294 -0
- package/src/context/loadPrompts.mjs +337 -0
- package/src/context/loadUserMessageContext.mjs +147 -0
- package/src/costTracker.mjs +210 -0
- package/src/env.mjs +44 -0
- package/src/main.mjs +281 -0
- package/src/mcpClient.mjs +351 -0
- package/src/mcpIntegration.mjs +160 -0
- package/src/model.d.ts +109 -0
- package/src/modelCaller.mjs +32 -0
- package/src/modelDefinition.d.ts +92 -0
- package/src/prompt.mjs +138 -0
- package/src/providers/anthropic.d.ts +248 -0
- package/src/providers/anthropic.mjs +587 -0
- package/src/providers/bedrock.d.ts +249 -0
- package/src/providers/bedrock.mjs +700 -0
- package/src/providers/gemini.d.ts +208 -0
- package/src/providers/gemini.mjs +754 -0
- package/src/providers/openai.d.ts +281 -0
- package/src/providers/openai.mjs +544 -0
- package/src/providers/openaiCompatible.d.ts +147 -0
- package/src/providers/openaiCompatible.mjs +652 -0
- package/src/providers/platform/awsSigV4.mjs +184 -0
- package/src/providers/platform/azure.mjs +42 -0
- package/src/providers/platform/bedrock.mjs +78 -0
- package/src/providers/platform/googleCloud.mjs +34 -0
- package/src/subagent.mjs +265 -0
- package/src/tmpfile.mjs +27 -0
- package/src/tool.d.ts +74 -0
- package/src/toolExecutor.mjs +236 -0
- package/src/toolInputValidator.mjs +183 -0
- package/src/toolUseApprover.mjs +99 -0
- package/src/tools/askURL.mjs +209 -0
- package/src/tools/askWeb.mjs +208 -0
- package/src/tools/compactContext.d.ts +4 -0
- package/src/tools/compactContext.mjs +87 -0
- package/src/tools/execCommand.d.ts +22 -0
- package/src/tools/execCommand.mjs +200 -0
- package/src/tools/patchFile.d.ts +4 -0
- package/src/tools/patchFile.mjs +133 -0
- package/src/tools/switchToMainAgent.d.ts +3 -0
- package/src/tools/switchToMainAgent.mjs +43 -0
- package/src/tools/switchToSubagent.d.ts +4 -0
- package/src/tools/switchToSubagent.mjs +59 -0
- package/src/tools/tmuxCommand.d.ts +14 -0
- package/src/tools/tmuxCommand.mjs +194 -0
- package/src/tools/writeFile.d.ts +4 -0
- package/src/tools/writeFile.mjs +56 -0
- package/src/usageStore.mjs +167 -0
- package/src/utils/evalJSONConfig.mjs +72 -0
- package/src/utils/matchValue.d.ts +6 -0
- package/src/utils/matchValue.mjs +40 -0
- package/src/utils/noThrow.mjs +31 -0
- package/src/utils/notify.mjs +29 -0
- package/src/utils/parseFileRange.mjs +18 -0
- package/src/utils/readFileRange.mjs +33 -0
- package/src/utils/retryOnError.mjs +41 -0
- package/src/voiceInput.mjs +61 -0
- package/src/voiceInputGemini.mjs +105 -0
- package/src/voiceInputOpenAI.mjs +104 -0
- package/src/voiceInputSession.mjs +543 -0
- package/src/voiceToggleKey.mjs +62 -0
- package/dist/main.mjs +0 -473
- package/dist/main.mjs.map +0 -7
|
@@ -0,0 +1,183 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
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
|
+
const files = (
|
|
56
|
+
await Promise.all(
|
|
57
|
+
agentDirs.map(async ({ dir, idPrefix, only }) => {
|
|
58
|
+
const files = await getMarkdownFiles(dir).catch((err) => {
|
|
59
|
+
if (err.code !== "ENOENT") {
|
|
60
|
+
console.warn(`Failed to list agent roles in ${dir}:`, err);
|
|
61
|
+
}
|
|
62
|
+
return /** @type {string[]} */ ([]);
|
|
63
|
+
});
|
|
64
|
+
return files.map((file) => ({ dir, file, idPrefix, only }));
|
|
65
|
+
}),
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
.flat()
|
|
69
|
+
// Filter by only pattern if specified
|
|
70
|
+
.filter(({ file, only }) => !(only && !only.test(file)));
|
|
71
|
+
|
|
72
|
+
const roles = /** @type {AgentRole[]} */ (
|
|
73
|
+
(
|
|
74
|
+
await Promise.all(
|
|
75
|
+
files.map(async ({ dir, file, idPrefix }) => {
|
|
76
|
+
const fullPath = path.join(dir, file);
|
|
77
|
+
const content = await fs.readFile(fullPath, "utf-8").catch((err) => {
|
|
78
|
+
console.warn(`Failed to read agent role file ${fullPath}:`, err);
|
|
79
|
+
return null;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
if (content === null) return null;
|
|
83
|
+
|
|
84
|
+
let role = parseAgentRole(file, content, fullPath, idPrefix);
|
|
85
|
+
if (role.import) {
|
|
86
|
+
try {
|
|
87
|
+
role = await mergeRemoteRole(role, file, fullPath);
|
|
88
|
+
} catch (err) {
|
|
89
|
+
console.warn(`Failed to import remote role ${role.id}:`, err);
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return role;
|
|
95
|
+
}),
|
|
96
|
+
)
|
|
97
|
+
).filter((role) => role)
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
return new Map(roles.map((role) => [role.id, role]));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Merges a remote role into a local role.
|
|
105
|
+
* @param {AgentRole} localRole
|
|
106
|
+
* @param {string} relativePath
|
|
107
|
+
* @param {string} fullPath
|
|
108
|
+
* @returns {Promise<AgentRole>}
|
|
109
|
+
*/
|
|
110
|
+
async function mergeRemoteRole(localRole, relativePath, fullPath) {
|
|
111
|
+
const importUrl = localRole.import;
|
|
112
|
+
if (!importUrl) {
|
|
113
|
+
return localRole;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const fetchedContent = await fetchAndCacheRole(importUrl).catch((err) => {
|
|
117
|
+
console.warn(`Failed to fetch agent role from ${importUrl}:`, err);
|
|
118
|
+
return null;
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
if (!fetchedContent) {
|
|
122
|
+
return localRole;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const remoteRole = parseAgentRole(relativePath, fetchedContent, fullPath);
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
...remoteRole,
|
|
129
|
+
...localRole, // Local overrides
|
|
130
|
+
content: `${remoteRole.content}\n\n---\n\n${localRole.content}`.trim(),
|
|
131
|
+
description: localRole.description || remoteRole.description || "",
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Fetch an agent role from a URL and cache it.
|
|
137
|
+
* @param {string} url
|
|
138
|
+
* @returns {Promise<string>}
|
|
139
|
+
*/
|
|
140
|
+
async function fetchAndCacheRole(url) {
|
|
141
|
+
const hash = crypto.createHash("sha256").update(url).digest("hex");
|
|
142
|
+
const cacheDir = path.join(AGENT_CACHE_DIR, "agents");
|
|
143
|
+
const cachePath = path.join(cacheDir, hash);
|
|
144
|
+
|
|
145
|
+
const cachedContent = await fs.readFile(cachePath, "utf-8").catch(() => null);
|
|
146
|
+
if (cachedContent !== null) {
|
|
147
|
+
return cachedContent;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const fetchedContent = await fetchContent(url);
|
|
151
|
+
|
|
152
|
+
// Attempt to cache, but don't block or fail on errors
|
|
153
|
+
fs.mkdir(cacheDir, { recursive: true })
|
|
154
|
+
.then(() => fs.writeFile(cachePath, fetchedContent, "utf-8"))
|
|
155
|
+
.catch((err) => {
|
|
156
|
+
console.warn(`Failed to write cache for ${url}:`, err);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
return fetchedContent;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Fetch content from a URL.
|
|
164
|
+
* @param {string} url
|
|
165
|
+
* @returns {Promise<string>}
|
|
166
|
+
*/
|
|
167
|
+
async function fetchContent(url) {
|
|
168
|
+
const githubMatch = url.match(
|
|
169
|
+
/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/blob\/([^/]+)\/(.+)$/,
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
if (githubMatch) {
|
|
173
|
+
const [, owner, repo, ref, path] = githubMatch;
|
|
174
|
+
const apiUrl = `repos/${owner}/${repo}/contents/${path}?ref=${ref}`;
|
|
175
|
+
try {
|
|
176
|
+
const { execFileSync } = await import("node:child_process");
|
|
177
|
+
return execFileSync(
|
|
178
|
+
"gh",
|
|
179
|
+
["api", "-H", "Accept: application/vnd.github.v3.raw", apiUrl],
|
|
180
|
+
{ encoding: "utf-8" },
|
|
181
|
+
);
|
|
182
|
+
} catch (err) {
|
|
183
|
+
throw new Error(`Failed to fetch from GitHub via gh CLI: ${err}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const response = await fetch(url);
|
|
188
|
+
if (!response.ok) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
`Failed to fetch agent role from ${url}: ${response.status} ${response.statusText}`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
return response.text();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Recursively get all markdown files in a directory.
|
|
198
|
+
* @param {string} dir
|
|
199
|
+
* @param {string} [baseDir]
|
|
200
|
+
* @returns {Promise<string[]>}
|
|
201
|
+
*/
|
|
202
|
+
async function getMarkdownFiles(dir, baseDir = dir) {
|
|
203
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
204
|
+
const files = [];
|
|
205
|
+
|
|
206
|
+
for (const entry of entries) {
|
|
207
|
+
const fullPath = path.join(dir, entry.name);
|
|
208
|
+
let isDirectory = entry.isDirectory();
|
|
209
|
+
let isFile = entry.isFile();
|
|
210
|
+
|
|
211
|
+
if (entry.isSymbolicLink()) {
|
|
212
|
+
const stat = await fs.stat(fullPath).catch(() => null);
|
|
213
|
+
if (!stat) continue;
|
|
214
|
+
isDirectory = stat.isDirectory();
|
|
215
|
+
isFile = stat.isFile();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (isDirectory) {
|
|
219
|
+
files.push(...(await getMarkdownFiles(fullPath, baseDir)));
|
|
220
|
+
} else if (isFile && entry.name.endsWith(".md")) {
|
|
221
|
+
files.push(path.relative(baseDir, fullPath));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return files;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Parse an agent role file content.
|
|
230
|
+
* @param {string} relativePath
|
|
231
|
+
* @param {string} fileContent
|
|
232
|
+
* @param {string} fullPath
|
|
233
|
+
* @param {string} [idPrefix=""]
|
|
234
|
+
* @returns {AgentRole}
|
|
235
|
+
*/
|
|
236
|
+
function parseAgentRole(relativePath, fileContent, fullPath, idPrefix = "") {
|
|
237
|
+
const rawId = relativePath.replace(/\.md$/, "");
|
|
238
|
+
const id = idPrefix + rawId;
|
|
239
|
+
const claudeOriginated = idPrefix.startsWith("claude");
|
|
240
|
+
|
|
241
|
+
// Match YAML frontmatter
|
|
242
|
+
const match = fileContent.match(
|
|
243
|
+
/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/,
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
if (!match) {
|
|
247
|
+
return {
|
|
248
|
+
id,
|
|
249
|
+
description: "",
|
|
250
|
+
content: fileContent.trim(),
|
|
251
|
+
filePath: fullPath,
|
|
252
|
+
claudeOriginated,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** @type {{description?:string; import?:string}} */
|
|
257
|
+
let frontmatter;
|
|
258
|
+
try {
|
|
259
|
+
frontmatter = /** @type {{description?:string; import?:string}} */ (
|
|
260
|
+
parseYaml(match[1])
|
|
261
|
+
);
|
|
262
|
+
} catch (_err) {
|
|
263
|
+
return {
|
|
264
|
+
id,
|
|
265
|
+
description: parseFrontmatterField(match[1], "description") ?? "",
|
|
266
|
+
content: fileContent.trim(),
|
|
267
|
+
filePath: fullPath,
|
|
268
|
+
claudeOriginated,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
const content = match[2].trim();
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
id,
|
|
275
|
+
description: frontmatter.description ?? "",
|
|
276
|
+
content,
|
|
277
|
+
filePath: fullPath,
|
|
278
|
+
claudeOriginated,
|
|
279
|
+
import: frontmatter.import,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Parse a field from YAML frontmatter.
|
|
285
|
+
* @param {string} frontmatter
|
|
286
|
+
* @param {string} field
|
|
287
|
+
* @returns {string | undefined}
|
|
288
|
+
*/
|
|
289
|
+
|
|
290
|
+
function parseFrontmatterField(frontmatter, field) {
|
|
291
|
+
const regex = new RegExp(`^${field}:\\s*(.*)$`, "m");
|
|
292
|
+
const match = frontmatter.match(regex);
|
|
293
|
+
return match ? match[1].trim() : undefined;
|
|
294
|
+
}
|