@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 +24 -53
- package/package.json +2 -5
- package/src/cliFormatter.mjs +123 -18
- package/src/cliInteractive.mjs +5 -1
- package/src/context/loadAgentRoles.mjs +3 -135
- package/src/context/loadPrompts.mjs +4 -143
- package/src/utils/parseFrontmatter.mjs +19 -0
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": "<
|
|
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
|
-
|
|
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": "
|
|
415
|
+
"toolName": { "$regex": "mcp__slack__slack_(read|search)_.+" },
|
|
421
416
|
"action": "allow"
|
|
422
417
|
}
|
|
423
418
|
]
|
|
424
419
|
},
|
|
425
420
|
|
|
426
|
-
//
|
|
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
|
|
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
|
|
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
|
|
486
|
-
|
|
479
|
+
// Override default notification command
|
|
480
|
+
"notifyCmd": { "command": "plain-notify-desktop", "args": [] }
|
|
487
481
|
|
|
488
|
-
//
|
|
489
|
-
//
|
|
490
|
-
|
|
491
|
-
|
|
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.
|
|
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",
|
package/src/cliFormatter.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
* @
|
|
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 =
|
|
103
|
-
.map(
|
|
104
|
-
(
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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(
|
|
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
|
+
}
|
package/src/cliInteractive.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|