Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/homebrew-tap.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@ jobs:
if: startsWith(inputs.tag_name, 'v')

steps:
- name: Validate release tag
env:
TAG: ${{ inputs.tag_name }}
run: |
set -euo pipefail
if [[ ! "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z]+)*$ ]]; then
echo "Invalid release tag: ${TAG}"
exit 1
fi

- name: Validate tap configuration
run: |
set -euo pipefail
Expand Down
19 changes: 14 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,8 @@ jobs:
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 0

- name: Resolve release tag
id: release
env:
INPUT_TAG: ${{ inputs.tag_name }}
run: |
Expand All @@ -43,6 +39,19 @@ jobs:
fi
echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV"
echo "RELEASE_VERSION=${TAG#v}" >> "$GITHUB_ENV"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"

- name: Checkout release tag
uses: actions/checkout@v5
with:
fetch-depth: 0
ref: refs/tags/${{ steps.release.outputs.tag }}

- name: Verify checked out release tag
run: |
set -euo pipefail
git rev-parse --verify "refs/tags/${RELEASE_TAG}^{commit}" >/dev/null
test "$(git rev-parse HEAD)" = "$(git rev-parse "refs/tags/${RELEASE_TAG}^{commit}")"

- name: Setup Node
uses: actions/setup-node@v6
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

## Unreleased

### Fixes

- Harden ownership detection, restoration, and malformed marker handling for beta host integrations.
- Build release artifacts from explicit tags and validate Homebrew tap release-tag input.
- Make `pnpm build` portable across supported Node platforms.

## 0.8.0 - 2026-05-25

### Features
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
"generate:builtin-rules": "node scripts/generate-builtin-rules.mjs",
"prepare": "pnpm repair:pnpm-bin-shims",
"repair:pnpm-bin-shims": "node scripts/repair-pnpm-bin-shims.mjs",
"build": "rm -rf dist && pnpm generate:builtin-rules && tsc -p tsconfig.json && node scripts/build-pi-runtime.mjs && node scripts/build-opencode-runtime.mjs && mkdir -p dist/rules && cp -R src/rules/. dist/rules/",
"build": "node scripts/clean-dist.mjs && pnpm generate:builtin-rules && tsc -p tsconfig.json && node scripts/build-pi-runtime.mjs && node scripts/build-opencode-runtime.mjs && node scripts/copy-rules.mjs",
"lint": "oxlint --deny-warnings src test scripts",
"lint:circular": "madge --circular --extensions ts,mjs --ts-config tsconfig.json src",
"release:artifacts": "node scripts/build-release.mjs",
Expand Down
3 changes: 3 additions & 0 deletions scripts/clean-dist.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { rm } from "node:fs/promises";

await rm("dist", { recursive: true, force: true });
4 changes: 4 additions & 0 deletions scripts/copy-rules.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { cp, mkdir } from "node:fs/promises";

await mkdir("dist/rules", { recursive: true });
await cp("src/rules", "dist/rules", { recursive: true });
3 changes: 1 addition & 2 deletions src/hosts/amazon-q/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mkdir, rename, rm, stat, writeFile } from "node:fs/promises";
import { mkdir, rename, stat, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";

import {
Expand Down Expand Up @@ -151,7 +151,6 @@ export async function uninstallAmazonQRule(
const backupPath = `${resolvedRulePath}.bak`;
const backup = await readInstructionFile(backupPath);
if (existing.exists && backup.exists && !isTokenjuiceAmazonQRuleText(backup.text)) {
await rm(resolvedRulePath, { force: true });
await rename(backupPath, resolvedRulePath);
return { rulePath: resolvedRulePath, removed: true };
}
Expand Down
3 changes: 3 additions & 0 deletions src/hosts/copilot-agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,9 @@ export async function installCopilotAgentHook(
removeTokenjuiceCopilotAgentHooks(config);
const retained = getPostToolUseHooks(config);
config.hooks.postToolUse = [...retained, createTokenjuiceCopilotAgentHook(command)];
if (config.disableAllHooks === true) {
config.disableAllHooks = false;
}
if (typeof config.version !== "number") {
config.version = 1;
}
Expand Down
24 changes: 16 additions & 8 deletions src/hosts/copilot-cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ type CopilotCliPostToolUsePayload = {
toolArgs?: unknown;
tool_result?: unknown;
toolResult?: unknown;
result_type?: unknown;
resultType?: unknown;
text_result_for_llm?: unknown;
textResultForLlm?: unknown;
};

const TOKENJUICE_COPILOT_CLI_FIX_COMMAND = "tokenjuice install copilot-cli";
Expand Down Expand Up @@ -492,14 +496,14 @@ export async function runCopilotCliPostToolUseHook(rawText: string): Promise<num
: isRecord(payload.tool_result)
? payload.tool_result
: undefined;
if (!toolResult) {
process.stdout.write("{}\n");
return 0;
}

const resultType = typeof toolResult.resultType === "string"
const resultType = typeof payload.resultType === "string"
? payload.resultType
: typeof payload.result_type === "string"
? payload.result_type
: typeof toolResult?.resultType === "string"
? toolResult.resultType
: typeof toolResult.result_type === "string"
: typeof toolResult?.result_type === "string"
? toolResult.result_type
: undefined;
// Only rewrite success output; failure/rejected/denied payloads pass
Expand All @@ -509,9 +513,13 @@ export async function runCopilotCliPostToolUseHook(rawText: string): Promise<num
return 0;
}

const combinedText = typeof toolResult.textResultForLlm === "string"
const combinedText = typeof payload.textResultForLlm === "string"
? payload.textResultForLlm
: typeof payload.text_result_for_llm === "string"
? payload.text_result_for_llm
: typeof toolResult?.textResultForLlm === "string"
? toolResult.textResultForLlm
: typeof toolResult.text_result_for_llm === "string"
: typeof toolResult?.text_result_for_llm === "string"
? toolResult.text_result_for_llm
: "";
if (!combinedText.trim()) {
Expand Down
4 changes: 1 addition & 3 deletions src/hosts/crush/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,7 @@ async function writeCrushSkillWithoutBackup(skillPath: string): Promise<void> {
}

function isTokenjuiceCrushSkillText(text: string): boolean {
return text.includes(TOKENJUICE_CRUSH_MARKER)
|| text.includes(TOKENJUICE_WRAP_COMMAND)
|| text.includes(TOKENJUICE_RAW_COMMAND);
return text.includes(TOKENJUICE_CRUSH_MARKER);
}

export async function installCrushSkill(
Expand Down
15 changes: 11 additions & 4 deletions src/hosts/gemini-cli/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { homedir } from "node:os";

Expand Down Expand Up @@ -99,13 +100,19 @@ async function loadGeminiSettingsWithBackup(settingsPath: string): Promise<{ con

async function writeGeminiSettings(settingsPath: string, config: GeminiSettings): Promise<void> {
await mkdir(dirname(settingsPath), { recursive: true });
const tempPath = `${settingsPath}.tmp`;
await writeFile(tempPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
await rename(tempPath, settingsPath);
const tempPath = `${settingsPath}.${process.pid}.${randomUUID()}.tmp`;
try {
await writeFile(tempPath, `${JSON.stringify(config, null, 2)}\n`, { encoding: "utf8", flag: "wx" });
await rename(tempPath, settingsPath);
} finally {
await rm(tempPath, { force: true });
}
}

function isTokenjuiceGeminiHook(entry: unknown): boolean {
return isRecord(entry)
&& entry.type === "command"
&& entry.name === "tokenjuice"
&& typeof entry.command === "string"
&& entry.command.includes(TOKENJUICE_GEMINI_CLI_SUBCOMMAND);
}
Expand Down
28 changes: 23 additions & 5 deletions src/hosts/kimi/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,12 +95,30 @@ async function readConfig(configPath: string): Promise<{ text: string; exists: b
}
}

function countMarker(text: string, marker: string): number {
return text.split(marker).length - 1;
}

function hasMalformedMarkerStructure(text: string): boolean {
return countMarker(text, TOKENJUICE_KIMI_BEGIN) !== countMarker(text, TOKENJUICE_KIMI_END);
let offset = 0;
let hasOpenBlock = false;
while (offset < text.length) {
const beginIndex = text.indexOf(TOKENJUICE_KIMI_BEGIN, offset);
const endIndex = text.indexOf(TOKENJUICE_KIMI_END, offset);
if (beginIndex === -1 && endIndex === -1) {
break;
}
if (beginIndex !== -1 && (endIndex === -1 || beginIndex < endIndex)) {
if (hasOpenBlock) {
return true;
}
hasOpenBlock = true;
offset = beginIndex + TOKENJUICE_KIMI_BEGIN.length;
} else {
if (!hasOpenBlock) {
return true;
}
hasOpenBlock = false;
offset = endIndex + TOKENJUICE_KIMI_END.length;
}
}
return hasOpenBlock;
}

function removeKimiHookBlock(text: string): { text: string; removed: number } {
Expand Down
16 changes: 13 additions & 3 deletions src/hosts/mcp-agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,21 @@ export type McpAgentDoctorReport = {
};

const TOKENJUICE_MCP_AGENT_FIX_COMMAND = "tokenjuice install mcp-agent";
const TOKENJUICE_MCP_AGENT_OWNERSHIP_MARKER = "<!-- tokenjuice:mcp-agent -->";
const TOKENJUICE_MCP_AGENT_MARKER = "tokenjuice mcp-agent terminal output compaction";
const TOKENJUICE_MCP_AGENT_RESTORE_BACKUP_MARKER_PREFIX = "<!-- tokenjuice:mcp-agent-restore-backup=";
const TOKENJUICE_MCP_AGENT_LOAD_GUIDANCE = "enable agents.search_paths with .mcp-agent/agents in mcp_agent.config.yaml";
const TOKENJUICE_MCP_AGENT_ADVISORY =
"mcp-agent support is beta and agent-file based; enable `.mcp-agent/agents` in `agents.search_paths` so mcp-agent can load it.";

function isTokenjuiceMcpAgentDefinitionText(text: string): boolean {
return text.includes(TOKENJUICE_MCP_AGENT_MARKER);
return text.includes(TOKENJUICE_MCP_AGENT_OWNERSHIP_MARKER)
|| isLegacyTokenjuiceMcpAgentDefinitionText(text);
}

function isLegacyTokenjuiceMcpAgentDefinitionText(text: string): boolean {
const restoreBackupSuffix = readRestoreBackupSuffix(text);
return text === buildMcpAgentDefinition({ restoreBackupSuffix, includeOwnershipMarker: false });
}

function readRestoreBackupSuffix(text: string): string | undefined {
Expand Down Expand Up @@ -199,14 +206,18 @@ async function resolveAgentPath(agentPath?: string, options: McpAgentDefinitionO
}

function buildMcpAgentDefinition(
{ restoreBackupSuffix }: { restoreBackupSuffix?: string | undefined } = {},
{
restoreBackupSuffix,
includeOwnershipMarker = true,
}: { restoreBackupSuffix?: string | undefined; includeOwnershipMarker?: boolean } = {},
): string {
return [
"---",
"name: tokenjuice",
"description: Use when mcp-agent workflows run terminal commands likely to produce long output or need compacted shell evidence.",
"---",
"",
...(includeOwnershipMarker ? [TOKENJUICE_MCP_AGENT_OWNERSHIP_MARKER, ""] : []),
...(restoreBackupSuffix
? [`${TOKENJUICE_MCP_AGENT_RESTORE_BACKUP_MARKER_PREFIX}${restoreBackupSuffix} -->`, ""]
: []),
Expand Down Expand Up @@ -285,7 +296,6 @@ export async function uninstallMcpAgentDefinition(
await rejectDefinitionSymlink(backupPath);
const backup = await readInstructionFile(backupPath);
if (backup.exists && !isTokenjuiceMcpAgentDefinitionText(backup.text)) {
await rm(resolvedAgentPath, { force: true });
await rename(backupPath, resolvedAgentPath);
return { agentPath: resolvedAgentPath, removed: true };
}
Expand Down
16 changes: 16 additions & 0 deletions test/hosts/copilot-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,22 @@ describe("installCopilotAgentHook", () => {
expect(parsed.hooks.postToolUse.find((entry) => entry.bash.includes("copilot-agent-post-tool-use"))).toBeTruthy();
});

it("enables hooks when reinstalling into a disabled configuration", async () => {
const projectDir = await createTempDir();
const hooksPath = join(projectDir, ".github", "hooks", "tokenjuice-agent.json");
await mkdir(join(projectDir, ".github", "hooks"), { recursive: true });
await writeFile(
hooksPath,
`${JSON.stringify({ version: 1, disableAllHooks: true, hooks: {} }, null, 2)}\n`,
"utf8",
);

await installCopilotAgentHook(undefined, { projectDir });
const config = JSON.parse(await readFile(hooksPath, "utf8")) as { disableAllHooks?: boolean };

expect(config.disableAllHooks).toBe(false);
});

it("removes stale tokenjuice entries from sibling repo hook files", async () => {
const projectDir = await createTempDir();
const hooksDir = join(projectDir, ".github", "hooks");
Expand Down
7 changes: 3 additions & 4 deletions test/hosts/copilot-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -504,10 +504,9 @@ describe("runCopilotCliPostToolUseHook", () => {
cwd: "/tmp",
toolName: "bash",
toolArgs: { command: "git ls-files" },
toolResult: {
resultType: "success",
textResultForLlm: longOutput,
},
toolResult: "raw tool result",
resultType: "success",
textResultForLlm: longOutput,
});

const { code, output } = await captureStdout(() => runCopilotCliPostToolUseHook(payload));
Expand Down
23 changes: 18 additions & 5 deletions test/hosts/crush.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,18 +105,19 @@ describe("crush skill", () => {
await expect(readFile(skillPath, "utf8")).resolves.toContain("tokenjuice wrap --raw -- <command>");
});

it("does not restore tokenjuice guidance with an edited marker on uninstall", async () => {
it("restores a markerless existing skill that mentions tokenjuice commands", async () => {
const home = await createTempDir();
const skillPath = join(home, ".crush", "skills", "tokenjuice", "SKILL.md");
await installCrushSkill(undefined, { projectDir: home });
await writeFile(skillPath, "---\nname: tokenjuice\n---\n\nuse `tokenjuice wrap -- <command>`\n", "utf8");
const customSkill = "---\nname: tokenjuice\n---\n\nuse `tokenjuice wrap -- <command>`\n";
await mkdir(join(home, ".crush", "skills", "tokenjuice"), { recursive: true });
await writeFile(skillPath, customSkill, "utf8");
await installCrushSkill(undefined, { projectDir: home });

const removed = await uninstallCrushSkill(skillPath);

expect(removed.removed).toBe(true);
await expect(access(skillPath)).rejects.toMatchObject({ code: "ENOENT" });
await expect(readFile(`${skillPath}.bak`, "utf8")).resolves.toContain("tokenjuice wrap -- <command>");
await expect(readFile(skillPath, "utf8")).resolves.toBe(customSkill);
await expect(access(`${skillPath}.bak`)).rejects.toMatchObject({ code: "ENOENT" });
});

it("restores a backed-up custom skill on uninstall", async () => {
Expand Down Expand Up @@ -202,6 +203,18 @@ describe("crush skill", () => {
await expect(readFile(skillPath, "utf8")).resolves.toContain("custom skill");
});

it("does not remove a custom skill that only mentions tokenjuice commands", async () => {
const home = await createTempDir();
const skillPath = join(home, ".crush", "skills", "tokenjuice", "SKILL.md");
await mkdir(join(home, ".crush", "skills", "tokenjuice"), { recursive: true });
await writeFile(skillPath, "---\nname: tokenjuice\n---\n\nrun tokenjuice wrap -- <command>\n", "utf8");

const removed = await uninstallCrushSkill(skillPath);

expect(removed.removed).toBe(false);
await expect(readFile(skillPath, "utf8")).resolves.toContain("run tokenjuice wrap -- <command>");
});

it("uses CRUSH_PROJECT_DIR for the default skill file", async () => {
const home = await createTempDir();
process.env.CRUSH_PROJECT_DIR = home;
Expand Down
29 changes: 29 additions & 0 deletions test/hosts/gemini-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,35 @@ describe("gemini-cli hooks", () => {
expect(parsed.hooks.AfterTool[1]?.hooks[0]?.command).toBe(`${launcherPath} gemini-cli-after-tool`);
});

it("preserves unrelated hooks that mention the tokenjuice subcommand", async () => {
const home = await createTempDir();
const settingsPath = join(home, "settings.json");
await writeFile(settingsPath, JSON.stringify({
hooks: {
AfterTool: [
{
matcher: "run_shell_command",
hooks: [{ type: "command", name: "custom", command: "echo gemini-cli-after-tool" }],
},
],
},
}));

await installGeminiCliHook(settingsPath);
const removed = await uninstallGeminiCliHook(settingsPath);
const parsed = JSON.parse(await readFile(settingsPath, "utf8")) as {
hooks: { AfterTool: Array<{ hooks: Array<{ name?: string; command: string }> }> };
};

expect(removed.removed).toBe(1);
expect(parsed.hooks.AfterTool).toEqual([
{
matcher: "run_shell_command",
hooks: [{ type: "command", name: "custom", command: "echo gemini-cli-after-tool" }],
},
]);
});

it("reports installed and uninstalled hook health", async () => {
const home = await createTempDir();
const settingsPath = join(home, "settings.json");
Expand Down
Loading
Loading