diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md
index fc729be0e8d..752babea7a0 100644
--- a/news/changelog-1.9.md
+++ b/news/changelog-1.9.md
@@ -11,7 +11,6 @@ All changes included in 1.9:
- ([#13633](https://github.com/quarto-dev/quarto-cli/issues/13633)): Fix detection and auto-installation of babel language packages from newer error format that doesn't explicitly mention `.ldf` filename.
- ([#13694](https://github.com/quarto-dev/quarto-cli/issues/13694)): Fix `notebook-view.url` being ignored - external notebook links now properly use specified URLs instead of local preview files.
- ([#13732](https://github.com/quarto-dev/quarto-cli/issues/13732)): Fix automatic font package installation for fonts with spaces in their names (e.g., "Noto Emoji", "DejaVu Sans"). Font file search patterns now match both with and without spaces.
-- ([#13798](https://github.com/quarto-dev/quarto-cli/pull/13798)): Directories specified in `ExecutionEngineDiscovery.ignoreDirs` were not getting ignored.
## Dependencies
@@ -19,12 +18,6 @@ All changes included in 1.9:
- Update `deno` to 2.4.5
- ([#13601](https://github.com/quarto-dev/quarto-cli/pull/13601)): Update `mermaid` to 11.12.0 (author: @multimeric)
-## Extensions
-
-- Metadata and brand extensions now work without a `_quarto.yml` project. (Engine extensions do too.) A temporary default project is created in memory.
-
-- New **Engine Extensions**, to allow other execution engines than knitr, jupyter, julia. Julia is now a bundled extension. See [the prerelease notes](https://prerelease.quarto.org/docs/prerelease/1.9/) and [engine extension documentation](https://prerelease.quarto.org/docs/extensions/engine.html).
-
## Formats
### `gfm`
@@ -78,7 +71,7 @@ All changes included in 1.9:
- ([#10031](https://github.com/quarto-dev/quarto-cli/issues/10031)): Fix manuscript rendering prompting for GitHub credentials when origin points to private repository. Auto-detection of manuscript URL now fails gracefully with a warning instead of blocking renders.
-## `publish`
+## Publishing
### Confluence
@@ -88,6 +81,22 @@ All changes included in 1.9:
- ([#13762](https://github.com/quarto-dev/quarto-cli/issues/13762)): Add `quarto.paths.typst()` to Quarto's Lua API to resolve Typst binary path in Lua filters and extensions consistently with Quarto itself. (author: @mcanouil)
+## Commands
+
+### `use brand`
+
+- ([#13828](https://github.com/quarto-dev/quarto-cli/pull/13828)): New `quarto use brand` command copies and synchronizes the `_brand/` directory from a repo, directory, or ZIP file. See [the prerelease documentation](https://prerelease.quarto.org/docs/authoring/brand.html#quarto-use-brand) for details.
+
+### `call build-ts-extension`
+
+- (): New `quarto call build-ts-extension` command builds a TypeScript extension, such as an engine extension, and places the artifacts in the `_extensions` directory. See the [engine extension pre-release documentation](https://prerelease.quarto.org/docs/extensions/engine.html) for details.
+
+## Extensions
+
+- Metadata and brand extensions now work without a `_quarto.yml` project. (Engine extensions do too.) A temporary default project is created in memory.
+
+- New **Engine Extensions**, to allow other execution engines than knitr, jupyter, julia. Julia is now a bundled extension. See [the prerelease notes](https://prerelease.quarto.org/docs/prerelease/1.9/) and [engine extension documentation](https://prerelease.quarto.org/docs/extensions/engine.html).
+
## Other fixes and improvements
- ([#13402](https://github.com/quarto-dev/quarto-cli/issues/13402)): `nfpm` () is now used to create the `.deb` package, and new `.rpm` package. Both Linux packages are also now built for `x86_64` (`amd64`) and `aarch64` (`arm64`) architectures.
diff --git a/src/command/use/cmd.ts b/src/command/use/cmd.ts
index c7089b15de8..6251f5bf02c 100644
--- a/src/command/use/cmd.ts
+++ b/src/command/use/cmd.ts
@@ -7,8 +7,9 @@ import { Command, ValidationError } from "cliffy/command/mod.ts";
import { useTemplateCommand } from "./commands/template.ts";
import { useBinderCommand } from "./commands/binder/binder.ts";
+import { useBrandCommand } from "./commands/brand.ts";
-const kUseCommands = [useTemplateCommand, useBinderCommand];
+const kUseCommands = [useTemplateCommand, useBinderCommand, useBrandCommand];
export const makeUseCommand = () => {
const theCommand = new Command()
diff --git a/src/command/use/commands/brand.ts b/src/command/use/commands/brand.ts
new file mode 100644
index 00000000000..6dac4b0dd07
--- /dev/null
+++ b/src/command/use/commands/brand.ts
@@ -0,0 +1,475 @@
+/*
+ * brand.ts
+ *
+ * Copyright (C) 2021-2025 Posit Software, PBC
+ */
+
+import {
+ ExtensionSource,
+ extensionSource,
+} from "../../../extension/extension-host.ts";
+import { info } from "../../../deno_ral/log.ts";
+import { Confirm } from "cliffy/prompt/mod.ts";
+import { basename, dirname, join, relative } from "../../../deno_ral/path.ts";
+import { ensureDir, ensureDirSync, existsSync } from "../../../deno_ral/fs.ts";
+import { TempContext } from "../../../core/temp-types.ts";
+import { downloadWithProgress } from "../../../core/download.ts";
+import { withSpinner } from "../../../core/console.ts";
+import { unzip } from "../../../core/zip.ts";
+import { templateFiles } from "../../../extension/template.ts";
+import { Command } from "cliffy/command/mod.ts";
+import { initYamlIntelligenceResourcesFromFilesystem } from "../../../core/schema/utils.ts";
+import { createTempContext } from "../../../core/temp.ts";
+import { InternalError } from "../../../core/lib/error.ts";
+import { notebookContext } from "../../../render/notebook/notebook-context.ts";
+import { projectContext } from "../../../project/project-context.ts";
+import { afterConfirm } from "../../../tools/tools-console.ts";
+
+const kRootTemplateName = "template.qmd";
+
+export const useBrandCommand = new Command()
+ .name("brand")
+ .arguments("")
+ .description(
+ "Use a brand for this project.",
+ )
+ .option(
+ "--force",
+ "Skip all prompts and confirmations",
+ )
+ .option(
+ "--dry-run",
+ "Show what would happen without making changes",
+ )
+ .example(
+ "Use a brand from Github",
+ "quarto use brand /",
+ )
+ .action(
+ async (
+ options: { force?: boolean; dryRun?: boolean },
+ target: string,
+ ) => {
+ if (options.force && options.dryRun) {
+ throw new Error("Cannot use --force and --dry-run together");
+ }
+ await initYamlIntelligenceResourcesFromFilesystem();
+ const temp = createTempContext();
+ try {
+ await useBrand(options, target, temp);
+ } finally {
+ temp.cleanup();
+ }
+ },
+ );
+
+async function useBrand(
+ options: { force?: boolean; dryRun?: boolean },
+ target: string,
+ tempContext: TempContext,
+) {
+ // Print header for dry-run
+ if (options.dryRun) {
+ info("\nDry run - no changes will be made.");
+ }
+
+ // Resolve brand host and trust
+ const source = await extensionSource(target);
+ // Is this source valid?
+ if (!source) {
+ info(
+ `Brand not found in local or remote sources`,
+ );
+ return;
+ }
+
+ // Check trust (skip for dry-run or force)
+ if (!options.dryRun && !options.force) {
+ const trusted = await isTrusted(source);
+ if (!trusted) {
+ return;
+ }
+ }
+
+ // Resolve brand directory
+ const brandDir = await ensureBrandDirectory(
+ options.force === true,
+ options.dryRun === true,
+ );
+
+ // Extract and move the template into place
+ const stagedDir = await stageBrand(source, tempContext);
+
+ // Filter the list to template files
+ const filesToCopy = templateFiles(stagedDir);
+
+ // Confirm changes to brand directory (skip for dry-run or force)
+ if (!options.dryRun && !options.force) {
+ const filename = (typeof (source.resolvedTarget) === "string"
+ ? source.resolvedTarget
+ : source.resolvedFile) || "brand.zip";
+
+ const allowUse = await Confirm.prompt({
+ message: `Proceed with using brand ${filename}?`,
+ default: true,
+ });
+ if (!allowUse) {
+ return;
+ }
+ }
+
+ if (!options.dryRun) {
+ info(
+ `\nPreparing brand files...`,
+ );
+ }
+
+ // Build set of source file paths for comparison
+ const sourceFiles = new Set(
+ filesToCopy
+ .filter((f) => !Deno.statSync(f).isDirectory)
+ .map((f) => relative(stagedDir, f)),
+ );
+
+ // Find extra files in target that aren't in source
+ const extraFiles = findExtraFiles(brandDir, sourceFiles);
+
+ // Track files by action type
+ const wouldOverwrite: string[] = [];
+ const wouldCreate: string[] = [];
+ const wouldRemove: string[] = [];
+ const copyActions: Array<{
+ file: string;
+ action: "create" | "overwrite";
+ copy: () => Promise;
+ }> = [];
+ let removed: string[] = [];
+
+ for (const fileToCopy of filesToCopy) {
+ const isDir = Deno.statSync(fileToCopy).isDirectory;
+ const rel = relative(stagedDir, fileToCopy);
+ if (isDir) {
+ continue;
+ }
+ // Compute the paths
+ const targetPath = join(brandDir, rel);
+ const displayName = rel;
+ const targetDir = dirname(targetPath);
+ const copyAction = {
+ file: displayName,
+ copy: async () => {
+ // Ensure the directory exists
+ await ensureDir(targetDir);
+
+ // Copy the file into place
+ await Deno.copyFile(fileToCopy, targetPath);
+ },
+ };
+
+ if (existsSync(targetPath)) {
+ // File exists - will be overwritten
+ if (options.dryRun) {
+ wouldOverwrite.push(displayName);
+ } else if (!options.force) {
+ // Prompt for overwrite
+ const proceed = await Confirm.prompt({
+ message: `Overwrite file ${displayName}?`,
+ default: true,
+ });
+ if (proceed) {
+ copyActions.push({ ...copyAction, action: "overwrite" });
+ } else {
+ throw new Error(
+ `The file ${displayName} already exists and would be overwritten by this action.`,
+ );
+ }
+ } else {
+ // Force mode - overwrite without prompting
+ copyActions.push({ ...copyAction, action: "overwrite" });
+ }
+ } else {
+ // File doesn't exist - will be created
+ if (options.dryRun) {
+ wouldCreate.push(displayName);
+ } else {
+ copyActions.push({ ...copyAction, action: "create" });
+ }
+ }
+ }
+
+ // Output dry-run summary and return
+ if (options.dryRun) {
+ if (wouldOverwrite.length > 0) {
+ info(`\nWould overwrite:`);
+ for (const file of wouldOverwrite) {
+ info(` - ${file}`);
+ }
+ }
+ if (wouldCreate.length > 0) {
+ info(`\nWould create:`);
+ for (const file of wouldCreate) {
+ info(` - ${file}`);
+ }
+ }
+ if (extraFiles.length > 0) {
+ info(`\nWould remove:`);
+ for (const file of extraFiles) {
+ info(` - ${file}`);
+ }
+ }
+ return;
+ }
+
+ // Copy the files
+ if (copyActions.length > 0) {
+ await withSpinner({ message: "Copying files..." }, async () => {
+ for (const copyAction of copyActions) {
+ await copyAction.copy();
+ }
+ });
+ }
+
+ // Handle extra files in target (not in source)
+ if (extraFiles.length > 0) {
+ const removeExtras = async () => {
+ for (const file of extraFiles) {
+ await Deno.remove(join(brandDir, file));
+ }
+ // Clean up empty directories
+ cleanupEmptyDirs(brandDir);
+ removed = extraFiles;
+ };
+
+ if (options.force) {
+ await removeExtras();
+ } else {
+ // Show the files that would be removed
+ info(`\nExtra files not in source brand:`);
+ for (const file of extraFiles) {
+ info(` - ${file}`);
+ }
+ // Use afterConfirm pattern - declining doesn't cancel command
+ await afterConfirm(
+ `Remove these ${extraFiles.length} file(s)?`,
+ removeExtras,
+ );
+ }
+ }
+
+ // Output summary of changes
+ const overwritten = copyActions.filter((a) => a.action === "overwrite");
+ const created = copyActions.filter((a) => a.action === "create");
+ if (overwritten.length > 0) {
+ info(`\nOverwritten:`);
+ for (const a of overwritten) {
+ info(` - ${a.file}`);
+ }
+ }
+ if (created.length > 0) {
+ info(`\nCreated:`);
+ for (const a of created) {
+ info(` - ${a.file}`);
+ }
+ }
+ if (removed.length > 0) {
+ info(`\nRemoved:`);
+ for (const file of removed) {
+ info(` - ${file}`);
+ }
+ }
+}
+
+async function stageBrand(
+ source: ExtensionSource,
+ tempContext: TempContext,
+) {
+ if (source.type === "remote") {
+ // A temporary working directory
+ const workingDir = tempContext.createDir();
+
+ // Stages a remote file by downloading and unzipping it
+ const archiveDir = join(workingDir, "archive");
+ ensureDirSync(archiveDir);
+
+ // The filename
+ const filename = (typeof (source.resolvedTarget) === "string"
+ ? source.resolvedTarget
+ : source.resolvedFile) || "brand.zip";
+
+ // The tarball path
+ const toFile = join(archiveDir, filename);
+
+ // Download the file
+ await downloadWithProgress(source.resolvedTarget, `Downloading`, toFile);
+
+ // Unzip and remove zip
+ await unzipInPlace(toFile);
+
+ // Try to find the correct sub directory
+ if (source.targetSubdir) {
+ const sourceSubDir = join(archiveDir, source.targetSubdir);
+ if (existsSync(sourceSubDir)) {
+ return sourceSubDir;
+ }
+ }
+
+ // Couldn't find a source sub dir, see if there is only a single
+ // subfolder and if so use that
+ const dirEntries = Deno.readDirSync(archiveDir);
+ let count = 0;
+ let name;
+ let hasFiles = false;
+ for (const dirEntry of dirEntries) {
+ // ignore any files
+ if (dirEntry.isDirectory) {
+ name = dirEntry.name;
+ count++;
+ } else {
+ hasFiles = true;
+ }
+ }
+ // there is a lone subfolder - use that.
+ if (!hasFiles && count === 1 && name) {
+ return join(archiveDir, name);
+ }
+
+ return archiveDir;
+ } else {
+ if (typeof source.resolvedTarget !== "string") {
+ throw new InternalError(
+ "Local resolved extension should always have a string target.",
+ );
+ }
+
+ if (Deno.statSync(source.resolvedTarget).isDirectory) {
+ // copy the contents of the directory, filtered by quartoignore
+ return source.resolvedTarget;
+ } else {
+ // A temporary working directory
+ const workingDir = tempContext.createDir();
+ const targetFile = join(workingDir, basename(source.resolvedTarget));
+
+ // Copy the zip to the working dir
+ Deno.copyFileSync(
+ source.resolvedTarget,
+ targetFile,
+ );
+
+ await unzipInPlace(targetFile);
+ return workingDir;
+ }
+ }
+}
+
+// Determines whether the user trusts the brand
+async function isTrusted(
+ source: ExtensionSource,
+): Promise {
+ if (source.type === "remote") {
+ // Write the preamble
+ const preamble =
+ `\nIf you do not trust the authors of the brand, we recommend that you do not install or use the brand.`;
+ info(preamble);
+
+ // Ask for trust
+ const question = "Do you trust the authors of this brand";
+ const confirmed: boolean = await Confirm.prompt({
+ message: question,
+ default: true,
+ });
+ return confirmed;
+ } else {
+ return true;
+ }
+}
+
+async function ensureBrandDirectory(force: boolean, dryRun: boolean) {
+ const currentDir = Deno.cwd();
+ const nbContext = notebookContext();
+ const project = await projectContext(currentDir, nbContext);
+ if (!project) {
+ throw new Error(`Could not find project dir for ${currentDir}`);
+ }
+ const brandDir = join(project.dir, "_brand");
+ if (!existsSync(brandDir)) {
+ if (dryRun) {
+ info(` Would create directory: _brand/`);
+ } else if (!force) {
+ // Prompt for confirmation
+ if (
+ !await Confirm.prompt({
+ message: `Create brand directory ${brandDir}?`,
+ default: true,
+ })
+ ) {
+ throw new Error(`Could not create brand directory ${brandDir}`);
+ }
+ ensureDirSync(brandDir);
+ } else {
+ // Force mode - create without prompting
+ ensureDirSync(brandDir);
+ }
+ }
+ return brandDir;
+}
+
+// Unpack and stage a zipped file
+async function unzipInPlace(zipFile: string) {
+ // Unzip the file
+ await withSpinner(
+ { message: "Unzipping" },
+ async () => {
+ // Unzip the archive
+ const result = await unzip(zipFile);
+ if (!result.success) {
+ throw new Error("Failed to unzip brand.\n" + result.stderr);
+ }
+
+ // Remove the tar ball itself
+ await Deno.remove(zipFile);
+
+ return Promise.resolve();
+ },
+ );
+}
+
+// Find files in target directory that aren't in source
+function findExtraFiles(
+ targetDir: string,
+ sourceFiles: Set,
+): string[] {
+ const extraFiles: string[] = [];
+
+ function walkDir(dir: string, baseRel: string = "") {
+ if (!existsSync(dir)) return;
+ for (const entry of Deno.readDirSync(dir)) {
+ // Use join() for cross-platform path separator compatibility
+ // This matches the behavior of relative() used to build sourceFiles
+ const rel = baseRel ? join(baseRel, entry.name) : entry.name;
+ if (entry.isDirectory) {
+ walkDir(join(dir, entry.name), rel);
+ } else if (!sourceFiles.has(rel)) {
+ extraFiles.push(rel);
+ }
+ }
+ }
+
+ walkDir(targetDir);
+ return extraFiles;
+}
+
+// Clean up empty directories after file removal
+function cleanupEmptyDirs(dir: string) {
+ if (!existsSync(dir)) return;
+ for (const entry of Deno.readDirSync(dir)) {
+ if (entry.isDirectory) {
+ const subdir = join(dir, entry.name);
+ cleanupEmptyDirs(subdir);
+ // Check if now empty
+ const contents = [...Deno.readDirSync(subdir)];
+ if (contents.length === 0) {
+ Deno.removeSync(subdir);
+ }
+ }
+ }
+}
diff --git a/src/project/project-shared.ts b/src/project/project-shared.ts
index 736e9745f44..083484360f4 100644
--- a/src/project/project-shared.ts
+++ b/src/project/project-shared.ts
@@ -557,9 +557,12 @@ export async function projectResolveBrand(
return project.brandCache.brand;
}
project.brandCache = {};
- let fileNames = ["_brand.yml", "_brand.yaml"].map((file) =>
- join(project.dir, file)
- );
+ let fileNames = [
+ "_brand.yml",
+ "_brand.yaml",
+ "_brand/_brand.yml",
+ "_brand/_brand.yaml",
+ ].map((file) => join(project.dir, file));
const brand = (project?.config?.brand ??
project?.config?.project.brand) as
| boolean
diff --git a/tests/smoke/use-brand/basic-brand/_brand.yml b/tests/smoke/use-brand/basic-brand/_brand.yml
new file mode 100644
index 00000000000..52e42199215
--- /dev/null
+++ b/tests/smoke/use-brand/basic-brand/_brand.yml
@@ -0,0 +1,4 @@
+meta:
+ name: Basic Test Brand
+color:
+ primary: "#007bff"
diff --git a/tests/smoke/use-brand/basic-brand/logo.png b/tests/smoke/use-brand/basic-brand/logo.png
new file mode 100644
index 00000000000..c8ba37dd5c9
Binary files /dev/null and b/tests/smoke/use-brand/basic-brand/logo.png differ
diff --git a/tests/smoke/use-brand/multi-file-brand/_brand.yml b/tests/smoke/use-brand/multi-file-brand/_brand.yml
new file mode 100644
index 00000000000..11c1107b45f
--- /dev/null
+++ b/tests/smoke/use-brand/multi-file-brand/_brand.yml
@@ -0,0 +1,4 @@
+meta:
+ name: Multi-file Test Brand
+color:
+ primary: "#28a745"
diff --git a/tests/smoke/use-brand/multi-file-brand/favicon.png b/tests/smoke/use-brand/multi-file-brand/favicon.png
new file mode 100644
index 00000000000..03ade4522c9
Binary files /dev/null and b/tests/smoke/use-brand/multi-file-brand/favicon.png differ
diff --git a/tests/smoke/use-brand/multi-file-brand/logo.png b/tests/smoke/use-brand/multi-file-brand/logo.png
new file mode 100644
index 00000000000..c8ba37dd5c9
Binary files /dev/null and b/tests/smoke/use-brand/multi-file-brand/logo.png differ
diff --git a/tests/smoke/use-brand/nested-brand/_brand.yml b/tests/smoke/use-brand/nested-brand/_brand.yml
new file mode 100644
index 00000000000..345aa0e4b15
--- /dev/null
+++ b/tests/smoke/use-brand/nested-brand/_brand.yml
@@ -0,0 +1,4 @@
+meta:
+ name: Nested Test Brand
+color:
+ primary: "#dc3545"
diff --git a/tests/smoke/use-brand/nested-brand/images/header.png b/tests/smoke/use-brand/nested-brand/images/header.png
new file mode 100644
index 00000000000..a9ec42c984e
Binary files /dev/null and b/tests/smoke/use-brand/nested-brand/images/header.png differ
diff --git a/tests/smoke/use-brand/nested-brand/images/logo.png b/tests/smoke/use-brand/nested-brand/images/logo.png
new file mode 100644
index 00000000000..c8ba37dd5c9
Binary files /dev/null and b/tests/smoke/use-brand/nested-brand/images/logo.png differ
diff --git a/tests/smoke/use/brand.test.ts b/tests/smoke/use/brand.test.ts
new file mode 100644
index 00000000000..766f26ca410
--- /dev/null
+++ b/tests/smoke/use/brand.test.ts
@@ -0,0 +1,652 @@
+import { testQuartoCmd, ExecuteOutput, Verify } from "../../test.ts";
+import { fileExists, folderExists, noErrorsOrWarnings, printsMessage } from "../../verify.ts";
+import { join, fromFileUrl, dirname } from "../../../src/deno_ral/path.ts";
+import { ensureDirSync, existsSync } from "../../../src/deno_ral/fs.ts";
+import { pathWithForwardSlashes } from "../../../src/core/path.ts";
+
+// Helper to verify files appear in the correct output sections
+function filesInSections(
+ expected: { overwrite?: string[]; create?: string[]; remove?: string[] },
+ dryRun: boolean
+): Verify {
+ return {
+ name: "files in correct sections",
+ verify: (outputs: ExecuteOutput[]) => {
+ const overwriteHeader = dryRun ? "Would overwrite:" : "Overwritten:";
+ const createHeader = dryRun ? "Would create:" : "Created:";
+ const removeHeader = dryRun ? "Would remove:" : "Removed:";
+
+ const found: { overwrite: string[]; create: string[]; remove: string[] } = {
+ overwrite: [],
+ create: [],
+ remove: [],
+ };
+ let currentSection: "overwrite" | "create" | "remove" | null = null;
+
+ for (const output of outputs) {
+ const line = output.msg;
+ if (line.includes(overwriteHeader)) {
+ currentSection = "overwrite";
+ } else if (line.includes(createHeader)) {
+ currentSection = "create";
+ } else if (line.includes(removeHeader)) {
+ currentSection = "remove";
+ } else if (currentSection && line.trim().startsWith("- ")) {
+ const filename = line.trim().slice(2); // remove "- "
+ // Normalize path separators for cross-platform compatibility
+ found[currentSection].push(pathWithForwardSlashes(filename));
+ }
+ }
+
+ // Verify expected files are in correct sections
+ for (const file of expected.overwrite ?? []) {
+ if (!found.overwrite.includes(pathWithForwardSlashes(file))) {
+ throw new Error(`Expected ${file} in overwrite section, found: [${found.overwrite.join(", ")}]`);
+ }
+ }
+ for (const file of expected.create ?? []) {
+ if (!found.create.includes(pathWithForwardSlashes(file))) {
+ throw new Error(`Expected ${file} in create section, found: [${found.create.join(", ")}]`);
+ }
+ }
+ for (const file of expected.remove ?? []) {
+ if (!found.remove.includes(pathWithForwardSlashes(file))) {
+ throw new Error(`Expected ${file} in remove section, found: [${found.remove.join(", ")}]`);
+ }
+ }
+ return Promise.resolve();
+ }
+ };
+}
+
+const tempDir = Deno.makeTempDirSync();
+const testDir = dirname(fromFileUrl(import.meta.url));
+const fixtureDir = join(testDir, "..", "use-brand");
+
+// Scenario 1: Basic brand installation
+const basicDir = join(tempDir, "basic");
+ensureDirSync(basicDir);
+testQuartoCmd(
+ "use",
+ ["brand", join(fixtureDir, "basic-brand"), "--force"],
+ [
+ noErrorsOrWarnings,
+ folderExists(join(basicDir, "_brand")),
+ fileExists(join(basicDir, "_brand", "_brand.yml")),
+ fileExists(join(basicDir, "_brand", "logo.png")),
+ ],
+ {
+ setup: () => {
+ Deno.writeTextFileSync(join(basicDir, "_quarto.yml"), "project:\n type: default\n");
+ return Promise.resolve();
+ },
+ cwd: () => basicDir,
+ teardown: () => {
+ try { Deno.removeSync(basicDir, { recursive: true }); } catch { /* ignore */ }
+ return Promise.resolve();
+ }
+ },
+ "quarto use brand - basic installation"
+);
+
+// Scenario 2: Dry-run mode
+const dryRunDir = join(tempDir, "dry-run");
+ensureDirSync(dryRunDir);
+testQuartoCmd(
+ "use",
+ ["brand", join(fixtureDir, "basic-brand"), "--dry-run"],
+ [
+ noErrorsOrWarnings,
+ printsMessage({ level: "INFO", regex: /Would create directory/ }),
+ filesInSections({ create: ["_brand.yml", "logo.png"] }, true),
+ {
+ name: "_brand directory should not exist in dry-run mode",
+ verify: () => {
+ const brandDir = join(dryRunDir, "_brand");
+ if (existsSync(brandDir)) {
+ throw new Error("_brand directory should not exist in dry-run mode");
+ }
+ return Promise.resolve();
+ }
+ }
+ ],
+ {
+ setup: () => {
+ Deno.writeTextFileSync(join(dryRunDir, "_quarto.yml"), "project:\n type: default\n");
+ return Promise.resolve();
+ },
+ cwd: () => dryRunDir,
+ teardown: () => {
+ try { Deno.removeSync(dryRunDir, { recursive: true }); } catch { /* ignore */ }
+ return Promise.resolve();
+ }
+ },
+ "quarto use brand - dry-run mode"
+);
+
+// Scenario 3: Force mode - overwrites existing, creates new, removes extra
+const forceOverwriteDir = join(tempDir, "force-overwrite");
+ensureDirSync(forceOverwriteDir);
+testQuartoCmd(
+ "use",
+ ["brand", join(fixtureDir, "basic-brand"), "--force"],
+ [
+ noErrorsOrWarnings,
+ // _brand.yml should be overwritten (exists in both)
+ {
+ name: "_brand.yml should be overwritten with new content",
+ verify: () => {
+ const content = Deno.readTextFileSync(join(forceOverwriteDir, "_brand", "_brand.yml"));
+ if (content.includes("Old Brand")) {
+ throw new Error("_brand.yml should have been overwritten");
+ }
+ if (!content.includes("Basic Test Brand")) {
+ throw new Error("_brand.yml should contain new brand content");
+ }
+ return Promise.resolve();
+ }
+ },
+ // logo.png should be created (not in target originally)
+ fileExists(join(forceOverwriteDir, "_brand", "logo.png")),
+ // unrelated.txt should be removed (not in source)
+ {
+ name: "unrelated.txt should be removed",
+ verify: () => {
+ if (existsSync(join(forceOverwriteDir, "_brand", "unrelated.txt"))) {
+ throw new Error("unrelated.txt should have been removed");
+ }
+ return Promise.resolve();
+ }
+ },
+ // Verify output sections
+ filesInSections({ overwrite: ["_brand.yml"], create: ["logo.png"], remove: ["unrelated.txt"] }, false),
+ ],
+ {
+ setup: () => {
+ Deno.writeTextFileSync(join(forceOverwriteDir, "_quarto.yml"), "project:\n type: default\n");
+ // Create existing _brand directory with files
+ const brandDir = join(forceOverwriteDir, "_brand");
+ ensureDirSync(brandDir);
+ // This file exists in source - should be overwritten
+ Deno.writeTextFileSync(join(brandDir, "_brand.yml"), "meta:\n name: Old Brand\n");
+ // This file does NOT exist in source - should be preserved
+ Deno.writeTextFileSync(join(brandDir, "unrelated.txt"), "keep me");
+ return Promise.resolve();
+ },
+ cwd: () => forceOverwriteDir,
+ teardown: () => {
+ try { Deno.removeSync(forceOverwriteDir, { recursive: true }); } catch { /* ignore */ }
+ return Promise.resolve();
+ }
+ },
+ "quarto use brand - force overwrites existing, creates new, removes extra"
+);
+
+// Scenario 4: Dry-run reports "Would overwrite" vs "Would create" vs "Would remove" correctly
+const dryRunOverwriteDir = join(tempDir, "dry-run-overwrite");
+ensureDirSync(dryRunOverwriteDir);
+testQuartoCmd(
+ "use",
+ ["brand", join(fixtureDir, "basic-brand"), "--dry-run"],
+ [
+ noErrorsOrWarnings,
+ // _brand.yml exists - should be in overwrite section
+ // logo.png doesn't exist - should be in create section
+ // extra.txt exists only in target - should be in remove section
+ filesInSections({
+ overwrite: ["_brand.yml"],
+ create: ["logo.png"],
+ remove: ["extra.txt"]
+ }, true),
+ // Verify _brand.yml was NOT modified
+ {
+ name: "_brand.yml should not be modified in dry-run",
+ verify: () => {
+ const content = Deno.readTextFileSync(join(dryRunOverwriteDir, "_brand", "_brand.yml"));
+ if (!content.includes("Old Brand")) {
+ throw new Error("_brand.yml should not be modified in dry-run mode");
+ }
+ return Promise.resolve();
+ }
+ },
+ // Verify logo.png was NOT created
+ {
+ name: "logo.png should not be created in dry-run",
+ verify: () => {
+ if (existsSync(join(dryRunOverwriteDir, "_brand", "logo.png"))) {
+ throw new Error("logo.png should not be created in dry-run mode");
+ }
+ return Promise.resolve();
+ }
+ },
+ // Verify extra.txt was NOT removed
+ {
+ name: "extra.txt should not be removed in dry-run",
+ verify: () => {
+ if (!existsSync(join(dryRunOverwriteDir, "_brand", "extra.txt"))) {
+ throw new Error("extra.txt should not be removed in dry-run mode");
+ }
+ return Promise.resolve();
+ }
+ },
+ ],
+ {
+ setup: () => {
+ Deno.writeTextFileSync(join(dryRunOverwriteDir, "_quarto.yml"), "project:\n type: default\n");
+ // Create existing _brand directory with _brand.yml and extra.txt (not logo.png)
+ const brandDir = join(dryRunOverwriteDir, "_brand");
+ ensureDirSync(brandDir);
+ Deno.writeTextFileSync(join(brandDir, "_brand.yml"), "meta:\n name: Old Brand\n");
+ Deno.writeTextFileSync(join(brandDir, "extra.txt"), "extra file not in source");
+ return Promise.resolve();
+ },
+ cwd: () => dryRunOverwriteDir,
+ teardown: () => {
+ try { Deno.removeSync(dryRunOverwriteDir, { recursive: true }); } catch { /* ignore */ }
+ return Promise.resolve();
+ }
+ },
+ "quarto use brand - dry-run reports overwrite vs create vs remove correctly"
+);
+
+// Scenario 5: Error - force and dry-run together
+const errorFlagDir = join(tempDir, "error-flags");
+ensureDirSync(errorFlagDir);
+testQuartoCmd(
+ "use",
+ ["brand", join(fixtureDir, "basic-brand"), "--force", "--dry-run"],
+ [
+ printsMessage({ level: "ERROR", regex: /Cannot use --force and --dry-run together/ }),
+ ],
+ {
+ setup: () => {
+ Deno.writeTextFileSync(join(errorFlagDir, "_quarto.yml"), "project:\n type: default\n");
+ return Promise.resolve();
+ },
+ cwd: () => errorFlagDir,
+ teardown: () => {
+ try { Deno.removeSync(errorFlagDir, { recursive: true }); } catch { /* ignore */ }
+ return Promise.resolve();
+ }
+ },
+ "quarto use brand - error on --force --dry-run"
+);
+
+// Scenario 6: Multi-file brand installation
+const multiFileDir = join(tempDir, "multi-file");
+ensureDirSync(multiFileDir);
+testQuartoCmd(
+ "use",
+ ["brand", join(fixtureDir, "multi-file-brand"), "--force"],
+ [
+ noErrorsOrWarnings,
+ folderExists(join(multiFileDir, "_brand")),
+ fileExists(join(multiFileDir, "_brand", "_brand.yml")),
+ fileExists(join(multiFileDir, "_brand", "logo.png")),
+ fileExists(join(multiFileDir, "_brand", "favicon.png")),
+ ],
+ {
+ setup: () => {
+ Deno.writeTextFileSync(join(multiFileDir, "_quarto.yml"), "project:\n type: default\n");
+ return Promise.resolve();
+ },
+ cwd: () => multiFileDir,
+ teardown: () => {
+ try { Deno.removeSync(multiFileDir, { recursive: true }); } catch { /* ignore */ }
+ return Promise.resolve();
+ }
+ },
+ "quarto use brand - multi-file installation"
+);
+
+// Scenario 7: Nested directory structure preserved
+const nestedDir = join(tempDir, "nested");
+ensureDirSync(nestedDir);
+testQuartoCmd(
+ "use",
+ ["brand", join(fixtureDir, "nested-brand"), "--force"],
+ [
+ noErrorsOrWarnings,
+ folderExists(join(nestedDir, "_brand")),
+ fileExists(join(nestedDir, "_brand", "_brand.yml")),
+ folderExists(join(nestedDir, "_brand", "images")),
+ fileExists(join(nestedDir, "_brand", "images", "logo.png")),
+ fileExists(join(nestedDir, "_brand", "images", "header.png")),
+ ],
+ {
+ setup: () => {
+ Deno.writeTextFileSync(join(nestedDir, "_quarto.yml"), "project:\n type: default\n");
+ return Promise.resolve();
+ },
+ cwd: () => nestedDir,
+ teardown: () => {
+ try { Deno.removeSync(nestedDir, { recursive: true }); } catch { /* ignore */ }
+ return Promise.resolve();
+ }
+ },
+ "quarto use brand - nested directory structure"
+);
+
+// Scenario 8: Error - no project directory
+const noProjectDir = join(tempDir, "no-project");
+ensureDirSync(noProjectDir);
+testQuartoCmd(
+ "use",
+ ["brand", join(fixtureDir, "basic-brand"), "--force"],
+ [
+ printsMessage({ level: "ERROR", regex: /Could not find project dir/ }),
+ ],
+ {
+ setup: () => {
+ // No _quarto.yml created - this should cause an error
+ return Promise.resolve();
+ },
+ cwd: () => noProjectDir,
+ teardown: () => {
+ try { Deno.removeSync(noProjectDir, { recursive: true }); } catch { /* ignore */ }
+ return Promise.resolve();
+ }
+ },
+ "quarto use brand - error on no project"
+);
+
+// Scenario 9: Nested directory - overwrite files in subdirectories, remove extra
+const nestedOverwriteDir = join(tempDir, "nested-overwrite");
+ensureDirSync(nestedOverwriteDir);
+testQuartoCmd(
+ "use",
+ ["brand", join(fixtureDir, "nested-brand"), "--force"],
+ [
+ noErrorsOrWarnings,
+ // images/logo.png should be overwritten (exists in both)
+ {
+ name: "images/logo.png should be overwritten",
+ verify: () => {
+ const stats = Deno.statSync(join(nestedOverwriteDir, "_brand", "images", "logo.png"));
+ // Original was 10 bytes ("old logo\n"), new one is 1862 bytes
+ if (stats.size < 100) {
+ throw new Error("images/logo.png should have been overwritten with larger file");
+ }
+ return Promise.resolve();
+ }
+ },
+ // images/header.png should be created (not in target originally)
+ fileExists(join(nestedOverwriteDir, "_brand", "images", "header.png")),
+ // images/unrelated.png should be removed (not in source)
+ {
+ name: "images/unrelated.png should be removed",
+ verify: () => {
+ if (existsSync(join(nestedOverwriteDir, "_brand", "images", "unrelated.png"))) {
+ throw new Error("images/unrelated.png should have been removed");
+ }
+ return Promise.resolve();
+ }
+ },
+ // Verify output sections (_brand.yml is created since not in target setup)
+ filesInSections({
+ overwrite: ["images/logo.png"],
+ create: ["_brand.yml", "images/header.png"],
+ remove: ["images/unrelated.png"]
+ }, false),
+ ],
+ {
+ setup: () => {
+ Deno.writeTextFileSync(join(nestedOverwriteDir, "_quarto.yml"), "project:\n type: default\n");
+ // Create existing _brand/images directory with files
+ const imagesDir = join(nestedOverwriteDir, "_brand", "images");
+ ensureDirSync(imagesDir);
+ // This file exists in source - should be overwritten
+ Deno.writeTextFileSync(join(imagesDir, "logo.png"), "old logo\n");
+ // This file does NOT exist in source - should be preserved
+ Deno.writeTextFileSync(join(imagesDir, "unrelated.png"), "keep me nested");
+ return Promise.resolve();
+ },
+ cwd: () => nestedOverwriteDir,
+ teardown: () => {
+ try { Deno.removeSync(nestedOverwriteDir, { recursive: true }); } catch { /* ignore */ }
+ return Promise.resolve();
+ }
+ },
+ "quarto use brand - nested overwrite, create, remove in subdirectories"
+);
+
+// Scenario 10: Dry-run with nested directories - reports correctly
+const dryRunNestedDir = join(tempDir, "dry-run-nested");
+ensureDirSync(dryRunNestedDir);
+testQuartoCmd(
+ "use",
+ ["brand", join(fixtureDir, "nested-brand"), "--dry-run"],
+ [
+ noErrorsOrWarnings,
+ // images/logo.png and _brand.yml exist - should be in overwrite section
+ // images/header.png doesn't exist - should be in create section
+ filesInSections({
+ overwrite: ["_brand.yml", "images/logo.png"],
+ create: ["images/header.png"]
+ }, true),
+ // Verify images/logo.png was NOT modified
+ {
+ name: "images/logo.png should not be modified in dry-run",
+ verify: () => {
+ const content = Deno.readTextFileSync(join(dryRunNestedDir, "_brand", "images", "logo.png"));
+ if (content !== "old logo\n") {
+ throw new Error("images/logo.png should not be modified in dry-run mode");
+ }
+ return Promise.resolve();
+ }
+ },
+ // Verify images/header.png was NOT created
+ {
+ name: "images/header.png should not be created in dry-run",
+ verify: () => {
+ if (existsSync(join(dryRunNestedDir, "_brand", "images", "header.png"))) {
+ throw new Error("images/header.png should not be created in dry-run mode");
+ }
+ return Promise.resolve();
+ }
+ },
+ ],
+ {
+ setup: () => {
+ Deno.writeTextFileSync(join(dryRunNestedDir, "_quarto.yml"), "project:\n type: default\n");
+ // Create existing _brand/images directory with only logo.png (not header.png)
+ const imagesDir = join(dryRunNestedDir, "_brand", "images");
+ ensureDirSync(imagesDir);
+ Deno.writeTextFileSync(join(imagesDir, "logo.png"), "old logo\n");
+ // Also create _brand.yml so we're only testing nested behavior
+ Deno.writeTextFileSync(join(dryRunNestedDir, "_brand", "_brand.yml"), "meta:\n name: Old\n");
+ return Promise.resolve();
+ },
+ cwd: () => dryRunNestedDir,
+ teardown: () => {
+ try { Deno.removeSync(dryRunNestedDir, { recursive: true }); } catch { /* ignore */ }
+ return Promise.resolve();
+ }
+ },
+ "quarto use brand - dry-run reports nested overwrite vs create correctly"
+);
+
+// Scenario 11: Nested directory created when doesn't exist
+const nestedNewSubdirDir = join(tempDir, "nested-new-subdir");
+ensureDirSync(nestedNewSubdirDir);
+testQuartoCmd(
+ "use",
+ ["brand", join(fixtureDir, "nested-brand"), "--force"],
+ [
+ noErrorsOrWarnings,
+ // _brand/ exists but images/ doesn't - should be created
+ folderExists(join(nestedNewSubdirDir, "_brand", "images")),
+ fileExists(join(nestedNewSubdirDir, "_brand", "images", "logo.png")),
+ fileExists(join(nestedNewSubdirDir, "_brand", "images", "header.png")),
+ // existing file at root should be overwritten
+ {
+ name: "_brand.yml should be overwritten",
+ verify: () => {
+ const content = Deno.readTextFileSync(join(nestedNewSubdirDir, "_brand", "_brand.yml"));
+ if (content.includes("Old Brand")) {
+ throw new Error("_brand.yml should have been overwritten");
+ }
+ return Promise.resolve();
+ }
+ },
+ ],
+ {
+ setup: () => {
+ Deno.writeTextFileSync(join(nestedNewSubdirDir, "_quarto.yml"), "project:\n type: default\n");
+ // Create _brand/ but NOT images/ subdirectory
+ const brandDir = join(nestedNewSubdirDir, "_brand");
+ ensureDirSync(brandDir);
+ Deno.writeTextFileSync(join(brandDir, "_brand.yml"), "meta:\n name: Old Brand\n");
+ return Promise.resolve();
+ },
+ cwd: () => nestedNewSubdirDir,
+ teardown: () => {
+ try { Deno.removeSync(nestedNewSubdirDir, { recursive: true }); } catch { /* ignore */ }
+ return Promise.resolve();
+ }
+ },
+ "quarto use brand - creates nested subdirectory when _brand exists but subdir doesn't"
+);
+
+// Scenario 12: Dry-run reports new subdirectory creation
+const dryRunNewSubdirDir = join(tempDir, "dry-run-new-subdir");
+ensureDirSync(dryRunNewSubdirDir);
+testQuartoCmd(
+ "use",
+ ["brand", join(fixtureDir, "nested-brand"), "--dry-run"],
+ [
+ noErrorsOrWarnings,
+ // Should NOT report "Would create directory" for _brand/ (already exists)
+ printsMessage({ level: "INFO", regex: /Would create directory/, negate: true }),
+ // _brand.yml exists - should be in overwrite section
+ // images/* files don't exist - should be in create section
+ filesInSections({
+ overwrite: ["_brand.yml"],
+ create: ["images/logo.png", "images/header.png"]
+ }, true),
+ // Verify images/ directory was NOT created
+ {
+ name: "images/ directory should not be created in dry-run",
+ verify: () => {
+ if (existsSync(join(dryRunNewSubdirDir, "_brand", "images"))) {
+ throw new Error("images/ directory should not be created in dry-run mode");
+ }
+ return Promise.resolve();
+ }
+ },
+ ],
+ {
+ setup: () => {
+ Deno.writeTextFileSync(join(dryRunNewSubdirDir, "_quarto.yml"), "project:\n type: default\n");
+ // Create _brand/ but NOT images/ subdirectory
+ const brandDir = join(dryRunNewSubdirDir, "_brand");
+ ensureDirSync(brandDir);
+ Deno.writeTextFileSync(join(brandDir, "_brand.yml"), "meta:\n name: Old\n");
+ return Promise.resolve();
+ },
+ cwd: () => dryRunNewSubdirDir,
+ teardown: () => {
+ try { Deno.removeSync(dryRunNewSubdirDir, { recursive: true }); } catch { /* ignore */ }
+ return Promise.resolve();
+ }
+ },
+ "quarto use brand - dry-run when _brand exists but nested subdir doesn't"
+);
+
+// Scenario 13: Empty directories are cleaned up after file removal
+const emptyDirCleanupDir = join(tempDir, "empty-dir-cleanup");
+ensureDirSync(emptyDirCleanupDir);
+testQuartoCmd(
+ "use",
+ ["brand", join(fixtureDir, "basic-brand"), "--force"],
+ [
+ noErrorsOrWarnings,
+ // extras/orphan.txt should be removed
+ {
+ name: "extras/orphan.txt should be removed",
+ verify: () => {
+ if (existsSync(join(emptyDirCleanupDir, "_brand", "extras", "orphan.txt"))) {
+ throw new Error("extras/orphan.txt should have been removed");
+ }
+ return Promise.resolve();
+ }
+ },
+ // extras/ directory should be cleaned up (was empty after removal)
+ {
+ name: "extras/ directory should be cleaned up",
+ verify: () => {
+ if (existsSync(join(emptyDirCleanupDir, "_brand", "extras"))) {
+ throw new Error("extras/ directory should have been cleaned up");
+ }
+ return Promise.resolve();
+ }
+ },
+ // Verify output shows removal
+ filesInSections({ remove: ["extras/orphan.txt"] }, false),
+ ],
+ {
+ setup: () => {
+ Deno.writeTextFileSync(join(emptyDirCleanupDir, "_quarto.yml"), "project:\n type: default\n");
+ // Create _brand/extras/ with a file not in source
+ const extrasDir = join(emptyDirCleanupDir, "_brand", "extras");
+ ensureDirSync(extrasDir);
+ Deno.writeTextFileSync(join(extrasDir, "orphan.txt"), "this file will be removed");
+ return Promise.resolve();
+ },
+ cwd: () => emptyDirCleanupDir,
+ teardown: () => {
+ try { Deno.removeSync(emptyDirCleanupDir, { recursive: true }); } catch { /* ignore */ }
+ return Promise.resolve();
+ }
+ },
+ "quarto use brand - empty directories cleaned up after file removal"
+);
+
+// Scenario 14: Deeply nested directories are recursively cleaned up
+const deepNestedCleanupDir = join(tempDir, "deep-nested-cleanup");
+ensureDirSync(deepNestedCleanupDir);
+testQuartoCmd(
+ "use",
+ ["brand", join(fixtureDir, "basic-brand"), "--force"],
+ [
+ noErrorsOrWarnings,
+ // deep/nested/path/orphan.txt should be removed
+ {
+ name: "deep/nested/path/orphan.txt should be removed",
+ verify: () => {
+ if (existsSync(join(deepNestedCleanupDir, "_brand", "deep", "nested", "path", "orphan.txt"))) {
+ throw new Error("deep/nested/path/orphan.txt should have been removed");
+ }
+ return Promise.resolve();
+ }
+ },
+ // All empty parent directories should be cleaned up recursively
+ {
+ name: "deep/ directory tree should be fully cleaned up",
+ verify: () => {
+ if (existsSync(join(deepNestedCleanupDir, "_brand", "deep"))) {
+ throw new Error("deep/ directory should have been cleaned up recursively");
+ }
+ return Promise.resolve();
+ }
+ },
+ // Verify output shows removal with full path
+ filesInSections({ remove: ["deep/nested/path/orphan.txt"] }, false),
+ ],
+ {
+ setup: () => {
+ Deno.writeTextFileSync(join(deepNestedCleanupDir, "_quarto.yml"), "project:\n type: default\n");
+ // Create _brand/deep/nested/path/ with a file not in source
+ const deepDir = join(deepNestedCleanupDir, "_brand", "deep", "nested", "path");
+ ensureDirSync(deepDir);
+ Deno.writeTextFileSync(join(deepDir, "orphan.txt"), "deeply nested orphan");
+ return Promise.resolve();
+ },
+ cwd: () => deepNestedCleanupDir,
+ teardown: () => {
+ try { Deno.removeSync(deepNestedCleanupDir, { recursive: true }); } catch { /* ignore */ }
+ return Promise.resolve();
+ }
+ },
+ "quarto use brand - deeply nested directories recursively cleaned up"
+);
diff --git a/tools/subcommand-history.ts b/tools/subcommand-history.ts
new file mode 100644
index 00000000000..48d8a07f377
--- /dev/null
+++ b/tools/subcommand-history.ts
@@ -0,0 +1,570 @@
+/**
+ * subcommand-history.ts
+ *
+ * Analyzes git history of a cliffy-based CLI to produce a timeline
+ * of when commands/subcommands were introduced and removed.
+ *
+ * Usage: quarto run tools/subcommand-history.ts
+ *
+ * ## What this tool detects
+ *
+ * 1. **Top-level commands** - directories under `src/command/` (e.g., `render/`, `publish/`)
+ * 2. **Cliffy subcommands** - registered via `.command()` API
+ * 3. **Publish providers** - directories under `src/publish/` (e.g., `netlify/`, `gh-pages/`)
+ *
+ * ## What this tool cannot detect
+ *
+ * Commands that parse arguments internally rather than using cliffy's subcommand system.
+ *
+ * **Cliffy `.command()` registration** (detected):
+ * ```typescript
+ * // In call/cmd.ts
+ * export const callCommand = new Command()
+ * .command("engine", engineCommand) // ← Creates "quarto call engine"
+ * .command("build-ts-extension", ...) // ← Creates "quarto call build-ts-extension"
+ * ```
+ * Cliffy handles routing to these subcommands automatically.
+ *
+ * **Internal argument parsing** (not detected):
+ * ```typescript
+ * // In install/cmd.ts
+ * export const installCommand = new Command()
+ * .arguments("[target...]") // ← Just takes arguments
+ * .action(async (options, ...target) => {
+ * if (target === "tinytex") { ... } // ← Code checks the value manually
+ * if (target === "chromium") { ... }
+ * });
+ * ```
+ * Here `tinytex` isn't a registered subcommand - it's just an argument value the code
+ * checks for.
+ *
+ * Both look the same to users (`quarto call engine` vs `quarto install tinytex`), but
+ * they're implemented differently. This tool can only detect the first pattern by
+ * searching for `.command("..."` in the code.
+ */
+
+import { join, relative } from "https://deno.land/std/path/mod.ts";
+
+interface CommandEntry {
+ date: string;
+ hash: string;
+ command: string;
+ message: string;
+ removed?: boolean;
+ parent?: string; // for subcommands, the parent command name
+}
+
+// Execute git command and return stdout
+async function runGit(args: string[], cwd?: string): Promise {
+ const cmd = new Deno.Command("git", {
+ args,
+ cwd,
+ stdout: "piped",
+ stderr: "piped",
+ });
+ const { stdout, stderr, success } = await cmd.output();
+ if (!success) {
+ const errText = new TextDecoder().decode(stderr);
+ throw new Error(`git ${args.join(" ")} failed: ${errText}`);
+ }
+ return new TextDecoder().decode(stdout).trim();
+}
+
+// Find the git repository root
+async function findGitRoot(): Promise {
+ return await runGit(["rev-parse", "--show-toplevel"]);
+}
+
+// Parse git log output line: "YYYY-MM-DD hash message"
+function parseGitLogLine(line: string): { date: string; hash: string; message: string } | null {
+ const match = line.match(/^(\d{4}-\d{2}-\d{2})\s+([a-f0-9]+)\s+(.*)$/);
+ if (!match) return null;
+ return { date: match[1], hash: match[2], message: match[3] };
+}
+
+// Find when a directory was first added to git
+async function findDirectoryIntroduction(
+ dirPath: string,
+ gitRoot: string
+): Promise {
+ const relPath = relative(gitRoot, dirPath);
+ try {
+ // Get the oldest commit that added files to this directory
+ const output = await runGit(
+ ["log", "--diff-filter=A", "--format=%as %h %s", "--reverse", "--", relPath],
+ gitRoot
+ );
+ const lines = output.split("\n").filter((l) => l.trim());
+ if (lines.length === 0) return null;
+
+ const parsed = parseGitLogLine(lines[0]);
+ if (!parsed) return null;
+
+ const commandName = dirPath.split("/").pop() || "";
+ return {
+ date: parsed.date,
+ hash: parsed.hash,
+ command: commandName,
+ message: parsed.message,
+ };
+ } catch {
+ return null;
+ }
+}
+
+// Find when a string pattern was introduced (oldest commit containing it)
+async function findStringIntroduction(
+ searchStr: string,
+ path: string,
+ gitRoot: string
+): Promise<{ date: string; hash: string; message: string } | null> {
+ const relPath = relative(gitRoot, path);
+ try {
+ // -S finds commits where the string count changed (added or removed)
+ // --reverse gives oldest first
+ const output = await runGit(
+ ["log", "-S", searchStr, "--format=%as %h %s", "--reverse", "--", relPath],
+ gitRoot
+ );
+ const lines = output.split("\n").filter((l) => l.trim());
+ if (lines.length === 0) return null;
+
+ return parseGitLogLine(lines[0]);
+ } catch {
+ return null;
+ }
+}
+
+// Find when a string pattern was removed (most recent commit where it was removed)
+async function findStringRemoval(
+ searchStr: string,
+ path: string,
+ gitRoot: string
+): Promise<{ date: string; hash: string; message: string } | null> {
+ const relPath = relative(gitRoot, path);
+ try {
+ // Get most recent commit that changed this string (without --reverse, newest first)
+ const output = await runGit(
+ ["log", "-S", searchStr, "--format=%as %h %s", "--", relPath],
+ gitRoot
+ );
+ const lines = output.split("\n").filter((l) => l.trim());
+ if (lines.length === 0) return null;
+
+ // The most recent commit is the removal
+ return parseGitLogLine(lines[0]);
+ } catch {
+ return null;
+ }
+}
+
+// Cliffy built-in commands that are inherited by all commands
+const CLIFFY_BUILTINS = new Set(["help", "completions"]);
+
+// Extract command names from .command("name" patterns
+function extractCliffyCommandNames(content: string): string[] {
+ const regex = /\.command\s*\(\s*["']([^"']+)["']/g;
+ const names: string[] = [];
+ let match;
+ while ((match = regex.exec(content)) !== null) {
+ // Extract just the command name (before any space for arguments like "install ")
+ const fullName = match[1];
+ const cmdName = fullName.split(/\s+/)[0];
+ // Skip cliffy built-in commands
+ if (!CLIFFY_BUILTINS.has(cmdName)) {
+ names.push(cmdName);
+ }
+ }
+ return names;
+}
+
+// Get all directories in a path
+async function getDirectories(path: string): Promise {
+ const dirs: string[] = [];
+ try {
+ for await (const entry of Deno.readDir(path)) {
+ if (entry.isDirectory) {
+ dirs.push(entry.name);
+ }
+ }
+ } catch {
+ // Directory doesn't exist
+ }
+ return dirs.sort();
+}
+
+// Scan for top-level commands (directories in src/command/)
+async function scanTopLevelCommands(
+ commandDir: string,
+ gitRoot: string
+): Promise {
+ const entries: CommandEntry[] = [];
+ const dirs = await getDirectories(commandDir);
+
+ for (const dir of dirs) {
+ const dirPath = join(commandDir, dir);
+ const entry = await findDirectoryIntroduction(dirPath, gitRoot);
+ if (entry) {
+ entries.push(entry);
+ }
+ }
+
+ return entries.sort((a, b) => a.date.localeCompare(b.date));
+}
+
+// Read all TypeScript files in a directory recursively
+async function readTsFiles(dir: string): Promise