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:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user