@iinm/plain-agent 1.8.5 → 1.8.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iinm/plain-agent",
3
- "version": "1.8.5",
3
+ "version": "1.8.6",
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", () => {
@@ -3,13 +3,13 @@
3
3
  import crypto from "node:crypto";
4
4
  import fs from "node:fs/promises";
5
5
  import path from "node:path";
6
- import { parse as parseYaml } from "yaml";
7
6
  import {
8
7
  AGENT_CACHE_DIR,
9
8
  AGENT_PROJECT_METADATA_DIR,
10
9
  AGENT_ROOT,
11
10
  AGENT_USER_CONFIG_DIR,
12
11
  } from "../env.mjs";
12
+ import { parseFrontmatter } from "../utils/parseFrontmatter.mjs";
13
13
 
14
14
  /**
15
15
  * @typedef {Object} AgentRole
@@ -253,21 +253,7 @@ function parseAgentRole(relativePath, fileContent, fullPath, idPrefix = "") {
253
253
  };
254
254
  }
255
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
- }
256
+ const frontmatter = parseFrontmatter(match[1]);
271
257
  const content = match[2].trim();
272
258
 
273
259
  return {
@@ -279,16 +265,3 @@ function parseAgentRole(relativePath, fileContent, fullPath, idPrefix = "") {
279
265
  import: frontmatter.import,
280
266
  };
281
267
  }
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
- }
@@ -4,13 +4,13 @@ import { execFileSync } from "node:child_process";
4
4
  import crypto from "node:crypto";
5
5
  import fs from "node:fs/promises";
6
6
  import path from "node:path";
7
- import { parse as parseYaml } from "yaml";
8
7
  import {
9
8
  AGENT_CACHE_DIR,
10
9
  AGENT_PROJECT_METADATA_DIR,
11
10
  AGENT_ROOT,
12
11
  AGENT_USER_CONFIG_DIR,
13
12
  } from "../env.mjs";
13
+ import { parseFrontmatter } from "../utils/parseFrontmatter.mjs";
14
14
 
15
15
  /**
16
16
  * @typedef {Object} Prompt
@@ -287,29 +287,7 @@ function parsePrompt(relativePath, fileContent, fullPath, idPrefix = "") {
287
287
 
288
288
  const content = match[2].trim();
289
289
 
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"];
290
+ const frontmatter = parseFrontmatter(match[1]);
313
291
 
314
292
  return {
315
293
  id,
@@ -318,20 +296,8 @@ function parsePrompt(relativePath, fileContent, fullPath, idPrefix = "") {
318
296
  filePath: fullPath,
319
297
  claudeOriginated,
320
298
  import: frontmatter.import,
321
- userInvocable: userInvocable ?? undefined,
299
+ userInvocable: frontmatter["user-invocable"] === "true" ? true : undefined,
322
300
  isShortcut,
323
301
  isSkill: relativePath.endsWith("SKILL.md"),
324
302
  };
325
303
  }
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
+ }