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
261 changes: 95 additions & 166 deletions .claude/skills/askdiff-dev/SKILL.md

Large diffs are not rendered by default.

279 changes: 96 additions & 183 deletions .claude/skills/askdiff/SKILL.md

Large diffs are not rendered by default.

9 changes: 4 additions & 5 deletions packages/cli/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,11 @@ if (!existsSync(skillSrc)) {
console.error(`error: SKILL.md not found at ${skillSrc}`);
process.exit(1);
}
// Substitute the source's `ASKDIFF_VERSION="latest"` with this build's
// exact version so the skill installed on a friend's machine pins to a
// known release. The in-repo source stays as 'latest' so /askdiff in
// this repo always pulls the newest published version.
// Substitute every `ASKDIFF_VERSION="latest"` (Step 4c + Step 5) with this
// build's exact version so the published skill pins to a known release. The
// in-repo source stays "latest" so /askdiff in this repo always pulls newest.
const skillBody = readFileSync(skillSrc, "utf8").replace(
/ASKDIFF_VERSION="latest"/,
/ASKDIFF_VERSION="latest"/g,
`ASKDIFF_VERSION="${pkg.version}"`,
);
writeFileSync(join(distDir, "skill.md"), skillBody);
Expand Down
191 changes: 191 additions & 0 deletions packages/cli/src/cli-integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { afterEach, beforeEach, describe, expect, it } from "@jest/globals";
import { execFile } from "node:child_process";
import {
existsSync,
mkdirSync,
mkdtempSync,
rmSync,
utimesSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve as resolvePath } from "node:path";
import { promisify } from "node:util";

const execFileAsync = promisify(execFile);

// Path to the built CLI bundle. Tests run after `pnpm --filter askdiff run build`,
// or directly during dev — when the bundle isn't present, tests are skipped to
// avoid masking the real failure.
const REPO_ROOT = resolvePath(__dirname, "..", "..", "..");
const CLI_PATH = join(REPO_ROOT, "packages", "cli", "dist", "index.js");

const FAKE_PROJECT_CWD = "/fake/integration/project";
const ENCODED = "-fake-integration-project";
const HOUR_MS = 3600 * 1000;

const writeJsonl = (
dir: string,
uuid: string,
lines: readonly string[],
mtimeMs: number,
): void => {
const path = join(dir, `${uuid}.jsonl`);
writeFileSync(path, lines.join("\n"));
const mtime = mtimeMs / 1000;
utimesSync(path, mtime, mtime);
};

describe("CLI integration: resolve-session subcommand", () => {
let configDir: string;
let projectDir: string;
let cliExists = true;

beforeEach(() => {
configDir = mkdtempSync(join(tmpdir(), "askdiff-cli-integration-"));
projectDir = join(configDir, "projects", ENCODED);
mkdirSync(projectDir, { recursive: true });

// Skip cleanly if the build hasn't run.
cliExists = existsSync(CLI_PATH);
});

afterEach(() => {
rmSync(configDir, { recursive: true, force: true });
});

const runCli = async (args: readonly string[]): Promise<{ stdout: string; stderr: string }> =>
execFileAsync("node", [CLI_PATH, ...args], {
env: {
...process.env,
CLAUDE_CONFIG_DIR: configDir,
},
});

it("respects --cwd when explicitly passed (regression: commander v14 parent/sub flag overlap)", async () => {
if (!cliExists) {
console.warn(`skipping — CLI bundle not built at ${CLI_PATH}`);
return;
}
const NOW = Date.now();
writeJsonl(projectDir, "11111111-1111-4111-8111-111111111111", ["foo bar"], NOW - HOUR_MS);

// Empty --cwd dir → no project dir under our isolated CLAUDE_CONFIG_DIR → empty.
const empty = await runCli([
"resolve-session",
"--cwd",
"/different/path/with/no/sessions",
"--keyword",
"foo",
]);
expect(JSON.parse(empty.stdout)).toEqual({ candidates: [] });

// Real --cwd → finds the fixture session.
const found = await runCli([
"resolve-session",
"--cwd",
FAKE_PROJECT_CWD,
"--keyword",
"foo",
]);
const parsed = JSON.parse(found.stdout) as { candidates: { uuid: string; count: number }[] };
expect(parsed.candidates).toHaveLength(1);
expect(parsed.candidates[0]?.uuid).toBe("11111111-1111-4111-8111-111111111111");
expect(parsed.candidates[0]?.count).toBe(1);
});

it("accepts repeated --keyword flags", async () => {
if (!cliExists) return;
const NOW = Date.now();
writeJsonl(
projectDir,
"22222222-2222-4222-8222-222222222222",
["mentions alpha", "mentions beta", "mentions both alpha and beta", "neither"],
NOW - HOUR_MS,
);

const r = await runCli([
"resolve-session",
"--cwd",
FAKE_PROJECT_CWD,
"--keyword",
"alpha",
"--keyword",
"beta",
]);
const parsed = JSON.parse(r.stdout) as { candidates: { count: number }[] };
expect(parsed.candidates[0]?.count).toBe(3); // 3 lines contain at least one needle
});

it("filters by --invoking", async () => {
if (!cliExists) return;
const NOW = Date.now();
writeJsonl(projectDir, "33333333-3333-4333-8333-333333333333", ["foo"], NOW - HOUR_MS);
writeJsonl(projectDir, "44444444-4444-4444-8444-444444444444", ["foo foo"], NOW - HOUR_MS);

const r = await runCli([
"resolve-session",
"--cwd",
FAKE_PROJECT_CWD,
"--invoking",
"44444444-4444-4444-8444-444444444444",
"--keyword",
"foo",
]);
const parsed = JSON.parse(r.stdout) as { candidates: { uuid: string }[] };
expect(parsed.candidates).toHaveLength(1);
expect(parsed.candidates[0]?.uuid).toBe("33333333-3333-4333-8333-333333333333");
});

it("emits empty candidates when no needles passed", async () => {
if (!cliExists) return;
const r = await runCli(["resolve-session", "--cwd", FAKE_PROJECT_CWD]);
expect(JSON.parse(r.stdout)).toEqual({ candidates: [] });
});
});

// The askdiff-dev skill calls the CLI via `tsx src/index.ts resolve-session …`.
// Static imports of `@askdiff/protocol` (CJS) at the top of `src/index.ts`
// would break this path under ESM. This test guards against re-introducing
// such imports.
describe("CLI integration: dev path (tsx)", () => {
let configDir: string;
let projectDir: string;

const TSX_PATH = join(REPO_ROOT, "node_modules", ".bin", "tsx");
const CLI_SOURCE = join(REPO_ROOT, "packages", "cli", "src", "index.ts");

beforeEach(() => {
configDir = mkdtempSync(join(tmpdir(), "askdiff-cli-tsx-"));
projectDir = join(configDir, "projects", ENCODED);
mkdirSync(projectDir, { recursive: true });
});

afterEach(() => {
rmSync(configDir, { recursive: true, force: true });
});

it("invokes resolve-session via tsx and returns valid JSON (dev-skill path)", async () => {
if (!existsSync(TSX_PATH)) {
console.warn(`skipping — tsx not found at ${TSX_PATH}`);
return;
}

const NOW = Date.now();
writeJsonl(
projectDir,
"55555555-5555-4555-8555-555555555555",
["dev-path-keyword line"],
NOW - HOUR_MS,
);

const { stdout } = await execFileAsync(
TSX_PATH,
[CLI_SOURCE, "resolve-session", "--cwd", FAKE_PROJECT_CWD, "--keyword", "dev-path-keyword"],
{ env: { ...process.env, CLAUDE_CONFIG_DIR: configDir } },
);
const parsed = JSON.parse(stdout) as { candidates: { uuid: string; count: number }[] };
expect(parsed.candidates).toHaveLength(1);
expect(parsed.candidates[0]?.uuid).toBe("55555555-5555-4555-8555-555555555555");
}, 15_000);
});
66 changes: 63 additions & 3 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,16 @@ import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { Command } from "commander";
import open from "open";
import { startServer, WS_PATH } from "@askdiff/server";
import { PROTOCOL_VERSION } from "@askdiff/protocol";
import { createUiHttpServer } from "./server-bundle.js";
import { resolveSession } from "./resolve-session.js";

// `@askdiff/server`, `@askdiff/protocol`, and `./server-bundle.js` are
// loaded lazily inside `runServer` (the only place that needs them).
// Static imports would force them onto every code path, including
// `resolve-session`, and the protocol package is CJS — statically
// importing it from this ESM module fails under `tsx` (used by the dev
// skill's resolve-session call). Dynamic imports of static-string
// specifiers are bundled by esbuild for the published CLI, so this
// costs nothing at production runtime.

const __dirname = dirname(fileURLToPath(import.meta.url));

Expand Down Expand Up @@ -66,10 +73,63 @@ async function main(): Promise<void> {
await installSkill(opts.force, opts.global);
});

const collect = (v: string, prev: string[]) => [...prev, v];
const parseIntOpt = (v: string) => Number.parseInt(v, 10);

program
.command("resolve-session")
.description(
"Search Claude Code session JSONLs in the current project for keyword/path/SHA/branch needles. Prints `{candidates:[{uuid,count,age}]}` JSON to stdout.",
)
// Note: `-c, --cwd` is intentionally NOT declared here. Commander v14
// routes it to the parent program (which already declares `-c, --cwd`)
// regardless of where it appears on the command line, so we read it
// from `program.opts()` and fall back to process.cwd().
.option("--invoking <uuid>", "session UUID to exclude (the invoking one)", "")
.option("--diff-file <path>", "extract `+++ b/<path>` lines from this diff as additional needles")
.option("--keyword <text>", "user keyword (repeatable)", collect, [] as string[])
.option("--sha <sha>", "commit SHA (repeatable)", collect, [] as string[])
.option("--branch <name>", "branch name (repeatable)", collect, [] as string[])
.option("--max-age-days <n>", "mtime cutoff in days", parseIntOpt, 30)
.option("--top <n>", "max candidates", parseIntOpt, 5)
.action(async (opts: ResolveSessionCliOptions) => {
const parentCwd = program.opts<{ cwd?: string }>().cwd;
const result = await resolveSession({
cwd: parentCwd ?? process.cwd(),
invoking: opts.invoking,
...(opts.diffFile !== undefined ? { diffFile: opts.diffFile } : {}),
keywords: opts.keyword,
shas: opts.sha,
branches: opts.branch,
maxAgeDays: opts.maxAgeDays,
top: opts.top,
});
console.log(JSON.stringify(result));
});

await program.parseAsync(process.argv);
}

interface ResolveSessionCliOptions {
invoking: string;
diffFile?: string;
keyword: string[];
sha: string[];
branch: string[];
maxAgeDays: number;
top: number;
}

async function runServer(opts: RunOptions): Promise<void> {
// Lazy-load: see top-of-file note. Keeps `resolve-session` off these
// imports so the dev-skill `tsx` invocation works.
const [{ startServer, WS_PATH }, { PROTOCOL_VERSION }, { createUiHttpServer }] =
await Promise.all([
import("@askdiff/server"),
import("@askdiff/protocol"),
import("./server-bundle.js"),
]);

const resolved = await resolveOptions(opts);
const port = await pickFreePort(resolved.port, resolved.host);

Expand Down
Loading
Loading