@iinm/plain-agent 1.8.5 → 1.8.7

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/README.md CHANGED
@@ -29,9 +29,8 @@ A lightweight CLI-based coding agent.
29
29
 
30
30
  - Node.js 22 or later
31
31
  - LLM provider credentials
32
+ - [ripgrep](https://github.com/burntsushi/ripgrep), [fd](https://github.com/sharkdp/fd)
32
33
  - Bash / Docker for sandboxed execution
33
- - [ripgrep](https://github.com/burntsushi/ripgrep)
34
- - [fd](https://github.com/sharkdp/fd)
35
34
 
36
35
  ## Quick Start
37
36
 
@@ -50,8 +49,8 @@ Create the configuration.
50
49
  ```js
51
50
  // ~/.config/plain-agent/config.local.json
52
51
  {
52
+ // Set default model
53
53
  "model": "claude-sonnet-4-6+thinking-high",
54
- // "model": "gpt-5.5+thinking-high",
55
54
 
56
55
  // Configure the providers you want to use
57
56
  "platforms": [
@@ -59,7 +58,7 @@ Create the configuration.
59
58
  "name": "anthropic",
60
59
  "variant": "default",
61
60
  "apiKey": "<ANTHROPIC_API_KEY>"
62
- // Or
61
+ // Or read from environment variable
63
62
  // "apiKey": { "$env": "ANTHROPIC_API_KEY" }
64
63
  },
65
64
  {
@@ -71,17 +70,16 @@ Create the configuration.
71
70
  "name": "openai",
72
71
  "variant": "default",
73
72
  "apiKey": "<OPENAI_API_KEY>"
74
- },
73
+ }
75
74
  ],
76
75
 
77
- // Optional
76
+ // (Optional) Enable web search tools
78
77
  "tools": {
79
78
  // askWeb: Searches the web to answer questions requiring up-to-date information or external sources.
80
79
  "askWeb": {
81
80
  "provider": "gemini",
82
81
  "apiKey": "<GEMINI_API_KEY>",
83
82
  "model": "gemini-3-flash-preview"
84
-
85
83
  // Or use Vertex AI (Requires gcloud CLI to get authentication token)
86
84
  // "provider": "gemini-vertex-ai",
87
85
  // "baseURL": "https://aiplatform.googleapis.com/v1beta1/projects/<project_id>/locations/<location>",
@@ -89,18 +87,14 @@ Create the configuration.
89
87
  },
90
88
 
91
89
  // askURL: Answers questions based on provided URL content.
92
- // Directly injecting URL content into context is not supported to prevent prompt injection.
93
90
  "askURL": {
94
91
  "provider": "gemini",
95
- "apiKey": "<GEMINI_API_KEY>"
92
+ "apiKey": "<GEMINI_API_KEY>",
96
93
  "model": "gemini-3-flash-preview"
97
-
98
94
  // Or use Vertex AI (Requires gcloud CLI to get authentication token)
99
95
  }
100
- },
101
-
96
+ }
102
97
  }
103
-
104
98
  ```
105
99
 
106
100
  <details>
@@ -118,6 +112,7 @@ Create the configuration.
118
112
  "azureConfigDir": "/home/xxx/.azure-for-agent"
119
113
  },
120
114
  {
115
+ // Requires AWS CLI to get credentials
121
116
  "name": "bedrock",
122
117
  "variant": "default",
123
118
  "baseURL": "https://bedrock-runtime.<region>.amazonaws.com",
@@ -128,8 +123,8 @@ Create the configuration.
128
123
  "name": "vertex-ai",
129
124
  "variant": "default",
130
125
  "baseURL": "https://aiplatform.googleapis.com/v1beta1/projects/<project>/locations/<location>",
131
- // Optional
132
- "account": "<service_account_email>"
126
+ // (Optional) Impersonate this service account to obtain an auth token
127
+ "account": "<SERVICE_ACCOUNT_EMAIL>"
133
128
  }
134
129
  ]
135
130
  }
@@ -302,7 +297,7 @@ plain cost
302
297
  plain cost --from 2026-04-01 --to 2026-04-30
303
298
  ```
304
299
 
305
- (Optional) Configure plain-agent for your project.
300
+ Configure plain-agent for your project.
306
301
 
307
302
  ```
308
303
  /configure Auto-approve file writes and patches
@@ -417,14 +412,13 @@ Files are loaded in the following order. Settings in later files override earlie
417
412
 
418
413
  // MCP Tool naming convention: mcp__<serverName>__<toolName>
419
414
  {
420
- "toolName": { "$regex": "slack_(read|search)_.+" },
415
+ "toolName": { "$regex": "mcp__slack__slack_(read|search)_.+" },
421
416
  "action": "allow"
422
417
  }
423
418
  ]
424
419
  },
425
420
 
426
- // (Optional) Sandbox environment for the exec_command and tmux_command tools
427
- // https://github.com/iinm/plain-agent/tree/main/sandbox
421
+ // Sandbox environment for the exec_command and tmux_command tools
428
422
  "sandbox": {
429
423
  "command": "plain-sandbox",
430
424
  "args": ["--allow-write", "--skip-build", "--keep-alive", "30"],
@@ -458,7 +452,7 @@ Files are loaded in the following order. Settings in later files override earlie
458
452
  "command": "npx",
459
453
  "args": ["-y", "chrome-devtools-mcp@latest", "--isolated"]
460
454
  },
461
- // ⚠️ Add this to config.local.json to avoid committing secrets to Git
455
+ // ⚠️ Add to config.local.json to avoid committing secrets to Git
462
456
  "slack": {
463
457
  "command": "npx",
464
458
  "args": ["-y", "mcp-remote", "https://mcp.slack.com/mcp", "--header", "Authorization:Bearer <SLACK_TOKEN>"],
@@ -475,21 +469,22 @@ Files are loaded in the following order. Settings in later files override earlie
475
469
  "command": "npx",
476
470
  "args": ["-y", "mcp-remote", "https://knowledge-mcp.global.api.aws"]
477
471
  },
478
- // ⚠️ Add this to config.local.json to avoid committing secrets to Git
472
+ // ⚠️ Add to config.local.json to avoid committing secrets to Git
479
473
  "google_developer-knowledge": {
480
474
  "command": "npx",
481
475
  "args": ["-y", "mcp-remote", "https://developerknowledge.googleapis.com/mcp", "--header", "X-Goog-Api-Key:<GOOGLE_API_KEY>"]
482
476
  }
483
477
  },
484
478
 
485
- // Override default notification command (falls back to terminal bell)
486
- // "notifyCmd": { "command": "plain-notify-desktop", "args": [] }
479
+ // Override default notification command
480
+ "notifyCmd": { "command": "plain-notify-desktop", "args": [] }
487
481
 
488
- // (Optional) Voice input. See "Voice Input" below.
489
- // "voiceInput": {
490
- // "provider": "openai",
491
- // "apiKey": "<OPENAI_API_KEY>"
492
- // }
482
+ // Voice input. See "Voice Input" below.
483
+ // ⚠️ Add to config.local.json to avoid committing secrets to Git
484
+ "voiceInput": {
485
+ "provider": "openai",
486
+ "apiKey": "<OPENAI_API_KEY>"
487
+ }
493
488
  }
494
489
  ```
495
490
  </details>
@@ -518,6 +513,7 @@ The agent searches for prompts in the following directories:
518
513
 
519
514
  - `~/.config/plain-agent/prompts/`
520
515
  - `.plain-agent/prompts/`
516
+ - `.plain-agent/prompts/skills/`
521
517
  - `.claude/commands/`
522
518
  - `.claude/skills/`
523
519
 
@@ -533,19 +529,6 @@ description: Create a commit message based on staged changes
533
529
  Review the staged changes and create a concise commit message following the conventional commits specification.
534
530
  ```
535
531
 
536
- You can also import remote prompts with the `import` field:
537
-
538
- ```md
539
- ---
540
- import: https://raw.githubusercontent.com/anthropics/claude-code/5cff78741f54a0dcfaeb11d29b9ea9a83f3882ff/plugins/feature-dev/commands/feature-dev.md
541
- ---
542
-
543
- - Use memory file instead of TodoWrite
544
- - Parallel execution of subagents is not supported. Delegate to subagents sequentially.
545
- ```
546
-
547
- Remote prompts are fetched and cached locally. The local content will be appended to the imported content.
548
-
549
532
  ### Shortcuts
550
533
 
551
534
  Prompts located in a `shortcuts/` subdirectory (e.g., `.plain-agent/prompts/shortcuts/commit.md`) can be invoked directly as a top-level command (e.g., `/commit`).
@@ -572,18 +555,6 @@ description: Simplifies and refines code for clarity and maintainability
572
555
  You are a code simplifier. Your role is to refactor code while preserving its functionality.
573
556
  ```
574
557
 
575
- You can also import remote subagent definitions with the `import` field:
576
-
577
- ```md
578
- ---
579
- import: https://raw.githubusercontent.com/anthropics/claude-code/f7ab5c799caf2ec8c7cd1b99d2bc2f158459ef5e/plugins/pr-review-toolkit/agents/code-simplifier.md
580
- ---
581
-
582
- Use AGENTS.md instead of CLAUDE.md in this project.
583
- ```
584
-
585
- Remote subagents are fetched and cached locally. The local content will be appended to the imported content.
586
-
587
558
  ## Claude Code Plugin Support
588
559
 
589
560
  Example:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iinm/plain-agent",
3
- "version": "1.8.5",
3
+ "version": "1.8.7",
4
4
  "description": "A lightweight CLI-based coding agent",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -33,10 +33,7 @@
33
33
  "lint": "npx @biomejs/biome check",
34
34
  "fix": "npx @biomejs/biome check --fix"
35
35
  },
36
- "dependencies": {
37
- "diff": "^8.0.4",
38
- "yaml": "^2.8.3"
39
- },
36
+ "dependencies": {},
40
37
  "devDependencies": {
41
38
  "@biomejs/biome": "^2.4.12",
42
39
  "@types/node": "^22.19.17",
@@ -8,8 +8,12 @@
8
8
  * @import { SwitchToSubagentInput } from "./tools/switchToSubagent"
9
9
  */
10
10
 
11
+ import { execFile } from "node:child_process";
12
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
13
+ import os from "node:os";
14
+ import path from "node:path";
11
15
  import { styleText } from "node:util";
12
- import { createPatch } from "diff";
16
+ import { noThrow } from "./utils/noThrow.mjs";
13
17
 
14
18
  /** Length above which a single-line arg forces block-form rendering. */
15
19
  const ARG_BLOCK_LENGTH_THRESHOLD = 60;
@@ -57,9 +61,11 @@ export function formatArgs(args) {
57
61
  /**
58
62
  * Format tool use for display.
59
63
  * @param {MessageContentToolUse} toolUse
60
- * @returns {string}
64
+ * @param {{ createDiff?: (oldContent: string, newContent: string) => Promise<string | null> }} [options]
65
+ * @returns {Promise<string>}
61
66
  */
62
- export function formatToolUse(toolUse) {
67
+ export async function formatToolUse(toolUse, options = {}) {
68
+ const { createDiff = tryGitDiff } = options;
63
69
  const { toolName, input } = toolUse;
64
70
 
65
71
  if (toolName === "exec_command") {
@@ -99,23 +105,26 @@ export function formatToolUse(toolUse) {
99
105
  diffs.push({ search, replace });
100
106
  }
101
107
 
102
- const highlightedDiff = diffs
103
- .map(
104
- ({ search, replace }) =>
105
- `${createPatch(patchFileInput.filePath || "", search, replace)
106
- .replace(/^-.+$/gm, (match) => styleText("red", match))
107
- .replace(/^\+.+$/gm, (match) => styleText("green", match))
108
- .replace(/^@@.+$/gm, (match) => styleText("gray", match))
109
- .replace(/^\$/gm, (match) =>
110
- styleText("gray", match),
111
- )}\n-------\n${replace}`,
112
- )
113
- .join("\n\n");
108
+ const highlightedDiff = await Promise.all(
109
+ diffs.map(async ({ search, replace }) => {
110
+ const gitDiffOutput = await createDiff(search, replace);
111
+ if (gitDiffOutput) {
112
+ return `${gitDiffOutput}\n-------\n${replace}`;
113
+ }
114
+ return [
115
+ `${styleText("yellow", "(git diff unavailable, showing plain diff)")}`,
116
+ "--- old",
117
+ `${search}`,
118
+ "+++ new",
119
+ `${replace}`,
120
+ ].join("\n");
121
+ }),
122
+ );
114
123
 
115
124
  return [
116
125
  `tool: ${toolName}`,
117
126
  `path: ${patchFileInput.filePath}`,
118
- `diff:\n${highlightedDiff}`,
127
+ `diff:\n${highlightedDiff.join("\n\n")}`,
119
128
  ].join("\n");
120
129
  }
121
130
 
@@ -349,11 +358,20 @@ export function formatCostForBatch(summary) {
349
358
  /**
350
359
  * Print a message to the console.
351
360
  * @param {Message} message
361
+ * @returns {Promise<void>}
352
362
  */
353
- export function printMessage(message) {
363
+ export async function printMessage(message) {
354
364
  switch (message.role) {
355
365
  case "assistant": {
356
366
  // console.log(styleText("bold", "\nAgent:"));
367
+ // Pre-format all tool_use parts in parallel to avoid sequential awaits
368
+ const toolUseParts = message.content.filter(
369
+ (part) => part.type === "tool_use",
370
+ );
371
+ const formattedToolUses = await Promise.all(
372
+ toolUseParts.map((part) => formatToolUse(part)),
373
+ );
374
+ let toolUseIndex = 0;
357
375
  for (const part of message.content) {
358
376
  switch (part.type) {
359
377
  // Note: Streamで表示するためここでは表示しない
@@ -371,7 +389,7 @@ export function printMessage(message) {
371
389
  // break;
372
390
  case "tool_use":
373
391
  console.log(styleText("bold", "\nTool call:"));
374
- console.log(formatToolUse(part));
392
+ console.log(formattedToolUses[toolUseIndex++]);
375
393
  break;
376
394
  }
377
395
  }
@@ -411,3 +429,90 @@ export function printMessage(message) {
411
429
  }
412
430
  }
413
431
  }
432
+
433
+ /**
434
+ * Generate a colored unified diff using `git diff --color`.
435
+ * Falls back to `null` if git is unavailable or if any step fails
436
+ * (temp directory creation, file writing, git execution, or cleanup).
437
+ * @param {string} oldContent
438
+ * @param {string} newContent
439
+ * @returns {Promise<string | null>}
440
+ */
441
+ async function tryGitDiff(oldContent, newContent) {
442
+ const tmpDir = await noThrow(() =>
443
+ mkdtemp(path.join(os.tmpdir(), "git-diff-")),
444
+ );
445
+ if (tmpDir instanceof Error) {
446
+ console.error(
447
+ styleText("yellow", `git diff: mkdtemp failed: ${tmpDir.message}`),
448
+ );
449
+ return null;
450
+ }
451
+
452
+ const oldPath = path.join(tmpDir, "old");
453
+ const newPath = path.join(tmpDir, "new");
454
+
455
+ try {
456
+ const w1 = await noThrow(() => writeFile(oldPath, oldContent, "utf8"));
457
+ if (w1 instanceof Error) {
458
+ console.error(
459
+ styleText("yellow", `git diff: writeFile(old) failed: ${w1.message}`),
460
+ );
461
+ return null;
462
+ }
463
+
464
+ const w2 = await noThrow(() => writeFile(newPath, newContent, "utf8"));
465
+ if (w2 instanceof Error) {
466
+ console.error(
467
+ styleText("yellow", `git diff: writeFile(new) failed: ${w2.message}`),
468
+ );
469
+ return null;
470
+ }
471
+
472
+ const diffResult = await noThrow(() => execGitDiff(oldPath, newPath));
473
+ if (diffResult instanceof Error) {
474
+ console.error(
475
+ styleText("yellow", `git diff: exec failed: ${diffResult.message}`),
476
+ );
477
+ return null;
478
+ }
479
+
480
+ return diffResult;
481
+ } finally {
482
+ const cleanup = await noThrow(() =>
483
+ rm(tmpDir, { recursive: true, force: true }),
484
+ );
485
+ if (cleanup instanceof Error) {
486
+ console.error(
487
+ styleText("yellow", `git diff: cleanup failed: ${cleanup.message}`),
488
+ );
489
+ }
490
+ }
491
+ }
492
+
493
+ /**
494
+ * Execute git diff accepting exit code 1 as success (differences found).
495
+ * @param {string} oldPath
496
+ * @param {string} newPath
497
+ * @returns {Promise<string>}
498
+ */
499
+ function execGitDiff(oldPath, newPath) {
500
+ return new Promise((resolve, reject) => {
501
+ execFile(
502
+ "git",
503
+ ["--no-pager", "diff", "--color", "--no-index", "--", oldPath, newPath],
504
+ { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 },
505
+ (error, stdout, stderr) => {
506
+ if (stderr) {
507
+ console.error(styleText("yellow", `git diff stderr: ${stderr}`));
508
+ }
509
+ // git diff returns exit code 1 when there are differences, which is expected
510
+ if (error && error.code !== 1) {
511
+ reject(error);
512
+ } else {
513
+ resolve(stdout);
514
+ }
515
+ },
516
+ );
517
+ });
518
+ }
@@ -473,7 +473,11 @@ export function startInteractiveSession({
473
473
  });
474
474
 
475
475
  agentEventEmitter.on("message", (message) => {
476
- printMessage(message);
476
+ printMessage(message).catch((err) => {
477
+ console.error(
478
+ styleText("red", `Error rendering message: ${err.message}`),
479
+ );
480
+ });
477
481
  });
478
482
 
479
483
  agentEventEmitter.on("toolUseRequest", () => {
@@ -1,15 +1,13 @@
1
1
  /** @import { ClaudeCodePlugin } from "../claudeCodePlugin.mjs" */
2
2
 
3
- import crypto from "node:crypto";
4
3
  import fs from "node:fs/promises";
5
4
  import path from "node:path";
6
- import { parse as parseYaml } from "yaml";
7
5
  import {
8
- AGENT_CACHE_DIR,
9
6
  AGENT_PROJECT_METADATA_DIR,
10
7
  AGENT_ROOT,
11
8
  AGENT_USER_CONFIG_DIR,
12
9
  } from "../env.mjs";
10
+ import { parseFrontmatter } from "../utils/parseFrontmatter.mjs";
13
11
 
14
12
  /**
15
13
  * @typedef {Object} AgentRole
@@ -18,7 +16,6 @@ import {
18
16
  * @property {string} content
19
17
  * @property {string} filePath
20
18
  * @property {boolean} claudeOriginated
21
- * @property {string} [import]
22
19
  */
23
20
 
24
21
  /**
@@ -81,15 +78,7 @@ export async function loadAgentRoles(claudeCodePlugins) {
81
78
 
82
79
  if (content === null) return null;
83
80
 
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
- }
81
+ const role = parseAgentRole(file, content, fullPath, idPrefix);
93
82
 
94
83
  return role;
95
84
  }),
@@ -100,99 +89,6 @@ export async function loadAgentRoles(claudeCodePlugins) {
100
89
  return new Map(roles.map((role) => [role.id, role]));
101
90
  }
102
91
 
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
92
  /**
197
93
  * Recursively get all markdown files in a directory.
198
94
  * @param {string} dir
@@ -253,21 +149,7 @@ function parseAgentRole(relativePath, fileContent, fullPath, idPrefix = "") {
253
149
  };
254
150
  }
255
151
 
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
- }
152
+ const frontmatter = parseFrontmatter(match[1]);
271
153
  const content = match[2].trim();
272
154
 
273
155
  return {
@@ -276,19 +158,5 @@ function parseAgentRole(relativePath, fileContent, fullPath, idPrefix = "") {
276
158
  content,
277
159
  filePath: fullPath,
278
160
  claudeOriginated,
279
- import: frontmatter.import,
280
161
  };
281
162
  }
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
- }
@@ -1,16 +1,13 @@
1
1
  /** @import { ClaudeCodePlugin } from "../claudeCodePlugin.mjs" */
2
2
 
3
- import { execFileSync } from "node:child_process";
4
- import crypto from "node:crypto";
5
3
  import fs from "node:fs/promises";
6
4
  import path from "node:path";
7
- import { parse as parseYaml } from "yaml";
8
5
  import {
9
- AGENT_CACHE_DIR,
10
6
  AGENT_PROJECT_METADATA_DIR,
11
7
  AGENT_ROOT,
12
8
  AGENT_USER_CONFIG_DIR,
13
9
  } from "../env.mjs";
10
+ import { parseFrontmatter } from "../utils/parseFrontmatter.mjs";
14
11
 
15
12
  /**
16
13
  * @typedef {Object} Prompt
@@ -19,7 +16,6 @@ import {
19
16
  * @property {string} content
20
17
  * @property {string} filePath
21
18
  * @property {boolean} claudeOriginated
22
- * @property {string} [import]
23
19
  * @property {boolean} [userInvocable]
24
20
  * @property {boolean} [isShortcut]
25
21
  * @property {boolean} [isSkill]
@@ -105,15 +101,7 @@ export async function loadPrompts(claudeCodePlugins) {
105
101
 
106
102
  if (content === null) return null;
107
103
 
108
- let prompt = parsePrompt(file, content, fullPath, idPrefix);
109
- if (prompt.import) {
110
- try {
111
- prompt = await mergeRemotePrompt(prompt, file, fullPath);
112
- } catch (err) {
113
- console.warn(`Failed to import remote prompt ${prompt.id}:`, err);
114
- return null;
115
- }
116
- }
104
+ const prompt = parsePrompt(file, content, fullPath, idPrefix);
117
105
 
118
106
  if (prompt.userInvocable === false) {
119
107
  return null;
@@ -127,98 +115,6 @@ export async function loadPrompts(claudeCodePlugins) {
127
115
  return new Map(prompts.map((prompt) => [prompt.id, prompt]));
128
116
  }
129
117
 
130
- /**
131
- * Merges a remote prompt into a local prompt if an import URL is provided.
132
- * @param {Prompt} localPrompt
133
- * @param {string} relativePath
134
- * @param {string} fullPath
135
- * @returns {Promise<Prompt>}
136
- */
137
- async function mergeRemotePrompt(localPrompt, relativePath, fullPath) {
138
- const importUrl = localPrompt.import;
139
- if (!importUrl) {
140
- return localPrompt;
141
- }
142
-
143
- const fetchedContent = await fetchAndCachePrompt(importUrl).catch((err) => {
144
- console.warn(`Failed to fetch prompt from ${importUrl}:`, err);
145
- return null;
146
- });
147
-
148
- if (!fetchedContent) {
149
- return localPrompt;
150
- }
151
-
152
- const remotePrompt = parsePrompt(relativePath, fetchedContent, fullPath);
153
-
154
- return {
155
- ...remotePrompt,
156
- ...localPrompt, // Local overrides
157
- content: `${remotePrompt.content}\n\n---\n\n${localPrompt.content}`.trim(),
158
- description: localPrompt.description || remotePrompt.description || "",
159
- };
160
- }
161
-
162
- /**
163
- * Fetch a prompt from a URL and cache it.
164
- * @param {string} url
165
- * @returns {Promise<string>}
166
- */
167
- async function fetchAndCachePrompt(url) {
168
- const hash = crypto.createHash("sha256").update(url).digest("hex");
169
- const cacheDir = path.join(AGENT_CACHE_DIR, "prompts");
170
- const cachePath = path.join(cacheDir, hash);
171
-
172
- const cachedContent = await fs.readFile(cachePath, "utf-8").catch(() => null);
173
- if (cachedContent !== null) {
174
- return cachedContent;
175
- }
176
-
177
- const fetchedContent = await fetchContent(url);
178
-
179
- // Attempt to cache, but don't block or fail on errors
180
- fs.mkdir(cacheDir, { recursive: true })
181
- .then(() => fs.writeFile(cachePath, fetchedContent, "utf-8"))
182
- .catch((err) => {
183
- console.warn(`Failed to write cache for ${url}:`, err);
184
- });
185
-
186
- return fetchedContent;
187
- }
188
-
189
- /**
190
- * Fetch content from a URL.
191
- * @param {string} url
192
- * @returns {Promise<string>}
193
- */
194
- async function fetchContent(url) {
195
- const githubMatch = url.match(
196
- /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/blob\/([^/]+)\/(.+)$/,
197
- );
198
-
199
- if (githubMatch) {
200
- const [, owner, repo, ref, path] = githubMatch;
201
- const apiUrl = `repos/${owner}/${repo}/contents/${path}?ref=${ref}`;
202
- try {
203
- return execFileSync(
204
- "gh",
205
- ["api", "-H", "Accept: application/vnd.github.v3.raw", apiUrl],
206
- { encoding: "utf-8" },
207
- );
208
- } catch (err) {
209
- throw new Error(`Failed to fetch from GitHub via gh CLI: ${err}`);
210
- }
211
- }
212
-
213
- const response = await fetch(url);
214
- if (!response.ok) {
215
- throw new Error(
216
- `Failed to fetch prompt from ${url}: ${response.status} ${response.statusText}`,
217
- );
218
- }
219
- return response.text();
220
- }
221
-
222
118
  /**
223
119
  * Recursively get all markdown files in a directory.
224
120
  * @param {string} dir
@@ -287,29 +183,7 @@ function parsePrompt(relativePath, fileContent, fullPath, idPrefix = "") {
287
183
 
288
184
  const content = match[2].trim();
289
185
 
290
- /** @type {{description?:string; import?:string; "user-invocable"?:boolean}} */
291
- let frontmatter;
292
- try {
293
- frontmatter =
294
- /** @type {{description?:string; import?:string; "user-invocable"?:boolean}} */ (
295
- parseYaml(match[1])
296
- );
297
- } catch (_err) {
298
- return {
299
- id,
300
- description: parseFrontmatterField(match[1], "description") ?? "",
301
- content,
302
- filePath: fullPath,
303
- claudeOriginated,
304
- import: parseFrontmatterField(match[1], "import"),
305
- userInvocable:
306
- parseFrontmatterField(match[1], "user-invocable") === "true" ||
307
- undefined,
308
- isShortcut,
309
- isSkill,
310
- };
311
- }
312
- const userInvocable = frontmatter["user-invocable"];
186
+ const frontmatter = parseFrontmatter(match[1]);
313
187
 
314
188
  return {
315
189
  id,
@@ -317,21 +191,8 @@ function parsePrompt(relativePath, fileContent, fullPath, idPrefix = "") {
317
191
  content,
318
192
  filePath: fullPath,
319
193
  claudeOriginated,
320
- import: frontmatter.import,
321
- userInvocable: userInvocable ?? undefined,
194
+ userInvocable: frontmatter["user-invocable"] === "true" ? true : undefined,
322
195
  isShortcut,
323
196
  isSkill: relativePath.endsWith("SKILL.md"),
324
197
  };
325
198
  }
326
-
327
- /**
328
- * Parse a field from YAML frontmatter.
329
- * @param {string} frontmatter
330
- * @param {string} field
331
- * @returns {string | undefined}
332
- */
333
- function parseFrontmatterField(frontmatter, field) {
334
- const regex = new RegExp(`^${field}:\\s*(.*)$`, "m");
335
- const match = frontmatter.match(regex);
336
- return match ? match[1].trim() : undefined;
337
- }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Parse simple key-value frontmatter using regex.
3
+ * Only supports `key: value` format. No multiline strings.
4
+ * @param {string} frontmatter - The YAML frontmatter content (without --- delimiters)
5
+ * @returns {Record<string, string>} Parsed key-value pairs
6
+ */
7
+ export function parseFrontmatter(frontmatter) {
8
+ /** @type {Record<string, string>} */
9
+ const result = {};
10
+
11
+ for (const line of frontmatter.split(/\r?\n/)) {
12
+ const match = line.match(/^(\w[\w-]*):\s?(.*)$/);
13
+ if (match) {
14
+ result[match[1]] = match[2].trimEnd();
15
+ }
16
+ }
17
+
18
+ return result;
19
+ }