feat(coding-agent): enabled archive entry writes with automatic creation support

- Added support for writing to archive entries using `archive.ext:path/inside/archive` syntax for .tar, .tar.gz, .tgz, and .zip formats.
- Added ability to create new archives automatically when writing to non-existent archive subpaths with nested directory support.
- Implemented archive path resolution and validation with fallback to plain file writes for non-archive filenames.
- Added comprehensive test coverage for archive write operations including entry updates, archive creation, and fallback behavior.
This commit is contained in:
can1357
2026-04-01 21:25:36 +02:00
parent 513982b16e
commit 9498032d68
4 changed files with 210 additions and 4 deletions
+4
View File
@@ -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
@@ -3,6 +3,7 @@ Creates or overwrites file at specified path.
<conditions>
- 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`
</conditions>
<critical>
+147 -4
View File
@@ -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<typeof writeSchema, WriteToolDetails
this.description = renderPromptTemplate(writeDescription);
}
async #resolveArchiveWritePath(writePath: string): Promise<ResolvedArchiveWritePath | null> {
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<AgentToolResult<WriteToolDetails>> {
const archiveEntries: Record<string, string | File> = {};
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<string, File>;
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<typeof writeSchema, WriteToolDetails
context?: AgentToolContext,
): Promise<AgentToolResult<WriteToolDetails>> {
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<typeof writeSchema, WriteToolDetails
await checkAutoGeneratedFile(absolutePath, path);
}
// Strip hashline display prefixes (LINE#ID:) if the model copied them from read output
const { text: cleanContent, stripped } = stripWriteContent(this.session, content);
const diagnostics = await this.#writethrough(absolutePath, cleanContent, signal, undefined, batchRequest);
invalidateFsScanAfterWrite(absolutePath);
+58
View File
@@ -582,6 +582,64 @@ describe("Coding Agent Tools", () => {
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", () => {