diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index aad20b164..aac7867fd 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,10 @@ # Changelog ## [Unreleased] +### Added + +- Support for writing to archive entries in `.tar`, `.tar.gz`, `.tgz`, and `.zip` files using `archive.ext:path/inside/archive` syntax +- Ability to create new archives when writing to archive subpaths that don't yet exist ## [13.17.3] - 2026-04-01 diff --git a/packages/coding-agent/src/prompts/tools/write.md b/packages/coding-agent/src/prompts/tools/write.md index b9afcb598..d592e5f2d 100644 --- a/packages/coding-agent/src/prompts/tools/write.md +++ b/packages/coding-agent/src/prompts/tools/write.md @@ -3,6 +3,7 @@ Creates or overwrites file at specified path. - Creating new files explicitly required by task - Replacing entire file contents when editing would be more complex +- Supports `.tar`, `.tar.gz`, `.tgz`, and `.zip` archive entries via `archive.ext:path/inside/archive` diff --git a/packages/coding-agent/src/tools/write.ts b/packages/coding-agent/src/tools/write.ts index 223119bd7..84368b115 100644 --- a/packages/coding-agent/src/tools/write.ts +++ b/packages/coding-agent/src/tools/write.ts @@ -1,4 +1,5 @@ import * as fs from "node:fs/promises"; +import * as path from "node:path"; import type { AgentTool, AgentToolContext, @@ -8,7 +9,7 @@ import type { } from "@oh-my-pi/pi-agent-core"; import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; -import { untilAborted } from "@oh-my-pi/pi-utils"; +import { isEnoent, untilAborted } from "@oh-my-pi/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; import { renderPromptTemplate } from "../config/prompt-templates"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; @@ -19,6 +20,7 @@ import writeDescription from "../prompts/tools/write.md" with { type: "text" }; import type { ToolSession } from "../sdk"; import { Ellipsis, Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../tui"; import { resolveFileDisplayMode } from "../utils/file-display-mode"; +import { parseArchivePathCandidates } from "./archive-reader"; import { checkAutoGeneratedFile } from "./auto-generated-guard"; import { invalidateFsScanAfterWrite } from "./fs-cache-invalidation"; import { type OutputMeta, outputMeta } from "./output-meta"; @@ -32,6 +34,7 @@ import { replaceTabs, shortenPath, } from "./render-utils"; +import { ToolError } from "./tool-errors"; const writeSchema = Type.Object({ path: Type.String({ description: "Path to the file to write (relative or absolute)" }), @@ -84,6 +87,44 @@ function stripWriteContent(session: ToolSession, content: string): { text: strin type WriteParams = WriteToolInput; +interface ResolvedArchiveWritePath { + absolutePath: string; + archivePath: string; + archiveSubPath: string; + exists: boolean; +} + +function isArchivePathNotFound(error: unknown): boolean { + if (isEnoent(error)) return true; + return typeof error === "object" && error !== null && "code" in error && error.code === "ENOTDIR"; +} + +function normalizeArchiveWriteSubPath(rawPath: string): string { + const normalized = rawPath.replace(/\\/g, "/"); + if (normalized.length === 0) { + throw new ToolError("Archive write path must target a file inside the archive"); + } + if (normalized.endsWith("/")) { + throw new ToolError("Archive write path must target a file, not a directory"); + } + + const parts = normalized.split("/"); + const normalizedParts: string[] = []; + for (const part of parts) { + if (!part || part === ".") continue; + if (part === "..") { + throw new ToolError("Archive path cannot contain '..'"); + } + normalizedParts.push(part); + } + + if (normalizedParts.length === 0) { + throw new ToolError("Archive write path must target a file inside the archive"); + } + + return normalizedParts.join("/"); +} + /** * Write tool implementation. * @@ -110,6 +151,90 @@ export class WriteTool implements AgentTool { + const candidates = parseArchivePathCandidates(writePath).filter(candidate => candidate.archivePath !== writePath); + if (candidates.length === 0) { + return null; + } + + const fallbackCandidate = candidates[candidates.length - 1]!; + const fallback: ResolvedArchiveWritePath = { + absolutePath: resolvePlanPath(this.session, fallbackCandidate.archivePath), + archivePath: fallbackCandidate.archivePath, + archiveSubPath: normalizeArchiveWriteSubPath(fallbackCandidate.subPath), + exists: false, + }; + + for (const candidate of candidates) { + const absolutePath = resolvePlanPath(this.session, candidate.archivePath); + try { + const stat = await Bun.file(absolutePath).stat(); + if (stat.isDirectory()) { + continue; + } + + return { + absolutePath, + archivePath: candidate.archivePath, + archiveSubPath: normalizeArchiveWriteSubPath(candidate.subPath), + exists: true, + }; + } catch (error) { + if (!isArchivePathNotFound(error)) { + throw error; + } + } + } + + return fallback; + } + + async #writeArchiveEntry( + displayPath: string, + content: string, + resolvedArchivePath: ResolvedArchiveWritePath, + ): Promise> { + const archiveEntries: Record = {}; + if (resolvedArchivePath.exists) { + let archive: Bun.Archive; + try { + archive = new Bun.Archive(await Bun.file(resolvedArchivePath.absolutePath).bytes()); + } catch (error) { + throw new ToolError(error instanceof Error ? error.message : String(error)); + } + + let files: Map; + try { + files = await archive.files(); + } catch (error) { + throw new ToolError(error instanceof Error ? error.message : String(error)); + } + + for (const [entryPath, file] of files) { + archiveEntries[entryPath.replace(/\\/g, "/")] = file; + } + } + + archiveEntries[resolvedArchivePath.archiveSubPath] = content; + + const parentDir = path.dirname(resolvedArchivePath.absolutePath); + if (parentDir && parentDir !== ".") { + await fs.mkdir(parentDir, { recursive: true }); + } + + try { + await Bun.Archive.write(resolvedArchivePath.absolutePath, archiveEntries); + } catch (error) { + throw new ToolError(error instanceof Error ? error.message : String(error)); + } + + invalidateFsScanAfterWrite(resolvedArchivePath.absolutePath); + return { + content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${displayPath}` }], + details: {}, + }; + } + async execute( _toolCallId: string, { path, content }: WriteParams, @@ -118,6 +243,27 @@ export class WriteTool implements AgentTool> { return untilAborted(signal, async () => { + // Strip hashline display prefixes (LINE#ID:) if the model copied them from read output + const { text: cleanContent, stripped } = stripWriteContent(this.session, content); + const resolvedArchivePath = await this.#resolveArchiveWritePath(path); + if (resolvedArchivePath) { + enforcePlanModeWrite(this.session, resolvedArchivePath.archivePath, { + op: resolvedArchivePath.exists ? "update" : "create", + }); + + const archiveResult = await this.#writeArchiveEntry(path, cleanContent, resolvedArchivePath); + if (stripped) { + const firstText = archiveResult.content.find( + (block): block is { type: "text"; text: string } => + block.type === "text" && typeof block.text === "string", + ); + if (firstText) { + firstText.text += `\nNote: auto-stripped hashline display prefixes from content before writing.`; + } + } + return archiveResult; + } + enforcePlanModeWrite(this.session, path, { op: "create" }); const absolutePath = resolvePlanPath(this.session, path); const batchRequest = getLspBatchRequest(context?.toolCall); @@ -127,9 +273,6 @@ export class WriteTool implements AgentTool { expect(fs.existsSync(expectedPath)).toBe(true); expect(fs.readFileSync(expectedPath, "utf-8")).toBe(content); }); + + it("should write to an existing archive entry", async () => { + const archivePath = path.join(testDir, "write-existing.zip"); + fs.writeFileSync( + archivePath, + createZipArchive([ + { path: "pkg/README.md", content: "# Original\n" }, + { path: "pkg/src/index.ts", content: "export const archiveValue = 1;\n" }, + ]), + ); + + const content = "# Updated\nLine 2\n"; + const result = await writeTool.execute("test-call-archive-write-existing", { + path: `${archivePath}:pkg/README.md`, + content, + }); + + expect(getTextOutput(result)).toContain( + `Successfully wrote ${content.length} bytes to ${archivePath}:pkg/README.md`, + ); + + const archive = new Bun.Archive(await Bun.file(archivePath).bytes()); + const files = await archive.files(); + expect(await files.get("pkg/README.md")?.text()).toBe(content); + expect(await files.get("pkg/src/index.ts")?.text()).toBe("export const archiveValue = 1;\n"); + }); + + it("should create a new archive when writing to an archive subpath", async () => { + const archivePath = path.join(testDir, "nested", "created.tar.gz"); + const content = "created inside archive\n"; + + const result = await writeTool.execute("test-call-archive-write-create", { + path: `${archivePath}:pkg/new.txt`, + content, + }); + + expect(getTextOutput(result)).toContain( + `Successfully wrote ${content.length} bytes to ${archivePath}:pkg/new.txt`, + ); + expect(fs.existsSync(archivePath)).toBe(true); + + const archive = new Bun.Archive(await Bun.file(archivePath).bytes()); + const files = await archive.files(); + expect(await files.get("pkg/new.txt")?.text()).toBe(content); + }); + + it("should treat a plain archive filename as a regular file write", async () => { + const archivePath = path.join(testDir, "literal.zip"); + const content = "plain file contents\n"; + + const result = await writeTool.execute("test-call-archive-plain-file", { + path: archivePath, + content, + }); + + expect(getTextOutput(result)).toContain(`Successfully wrote ${content.length} bytes to ${archivePath}`); + expect(fs.readFileSync(archivePath, "utf-8")).toBe(content); + }); }); describe("edit tool", () => {