diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index ab75379db..80e3eb011 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -473,6 +473,7 @@ interface PackageJson { diffexConfig?: { name?: string; configDir?: string; + production?: boolean; }; } @@ -490,6 +491,8 @@ export const APP_NAME: string = diffexConfigName || "diffex"; export const APP_TITLE: string = diffexConfigName ? APP_NAME : "Diffex"; export const CONFIG_DIR_NAME: string = pkg.diffexConfig?.configDir || ".diffex"; export const VERSION: string = pkg.version || "0.0.0"; +/** Set by distribution staging, independently of the user's NODE_ENV. */ +export const IS_PRODUCTION_DISTRIBUTION: boolean = isBunBinary || pkg.diffexConfig?.production === true; export const ENV_AGENT_DIR = "DIFFEX_CODING_AGENT_DIR"; export const ENV_SESSION_DIR = "DIFFEX_CODING_AGENT_SESSION_DIR"; diff --git a/packages/coding-agent/src/core/prompt-templates.ts b/packages/coding-agent/src/core/prompt-templates.ts index e0fe89e53..0577c7da6 100644 --- a/packages/coding-agent/src/core/prompt-templates.ts +++ b/packages/coding-agent/src/core/prompt-templates.ts @@ -1,6 +1,6 @@ import { existsSync, readdirSync, readFileSync, statSync } from "fs"; import { basename, dirname, join, resolve, sep } from "path"; -import { CONFIG_DIR_NAME } from "../config.ts"; +import { CONFIG_DIR_NAME, IS_PRODUCTION_DISTRIBUTION } from "../config.ts"; import { parseFrontmatter } from "../utils/frontmatter.ts"; import { resolvePath } from "../utils/paths.ts"; import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.ts"; @@ -107,6 +107,7 @@ function loadTemplateFromFile(filePath: string, sourceInfo: SourceInfo): PromptT const { frontmatter, body } = parseFrontmatter>(rawContent); const name = basename(filePath).replace(/\.md$/, ""); + if (IS_PRODUCTION_DISTRIBUTION && name === "cl") return null; // Get description from frontmatter or first non-empty line let description = frontmatter.description || ""; @@ -273,6 +274,9 @@ export function expandPromptTemplate(text: string, templates: PromptTemplate[]): if (!match) return text; const templateName = match[1]; + if (IS_PRODUCTION_DISTRIBUTION && templateName === "cl") { + throw new Error("/cl is unavailable in production Diffex installations."); + } const argsString = match[2] ?? ""; const template = templates.find((t) => t.name === templateName); diff --git a/packages/coding-agent/src/core/system-prompt.ts b/packages/coding-agent/src/core/system-prompt.ts index 7c913bf4d..1aaaac810 100644 --- a/packages/coding-agent/src/core/system-prompt.ts +++ b/packages/coding-agent/src/core/system-prompt.ts @@ -2,7 +2,8 @@ * System prompt construction and project context loading */ -import { getDocsPath, getExamplesPath, getReadmePath } from "../config.ts"; +import { existsSync } from "node:fs"; +import { getDocsPath, getExamplesPath } from "../config.ts"; import { formatSkillsForPrompt, type Skill } from "./skills.ts"; export interface BuildSystemPromptOptions { @@ -79,7 +80,6 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions): string { } // Get absolute paths to documentation and examples - const readmePath = getReadmePath(); const docsPath = getDocsPath(); const examplesPath = getExamplesPath(); @@ -136,13 +136,16 @@ Guidelines: ${guidelines} Diffex documentation (read only when the user asks about Diffex itself, its SDK, extensions, themes, skills, or TUI): -- Main documentation: ${readmePath} +${ + existsSync(docsPath) + ? `- Product website: https://diffex.ai/ - Additional docs: ${docsPath} -- Examples: ${examplesPath} (extensions, custom tools, SDK) -- When reading Diffex docs or examples, resolve docs/... under Additional docs and examples/... under Examples, not the current working directory -- When asked about: extensions (docs/extensions.md, examples/extensions/), themes (docs/themes.md), skills (docs/skills.md), prompt templates (docs/prompt-templates.md), TUI components (docs/tui.md), keybindings (docs/keybindings.md), SDK integrations (docs/sdk.md), custom providers (docs/custom-provider.md), adding models (docs/models.md), Diffex packages (docs/packages.md), environment variables (docs/environment-variables.md) -- When working on Diffex topics, read the docs and examples, and follow .md cross-references before implementing -- Always read Diffex.md files completely and follow links to related docs (e.g., tui.md for TUI API details)`; +${existsSync(examplesPath) ? `- Examples: ${examplesPath} (extensions, custom tools, SDK)\n- When reading Diffex docs or examples, resolve docs/... under Additional docs and examples/... under Examples, not the current working directory` : "- Resolve docs/... under Additional docs, not the current working directory; example source files are not included in this installation"} +- When asked about: extensions (docs/extensions.md), themes (docs/themes.md), skills (docs/skills.md), prompt templates (docs/prompt-templates.md), TUI components (docs/tui.md), keybindings (docs/keybindings.md), SDK integrations (docs/sdk.md), custom providers (docs/custom-provider.md), adding models (docs/models.md), Diffex packages (docs/packages.md), environment variables (docs/environment-variables.md) +- When working on Diffex topics, read the docs and follow .md cross-references before implementing +- Always read Diffex.md files completely and follow links to related docs (e.g., tui.md for TUI API details)` + : "- Product website: https://diffex.ai/" +}`; if (appendSection) { prompt += appendSection; diff --git a/packages/coding-agent/test/production-prompt-templates.test.ts b/packages/coding-agent/test/production-prompt-templates.test.ts new file mode 100644 index 000000000..e9cbf2df3 --- /dev/null +++ b/packages/coding-agent/test/production-prompt-templates.test.ts @@ -0,0 +1,49 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test, vi } from "vitest"; +import { expandPromptTemplate, loadPromptTemplates } from "../src/core/prompt-templates.ts"; + +vi.mock("../src/config.ts", () => ({ CONFIG_DIR_NAME: ".diffex", IS_PRODUCTION_DISTRIBUTION: true })); + +describe("production prompt templates", () => { + test.each(["project", "global", "directory", "file"])( + "hides /cl loaded from %s while preserving user prompts", + (source) => { + const root = mkdtempSync(join(tmpdir(), "diffex-production-prompts-")); + try { + const cwd = join(root, "project"); + const agentDir = join(root, "agent"); + const directory = + source === "project" + ? join(cwd, ".diffex/prompts") + : source === "global" + ? join(agentDir, "prompts") + : join(root, "explicit"); + mkdirSync(directory, { recursive: true }); + writeFileSync(join(directory, "cl.md"), "Audit repository changelogs"); + writeFileSync(join(directory, "review.md"), "Review $ARGUMENTS"); + const promptPaths = + source === "file" + ? [join(directory, "cl.md"), join(directory, "review.md")] + : source === "directory" + ? [directory] + : []; + const templates = loadPromptTemplates({ cwd, agentDir, promptPaths, includeDefaults: true }); + expect(templates.map((template) => template.name)).toEqual(["review"]); + expect(expandPromptTemplate("/review changes", templates)).toBe("Review changes"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + ); + + test.each(["/cl", "/cl audit", "/cl\naudit"])("rejects %s even without loaded templates", (text) => { + expect(() => expandPromptTemplate(text, [])).toThrow("/cl is unavailable in production Diffex installations."); + }); + + test("preserves ordinary text and similarly named commands", () => { + for (const text of ["Discuss /cl", "/clear", "/clone", "/changelog"]) + expect(expandPromptTemplate(text, [])).toBe(text); + }); +}); diff --git a/packages/coding-agent/test/system-prompt.test.ts b/packages/coding-agent/test/system-prompt.test.ts index fa0ced448..e6bbbc7d6 100644 --- a/packages/coding-agent/test/system-prompt.test.ts +++ b/packages/coding-agent/test/system-prompt.test.ts @@ -1,7 +1,42 @@ -import { describe, expect, test } from "vitest"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test, vi } from "vitest"; import { buildSystemPrompt } from "../src/core/system-prompt.ts"; describe("buildSystemPrompt", () => { + test("uses installed reference docs without pointing to an empty README or absent examples", () => { + const directory = mkdtempSync(join(tmpdir(), "diffex-package-docs-")); + try { + mkdirSync(join(directory, "docs")); + writeFileSync(join(directory, "README.md"), ""); + vi.stubEnv("DIFFEX_PACKAGE_DIR", directory); + const prompt = buildSystemPrompt({ cwd: process.cwd() }); + expect(prompt).toContain(`Additional docs: ${join(directory, "docs")}`); + expect(prompt).toContain("skills (docs/skills.md)"); + expect(prompt).toContain("https://diffex.ai/"); + expect(prompt).not.toContain("README.md"); + expect(prompt).not.toContain("Examples:"); + } finally { + vi.unstubAllEnvs(); + rmSync(directory, { recursive: true, force: true }); + } + }); + test("uses the product website when packaged documentation is absent", () => { + const directory = mkdtempSync(join(tmpdir(), "diffex-package-docs-")); + try { + vi.stubEnv("DIFFEX_PACKAGE_DIR", directory); + const prompt = buildSystemPrompt({ cwd: process.cwd() }); + expect(prompt).toContain("https://diffex.ai/"); + expect(prompt).not.toContain("Main documentation:"); + expect(prompt).not.toContain("Additional docs:"); + expect(prompt).not.toContain("Examples:"); + expect(prompt).not.toContain(directory); + } finally { + vi.unstubAllEnvs(); + rmSync(directory, { recursive: true, force: true }); + } + }); describe("empty tools", () => { test("shows (none) for empty tools list", () => { const prompt = buildSystemPrompt({ diff --git a/scripts/check-distribution-payload.py b/scripts/check-distribution-payload.py index 4c3af961d..f4f299df5 100644 --- a/scripts/check-distribution-payload.py +++ b/scripts/check-distribution-payload.py @@ -122,7 +122,7 @@ def inspect_archive(file, license_file, expected_name=None, expected_version=Non root = roots[0] if root and any(not name.startswith(root) for name in entries): fail(str(file), 'file outside distribution root') - for notice in ['LICENSE', 'NOTICE', 'THIRD-PARTY-NOTICES.txt', 'distribution-components.json', 'distribution-files.json']: + for notice in ['LICENSE', 'THIRD-PARTY-NOTICES.txt', 'distribution-components.json', 'distribution-files.json']: if not entries.get(root + notice, b'').strip(): fail(str(file), 'missing required notice: ' + notice) if entries[root + 'LICENSE'] != Path(license_file).read_bytes(): diff --git a/scripts/cli-distribution.mjs b/scripts/cli-distribution.mjs index b249d8d19..e40b44b20 100644 --- a/scripts/cli-distribution.mjs +++ b/scripts/cli-distribution.mjs @@ -54,6 +54,13 @@ export function cliDistribution(root = ".") { throw new Error(`Bundled workspace version differs from CLI: ${pkg.manifest.name}`); for (const name of Object.keys(dependencies)) delete optionalDependencies[name]; const manifest = structuredClone(cli.manifest); + manifest.homepage = "https://diffex.ai/"; + manifest.diffexConfig = { ...manifest.diffexConfig, production: true }; + delete manifest.repository; + delete manifest.bugs; + manifest.files = manifest.files.filter( + (file) => !["notice", "contributing.md"].includes(file.split("/")[0].toLowerCase()), + ); manifest.dependencies = Object.fromEntries(Object.entries(dependencies).sort(([a], [b]) => a.localeCompare(b))); manifest.optionalDependencies = Object.fromEntries( Object.entries(optionalDependencies).sort(([a], [b]) => a.localeCompare(b)), diff --git a/scripts/distribution-payload.test.mjs b/scripts/distribution-payload.test.mjs index 133acf1ba..779bf6de3 100644 --- a/scripts/distribution-payload.test.mjs +++ b/scripts/distribution-payload.test.mjs @@ -79,7 +79,6 @@ test("accepts a tarball with readable JavaScript, declarations, and complete not for (const notice of [ "LICENSE", - "NOTICE", "THIRD-PARTY-NOTICES.txt", "distribution-components.json", "distribution-files.json", @@ -91,6 +90,11 @@ for (const notice of [ }); } +test("accepts consolidated attribution without a separate NOTICE file", () => { + const result = inspect({}, "NOTICE"); + assert.equal(result.status, 0, result.stderr); +}); + for (const [name, content, reason] of [ ["dist/implementation.ts", "export const value = 1", /prohibited source file/], ["dist/renamed.js", "export interface Hidden {", /TypeScript implementation content/], diff --git a/scripts/prepare-distribution.mjs b/scripts/prepare-distribution.mjs index 0b85d2b31..a571da182 100644 --- a/scripts/prepare-distribution.mjs +++ b/scripts/prepare-distribution.mjs @@ -59,8 +59,9 @@ export function preparePackageDistribution(directory) { notice: "LICENSE", }, ]; - let notices = - "Diffex modifications copyright 2026 Luntraa, Inc.\nPi-derived portions retain the complete MIT terms in LICENSE.\n"; + const notice = + "Diffex\nCopyright 2026 Luntraa, Inc.\n\nWhere this distribution contains Pi Agent Harness-derived portions,\nthose portions retain their MIT terms and copyright notice in LICENSE.\nThe Apache-2.0 package declaration does not replace third-party terms.\n"; + let notices = notice; if (pkg.name === "@diffexai/diffex") { for (const workspace of ["evolution", "harness-trace"]) { components.push({ @@ -95,10 +96,7 @@ export function preparePackageDistribution(directory) { }); } cpSync(join(root, "LICENSE"), join(absolute, "LICENSE")); - writeFileSync( - join(absolute, "NOTICE"), - `Diffex\nCopyright 2026 Luntraa, Inc.\n\nWhere this distribution contains Pi Agent Harness-derived portions,\nthose portions retain their MIT terms and copyright notice in LICENSE.\nThe Apache-2.0 package declaration does not replace third-party terms.\n`, - ); + writeFileSync(join(absolute, "NOTICE"), notice); writeFileSync(join(absolute, "THIRD-PARTY-NOTICES.txt"), notices); for (const component of components) { if (component.notice) diff --git a/scripts/prepare-distribution.test.mjs b/scripts/prepare-distribution.test.mjs index 55c47fb5d..bd2b394cc 100644 --- a/scripts/prepare-distribution.test.mjs +++ b/scripts/prepare-distribution.test.mjs @@ -1,10 +1,114 @@ import assert from "node:assert/strict"; import { execFileSync, spawnSync } from "node:child_process"; -import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; import { PROJECT_CHANGELOGS } from "./prepare-distribution.mjs"; +import { cliDistribution } from "./cli-distribution.mjs"; + +test("packed CLI exposes the product homepage and preserves referenced documentation", () => { + const root = mkdtempSync(join(tmpdir(), "diffex-cli-presentation-")); + try { + const copy = (file) => { + mkdirSync(join(root, file, ".."), { recursive: true }); + cpSync(file, join(root, file)); + }; + for (const script of [ + "prepare-distribution.mjs", + "release-packages.mjs", + "release-distribution-manifest.mjs", + "package-workspaces.mjs", + "check-distribution-payload.py", + "cli-distribution.mjs", + "stage-cli-distribution.mjs", + ]) copy(`scripts/${script}`); + for (const file of [ + "LICENSE", + "docs/release/distribution-policy.json", + "legal/upstream/highlight-11.9.0.LICENSE", + "node_modules/marked/LICENSE", + "packages/coding-agent/npm-shrinkwrap.json", + "packages/coding-agent/install-lock/package.json", + "packages/coding-agent/install-lock/package-lock.json", + ]) copy(file); + const directories = [ + "packages/coding-agent", + "packages/evolution", + "packages/harness-trace", + ...cliDistribution().bundled.map((pkg) => pkg.directory), + ]; + for (const directory of directories) { + copy(`${directory}/package.json`); + for (const folder of ["src", "dist", "docs"]) + mkdirSync(join(root, directory, folder), { recursive: true }); + writeFileSync(join(root, directory, "src/index.ts"), "export const value = 1;\n"); + writeFileSync(join(root, directory, "dist/index.js"), "export const value = 1;\n"); + for (const file of ["README.md", "CONTRIBUTING.md", "docs/skills.md", "CHANGELOG.md"]) + writeFileSync(join(root, directory, file), "Repository documentation\n"); + } + const pkg = join(root, "packages/coding-agent"); + cpSync("packages/coding-agent/docs", join(pkg, "docs"), { recursive: true }); + const excludedDocs = [ + "development.md", + "docs.json", + "index.md", + "invocation-traces.md", + "images/doom-extension.png", + "images/exy.png", + ]; + const metadata = JSON.parse(readFileSync(join(pkg, "package.json"), "utf8")); + metadata.files.push("CONTRIBUTING.md"); + writeFileSync(join(pkg, "package.json"), JSON.stringify(metadata)); + for (const platform of ["darwin", "win32"]) + mkdirSync(join(root, "packages/tui/native", platform, "prebuilds"), { recursive: true }); + writeFileSync( + join(root, "prepare.mjs"), + 'import { preparePackageDistribution } from "./scripts/prepare-distribution.mjs";\n' + + 'import { stageCliDistribution } from "./scripts/stage-cli-distribution.mjs";\n' + + 'preparePackageDistribution("packages/coding-agent");\n' + + 'stageCliDistribution("packages/coding-agent", "stage");\n', + ); + execFileSync("node", ["prepare.mjs"], { + cwd: root, + env: { ...process.env, DIFFEX_SOURCE_COMMIT: "a".repeat(40) }, + }); + const [packed] = JSON.parse( + execFileSync("npm", ["pack", "--ignore-scripts", "--json", "--cache", join(root, "cache")], { + cwd: join(root, "stage"), + encoding: "utf8", + }), + ); + const archive = join(root, "stage", packed.filename); + const published = JSON.parse(execFileSync("tar", ["-xOf", archive, "package/package.json"], { encoding: "utf8" })); + assert.equal(published.homepage, "https://diffex.ai/"); + assert.equal(published.repository, undefined); + assert.equal(published.bugs, undefined); + assert.equal(published.diffexConfig.production, true); + assert.equal(execFileSync("tar", ["-xOf", archive, "package/README.md"]).length, 0); + for (const file of readdirSync(join(pkg, "docs"), { recursive: true })) { + const source = join(pkg, "docs", file); + if (!statSync(source).isFile()) continue; + const path = `docs/${file.replaceAll("\\", "/")}`; + if (excludedDocs.includes(file.replaceAll("\\", "/"))) { + assert(!packed.files.some((entry) => entry.path === path), `${path} must not ship`); + } else { + assert.deepEqual(execFileSync("tar", ["-xOf", archive, `package/${path}`]), readFileSync(source), `${path} must remain intact`); + } + } + for (const file of excludedDocs) assert(existsSync(join(pkg, "docs", file)), `${file} must remain in source`); + for (const { path } of packed.files) + assert(!path.split("/").some((part) => ["notice", "contributing.md"].includes(part.toLowerCase())), path); + const attribution = execFileSync("tar", ["-xOf", archive, "package/THIRD-PARTY-NOTICES.txt"], { encoding: "utf8" }); + assert(attribution.startsWith(readFileSync(join(pkg, "NOTICE"), "utf8"))); + assert.match(attribution, /highlight\.js@11\.9\.0/); + assert.match(attribution, /marked@18\.0\.5/); + const result = spawnSync("python3", ["scripts/check-distribution-payload.py", archive], { cwd: root, encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); test("actual npm tarball carries notices and excludes debug sources after preparation", () => { const root = mkdtempSync(join(tmpdir(), "diffex-prepack-test-")); diff --git a/scripts/stage-cli-distribution.mjs b/scripts/stage-cli-distribution.mjs index 56cbfe3c5..630e0289d 100644 --- a/scripts/stage-cli-distribution.mjs +++ b/scripts/stage-cli-distribution.mjs @@ -1,6 +1,6 @@ import { cpSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { createHash } from "node:crypto"; -import { join } from "node:path"; +import { join, relative } from "node:path"; import { cliDistribution } from "./cli-distribution.mjs"; import { preparePackageDistribution } from "./prepare-distribution.mjs"; @@ -9,13 +9,24 @@ export function stageCliDistribution(directory, stage) { mkdirSync(stage, { recursive: true }); const notices = [ "LICENSE", - "NOTICE", "THIRD-PARTY-NOTICES.txt", "distribution-components.json", "distribution-files.json", ]; - for (const file of manifest.files) cpSync(join(directory, file), join(stage, file), { recursive: true }); - cpSync(join(directory, "README.md"), join(stage, "README.md")); + const excludedDocs = new Set([ + "docs/development.md", + "docs/docs.json", + "docs/index.md", + "docs/invocation-traces.md", + "docs/images/doom-extension.png", + "docs/images/exy.png", + ]); + for (const file of manifest.files) + cpSync(join(directory, file), join(stage, file), { + recursive: true, + filter: (source) => !excludedDocs.has(relative(directory, source).replaceAll("\\", "/")), + }); + writeFileSync(join(stage, "README.md"), ""); const inventory = JSON.parse(readFileSync(join(directory, "distribution-files.json"), "utf8")); const components = JSON.parse(readFileSync(join(directory, "distribution-components.json"), "utf8")).filter( (component) => !bundled.some((pkg) => pkg.manifest.name === component.name), @@ -33,6 +44,8 @@ export function stageCliDistribution(directory, stage) { "peerDependencies", "peerDependenciesMeta", "files", + "repository", + "bugs", ]) delete metadata[field]; mkdirSync(target, { recursive: true });