feat(coding-agent/tools): enabled shebang files to be auto-marked executable

- Added a `madeExecutable` result field to `WriteToolDetails` to surface executable changes.
- Implemented `maybeMarkExecutableForShebang` to chmod shebang files executable while preserving existing mode bits and swallowing chmod errors.
- Updated write flow and renderer output to return and display when a file was auto-marked executable.
This commit is contained in:
can1357
2026-05-28 00:34:11 +02:00
parent 7fa55750f9
commit 9474e95cb5
2 changed files with 126 additions and 2 deletions
+31 -2
View File
@@ -70,6 +70,8 @@ export type WriteToolInput = z.infer<typeof writeSchema>;
export interface WriteToolDetails {
diagnostics?: FileDiagnosticsResult;
meta?: OutputMeta;
/** Set when the file was auto-chmod'd because content begins with a `#!` shebang. */
madeExecutable?: boolean;
}
/**
@@ -103,6 +105,28 @@ function appendNoteToResult(result: AgentToolResult<WriteToolDetails>, note: str
}
}
/**
* If `content` begins with a `#!` shebang, ensure the file is executable.
*
* Mirrors `chmod a+x` (adds user/group/other execute bits to existing mode).
* Errors are swallowed: chmod failure (e.g. Windows ACL, read-only mount)
* MUST NOT fail an otherwise successful write. Returns whether the mode
* actually changed so the caller can surface a note.
*/
async function maybeMarkExecutableForShebang(absolutePath: string, content: string): Promise<boolean> {
if (!content.startsWith("#!")) return false;
try {
const stat = await fs.stat(absolutePath);
const currentMode = stat.mode & 0o7777;
const newMode = currentMode | 0o111;
if (newMode === currentMode) return false;
await fs.chmod(absolutePath, newMode);
return true;
} catch {
return false;
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Tool Class
// ═══════════════════════════════════════════════════════════════════════════
@@ -772,6 +796,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
const diagnostics = await this.#writethrough(absolutePath, cleanContent, signal, undefined, batchRequest);
invalidateFsScanAfterWrite(absolutePath);
const madeExecutable = await maybeMarkExecutableForShebang(absolutePath, cleanContent);
const displayPath = formatPathRelativeToCwd(absolutePath, this.session.cwd);
let resultText = `Successfully wrote ${cleanContent.length} bytes to ${displayPath}`;
@@ -781,7 +806,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
if (!diagnostics) {
return {
content: [{ type: "text", text: resultText }],
details: {},
details: { madeExecutable: madeExecutable || undefined },
};
}
@@ -789,6 +814,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
content: [{ type: "text", text: resultText }],
details: {
diagnostics,
madeExecutable: madeExecutable || undefined,
meta: outputMeta()
.diagnostics(diagnostics.summary, diagnostics.messages ?? [])
.get(),
@@ -915,13 +941,16 @@ export const writeToolRenderer = {
const pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", "…");
const lineCount = countLines(fileContent);
const lineSuffix = formatLineCountSuffix(lineCount, uiTheme);
const execSuffix = result.details?.madeExecutable
? `${uiTheme.fg("dim", " · ")}${uiTheme.fg("success", "made executable!")}`
: "";
// Build header with status icon
const header = renderStatusLine(
{
icon: "success",
title: "Write",
description: `${langIcon} ${pathDisplay}${lineSuffix}`,
description: `${langIcon} ${pathDisplay}${lineSuffix}${execSuffix}`,
},
uiTheme,
);