backlog-md/src/cli.ts

3140 lines
101 KiB
JavaScript

#!/usr/bin/env node
import { basename, join } from "node:path";
import { stdin as input, stdout as output } from "node:process";
import { createInterface } from "node:readline/promises";
import { $, spawn } from "bun";
import { Command } from "commander";
import prompts from "prompts";
import { runAdvancedConfigWizard } from "./commands/advanced-config-wizard.ts";
import { type CompletionInstallResult, installCompletion, registerCompletionCommand } from "./commands/completion.ts";
import { configureAdvancedSettings } from "./commands/configure-advanced-settings.ts";
import { registerMcpCommand } from "./commands/mcp.ts";
import { DEFAULT_DIRECTORIES } from "./constants/index.ts";
import { initializeProject } from "./core/init.ts";
import { computeSequences } from "./core/sequences.ts";
import { formatTaskPlainText } from "./formatters/task-plain-text.ts";
import {
type AgentInstructionFile,
addAgentInstructions,
Core,
type EnsureMcpGuidelinesResult,
ensureMcpGuidelines,
exportKanbanBoardToFile,
initializeGitRepository,
installClaudeAgent,
isGitRepository,
updateReadmeWithBoard,
} from "./index.ts";
import {
type BacklogConfig,
type Decision,
type DecisionSearchResult,
type Document as DocType,
type DocumentSearchResult,
isLocalEditableTask,
type SearchPriorityFilter,
type SearchResult,
type SearchResultType,
type Task,
type TaskListFilter,
type TaskSearchResult,
} from "./types/index.ts";
import type { TaskEditArgs } from "./types/task-edit-args.ts";
import { genericSelectList } from "./ui/components/generic-list.ts";
import { createLoadingScreen } from "./ui/loading.ts";
import { viewTaskEnhanced } from "./ui/task-viewer-with-search.ts";
import { promptText, scrollableViewer } from "./ui/tui.ts";
import { type AgentSelectionValue, PLACEHOLDER_AGENT_VALUE, processAgentSelection } from "./utils/agent-selection.ts";
import { formatValidStatuses, getCanonicalStatus, getValidStatuses } from "./utils/status.ts";
import { parsePositiveIndexList, processAcceptanceCriteriaOptions, toStringArray } from "./utils/task-builders.ts";
import { buildTaskUpdateInput } from "./utils/task-edit-builder.ts";
import { normalizeTaskId, taskIdsEqual } from "./utils/task-path.ts";
import { sortTasks } from "./utils/task-sorting.ts";
import { getVersion } from "./utils/version.ts";
type IntegrationMode = "mcp" | "cli" | "none";
function normalizeIntegrationOption(value: string): IntegrationMode | null {
const normalized = value.trim().toLowerCase();
if (
normalized === "mcp" ||
normalized === "connector" ||
normalized === "model-context-protocol" ||
normalized === "model_context_protocol"
) {
return "mcp";
}
if (
normalized === "cli" ||
normalized === "legacy" ||
normalized === "commands" ||
normalized === "command" ||
normalized === "instructions" ||
normalized === "instruction" ||
normalized === "agent" ||
normalized === "agents"
) {
return "cli";
}
if (
normalized === "none" ||
normalized === "skip" ||
normalized === "manual" ||
normalized === "later" ||
normalized === "no" ||
normalized === "off"
) {
return "none";
}
return null;
}
// Always use "backlog" as the global MCP server name so fallback mode works when the project isn't initialized.
const MCP_SERVER_NAME = "backlog";
const MCP_CLIENT_INSTRUCTION_MAP: Record<string, AgentInstructionFile> = {
claude: "CLAUDE.md",
codex: "AGENTS.md",
gemini: "GEMINI.md",
guide: "AGENTS.md",
};
async function openUrlInBrowser(url: string): Promise<void> {
let cmd: string[];
if (process.platform === "darwin") {
cmd = ["open", url];
} else if (process.platform === "win32") {
cmd = ["cmd", "/c", "start", "", url];
} else {
cmd = ["xdg-open", url];
}
try {
await $`${cmd}`.quiet();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(` ⚠️ Unable to open browser automatically (${message}). Please visit ${url}`);
}
}
async function runMcpClientCommand(label: string, command: string, args: string[]): Promise<string> {
console.log(` Configuring ${label}...`);
try {
const child = spawn({
cmd: [command, ...args],
stdout: "inherit",
stderr: "inherit",
});
await child.exited;
console.log(` ✓ Added Backlog MCP server to ${label}`);
return label;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(` ⚠️ Unable to configure ${label} automatically (${message}).`);
console.warn(` Run manually: ${command} ${args.join(" ")}`);
return `${label} (manual setup required)`;
}
}
// Helper function for accumulating multiple CLI option values
function createMultiValueAccumulator() {
return (value: string, previous: string | string[]) => {
const soFar = Array.isArray(previous) ? previous : previous ? [previous] : [];
return [...soFar, value];
};
}
// Helper function to process multiple AC operations
/**
* Processes --ac and --acceptance-criteria options to extract acceptance criteria
* Handles both single values and arrays from multi-value accumulators
*/
function getDefaultAdvancedConfig(existingConfig?: BacklogConfig | null): Partial<BacklogConfig> {
return {
checkActiveBranches: existingConfig?.checkActiveBranches ?? true,
remoteOperations: existingConfig?.remoteOperations ?? true,
activeBranchDays: existingConfig?.activeBranchDays ?? 30,
bypassGitHooks: existingConfig?.bypassGitHooks ?? false,
autoCommit: existingConfig?.autoCommit ?? false,
zeroPaddedIds: existingConfig?.zeroPaddedIds,
defaultEditor: existingConfig?.defaultEditor,
defaultPort: existingConfig?.defaultPort ?? 6420,
autoOpenBrowser: existingConfig?.autoOpenBrowser ?? true,
};
}
// Windows color fix
if (process.platform === "win32") {
const term = process.env.TERM;
if (!term || /^(xterm|dumb|ansi|vt100)$/i.test(term)) {
process.env.TERM = "xterm-256color";
}
}
// Temporarily isolate BUN_OPTIONS during CLI parsing to prevent conflicts
// Save the original value so it's available for subsequent commands
const originalBunOptions = process.env.BUN_OPTIONS;
if (process.env.BUN_OPTIONS) {
delete process.env.BUN_OPTIONS;
}
// Get version from package.json
const version = await getVersion();
// Bare-run splash screen handling (before Commander parses commands)
// Show a welcome splash when invoked without subcommands, unless help/version requested
try {
let rawArgs = process.argv.slice(2);
// Some package managers (e.g., Bun global shims) may inject the resolved
// binary path as the first non-node argument. Strip it if detected.
if (rawArgs.length > 0) {
const first = rawArgs[0];
if (
typeof first === "string" &&
/node_modules[\\/]+backlog\.md-(darwin|linux|windows)-[^\\/]+[\\/]+backlog(\.exe)?$/.test(first)
) {
rawArgs = rawArgs.slice(1);
}
}
const wantsHelp = rawArgs.includes("-h") || rawArgs.includes("--help");
const wantsVersion = rawArgs.includes("-v") || rawArgs.includes("--version");
// Treat only --plain as allowed flag for splash; any other args means use normal CLI parsing
const onlyPlain = rawArgs.length === 1 && rawArgs[0] === "--plain";
const isBare = rawArgs.length === 0 || onlyPlain;
if (isBare && !wantsHelp && !wantsVersion) {
const isTTY = !!process.stdout.isTTY;
const forcePlain = rawArgs.includes("--plain");
const noColor = !!process.env.NO_COLOR || !isTTY;
let initialized = false;
try {
const core = new Core(process.cwd());
const cfg = await core.filesystem.loadConfig();
initialized = !!cfg;
} catch {
initialized = false;
}
const { printSplash } = await import("./ui/splash.ts");
// Auto-fallback to plain when non-TTY, or explicit --plain, or if terminal very narrow
const termWidth = Math.max(0, Number(process.stdout.columns || 0));
const autoPlain = !isTTY || (termWidth > 0 && termWidth < 60);
await printSplash({
version,
initialized,
plain: forcePlain || autoPlain,
color: !noColor,
});
// Ensure we don't enter Commander command parsing
process.exit(0);
}
} catch {
// Fall through to normal CLI parsing on any splash error
}
// Global config migration - run before any command processing
// Only run if we're in a backlog project (skip for init, help, version)
const shouldRunMigration =
!process.argv.includes("init") &&
!process.argv.includes("--help") &&
!process.argv.includes("-h") &&
!process.argv.includes("--version") &&
!process.argv.includes("-v") &&
process.argv.length > 2; // Ensure we have actual commands
if (shouldRunMigration) {
try {
const cwd = process.cwd();
const core = new Core(cwd);
// Only migrate if config already exists (project is already initialized)
const config = await core.filesystem.loadConfig();
if (config) {
await core.ensureConfigMigrated();
}
} catch (_error) {
// Silently ignore migration errors - project might not be initialized yet
}
}
const program = new Command();
program
.name("backlog")
.description("Backlog.md - Project management CLI")
.version(version, "-v, --version", "display version number");
program
.command("init [projectName]")
.description("initialize backlog project in the current repository")
.option(
"--agent-instructions <instructions>",
"comma-separated agent instructions to create. Valid: claude, agents, gemini, copilot, cursor (alias of agents), none. Use 'none' to skip; when combined with others, 'none' is ignored.",
)
.option("--check-branches <boolean>", "check task states across active branches (default: true)")
.option("--include-remote <boolean>", "include remote branches when checking (default: true)")
.option("--branch-days <number>", "days to consider branch active (default: 30)")
.option("--bypass-git-hooks <boolean>", "bypass git hooks when committing (default: false)")
.option("--zero-padded-ids <number>", "number of digits for zero-padding IDs (0 to disable)")
.option("--default-editor <editor>", "default editor command")
.option("--web-port <number>", "default web UI port (default: 6420)")
.option("--auto-open-browser <boolean>", "auto-open browser for web UI (default: true)")
.option("--install-claude-agent <boolean>", "install Claude Code agent (default: false)")
.option("--integration-mode <mode>", "choose how AI tools connect to Backlog.md (mcp, cli, or none)")
.option("--defaults", "use default values for all prompts")
.action(
async (
projectName: string | undefined,
options: {
agentInstructions?: string;
checkBranches?: string;
includeRemote?: string;
branchDays?: string;
bypassGitHooks?: string;
zeroPaddedIds?: string;
defaultEditor?: string;
webPort?: string;
autoOpenBrowser?: string;
installClaudeAgent?: string;
integrationMode?: string;
defaults?: boolean;
},
) => {
try {
const cwd = process.cwd();
const isRepo = await isGitRepository(cwd);
if (!isRepo) {
const rl = createInterface({ input, output });
const answer = (await rl.question("No git repository found. Initialize one here? [y/N] "))
.trim()
.toLowerCase();
rl.close();
if (answer.startsWith("y")) {
await initializeGitRepository(cwd);
} else {
console.log("Aborting initialization.");
process.exit(1);
}
}
const core = new Core(cwd);
// Check if project is already initialized and load existing config
const existingConfig = await core.filesystem.loadConfig();
const isReInitialization = !!existingConfig;
if (isReInitialization) {
console.log(
"Existing backlog project detected. Current configuration will be preserved where not specified.",
);
}
// Helper function to parse boolean strings
const parseBoolean = (value: string | undefined, defaultValue: boolean): boolean => {
if (value === undefined) return defaultValue;
return value.toLowerCase() === "true" || value === "1";
};
// Helper function to parse number strings
const parseNumber = (value: string | undefined, defaultValue: number): number => {
if (value === undefined) return defaultValue;
const parsed = Number.parseInt(value, 10);
return Number.isNaN(parsed) ? defaultValue : parsed;
};
// Non-interactive mode when any flag is provided or --defaults is used
const isNonInteractive = !!(
options.agentInstructions ||
options.defaults ||
options.checkBranches ||
options.includeRemote ||
options.branchDays ||
options.bypassGitHooks ||
options.zeroPaddedIds ||
options.defaultEditor ||
options.webPort ||
options.autoOpenBrowser ||
options.installClaudeAgent ||
options.integrationMode
);
// Get project name
let name = projectName;
if (!name) {
const defaultName = existingConfig?.projectName || "";
const promptMessage = isReInitialization && defaultName ? `Project name (${defaultName}):` : "Project name:";
name = await promptText(promptMessage);
// Use existing name if nothing entered during re-init
if (!name && isReInitialization && defaultName) {
name = defaultName;
}
if (!name) {
console.log("Aborting initialization.");
process.exit(1);
}
}
const defaultAdvancedConfig = getDefaultAdvancedConfig(existingConfig);
const applyAdvancedOptionOverrides = () => {
const result: Partial<BacklogConfig> = { ...defaultAdvancedConfig };
result.checkActiveBranches = parseBoolean(options.checkBranches, result.checkActiveBranches ?? true);
if (result.checkActiveBranches) {
result.remoteOperations = parseBoolean(options.includeRemote, result.remoteOperations ?? true);
result.activeBranchDays = parseNumber(options.branchDays, result.activeBranchDays ?? 30);
} else {
result.remoteOperations = false;
}
result.bypassGitHooks = parseBoolean(options.bypassGitHooks, result.bypassGitHooks ?? false);
const paddingValue = parseNumber(options.zeroPaddedIds, result.zeroPaddedIds ?? 0);
result.zeroPaddedIds = paddingValue > 0 ? paddingValue : undefined;
result.defaultEditor =
options.defaultEditor ||
existingConfig?.defaultEditor ||
process.env.EDITOR ||
process.env.VISUAL ||
undefined;
result.defaultPort = parseNumber(options.webPort, result.defaultPort ?? 6420);
result.autoOpenBrowser = parseBoolean(options.autoOpenBrowser, result.autoOpenBrowser ?? true);
return result;
};
const integrationOption = options.integrationMode
? normalizeIntegrationOption(options.integrationMode)
: undefined;
if (options.integrationMode && !integrationOption) {
console.error(`Invalid integration mode: ${options.integrationMode}. Valid options are: mcp, cli, none`);
process.exit(1);
}
let integrationMode: IntegrationMode | null = integrationOption ?? (isNonInteractive ? "mcp" : null);
const mcpServerName = MCP_SERVER_NAME;
type AgentSelection = AgentSelectionValue;
let agentFiles: AgentInstructionFile[] = [];
let agentInstructionsSkipped = false;
let mcpClientSetupSummary: string | undefined;
const mcpGuideUrl = "https://github.com/MrLesk/Backlog.md#-mcp-integration-model-context-protocol";
if (
!integrationOption &&
integrationMode === "mcp" &&
(options.agentInstructions || options.installClaudeAgent)
) {
integrationMode = "cli";
}
if (integrationMode === "mcp" && (options.agentInstructions || options.installClaudeAgent)) {
console.error(
"The MCP connector option cannot be combined with --agent-instructions or --install-claude-agent.",
);
process.exit(1);
}
if (integrationMode === "none" && (options.agentInstructions || options.installClaudeAgent)) {
console.error(
"Skipping AI integration cannot be combined with --agent-instructions or --install-claude-agent.",
);
process.exit(1);
}
mainSelection: while (true) {
if (integrationMode === null) {
let cancelled = false;
const integrationPrompt = await prompts(
{
type: "select",
name: "mode",
message: "How would you like your AI tools to connect to Backlog.md?",
hint: "Pick MCP when your editor supports the Model Context Protocol.",
initial: 0,
choices: [
{
title: "via MCP connector (recommended for Claude Code, Codex, Gemini, Cursor, etc.)",
description: "Agents learn the Backlog.md workflow through MCP tools, resources, and prompts.",
value: "mcp",
},
{
title: "via CLI commands (broader compatibility)",
description: "Agents will use Backlog.md by invoking CLI commands directly",
value: "cli",
},
{
title: "Skip for now (I am not using Backlog.md with AI tools)",
description: "Continue without setting up MCP or instruction files.",
value: "none",
},
],
},
{
onCancel: () => {
cancelled = true;
},
},
);
if (cancelled) {
console.log("Initialization cancelled.");
return;
}
const selectedMode = integrationPrompt?.mode
? normalizeIntegrationOption(String(integrationPrompt.mode))
: null;
integrationMode = selectedMode ?? "mcp";
console.log("");
}
if (integrationMode === "cli") {
if (options.agentInstructions) {
const nameMap: Record<string, AgentSelection> = {
cursor: "AGENTS.md",
claude: "CLAUDE.md",
agents: "AGENTS.md",
gemini: "GEMINI.md",
copilot: ".github/copilot-instructions.md",
none: "none",
"CLAUDE.md": "CLAUDE.md",
"AGENTS.md": "AGENTS.md",
"GEMINI.md": "GEMINI.md",
".github/copilot-instructions.md": ".github/copilot-instructions.md",
};
const requestedInstructions = options.agentInstructions.split(",").map((f) => f.trim().toLowerCase());
const mappedFiles: AgentSelection[] = [];
for (const instruction of requestedInstructions) {
const mappedFile = nameMap[instruction];
if (!mappedFile) {
console.error(`Invalid agent instruction: ${instruction}`);
console.error("Valid options are: cursor, claude, agents, gemini, copilot, none");
process.exit(1);
}
mappedFiles.push(mappedFile);
}
const { files, needsRetry, skipped } = processAgentSelection({ selected: mappedFiles });
if (needsRetry) {
console.error("Please select at least one agent instruction file before continuing.");
process.exit(1);
}
agentFiles = files;
agentInstructionsSkipped = skipped;
} else if (isNonInteractive) {
agentFiles = [];
} else {
const defaultHint = "Enter selects highlighted agent (after moving); space toggles selections\n";
while (true) {
let highlighted: AgentSelection | undefined;
let initialCursor: number | undefined;
let cursorMoved = false;
let selectionCancelled = false;
const response = await prompts(
{
type: "multiselect",
name: "files",
message: "Select instruction files for CLI-based AI tools",
choices: [
{
title: "↓ Use space to toggle instruction files (enter accepts)",
value: PLACEHOLDER_AGENT_VALUE,
disabled: true,
},
{ title: "CLAUDE.md — Claude Code", value: "CLAUDE.md" },
{
title: "AGENTS.md — Codex, Cursor, Zed, Warp, Aider, RooCode, etc.",
value: "AGENTS.md",
},
{ title: "GEMINI.md — Google Gemini Code Assist CLI", value: "GEMINI.md" },
{ title: "Copilot instructions — GitHub Copilot", value: ".github/copilot-instructions.md" },
],
hint: defaultHint,
instructions: false,
onRender: function () {
try {
const promptInstance = this as unknown as {
cursor: number;
value: Array<{ value: AgentSelection }>;
hint: string;
};
if (initialCursor === undefined) {
initialCursor = promptInstance.cursor;
}
if (initialCursor !== undefined && promptInstance.cursor !== initialCursor) {
cursorMoved = true;
}
const focus = promptInstance.value?.[promptInstance.cursor];
highlighted = focus?.value;
promptInstance.hint = defaultHint;
} catch {}
return undefined;
},
},
{
onCancel: () => {
selectionCancelled = true;
},
},
);
if (selectionCancelled) {
integrationMode = null;
console.log("");
continue mainSelection;
}
const rawSelection = (response?.files ?? []) as AgentSelection[];
const selected =
rawSelection.length === 0 &&
highlighted &&
highlighted !== PLACEHOLDER_AGENT_VALUE &&
highlighted !== "none"
? [highlighted]
: rawSelection;
const { files, needsRetry, skipped } = processAgentSelection({
selected,
highlighted,
useHighlightFallback: cursorMoved,
});
if (needsRetry) {
console.log("Please select at least one agent instruction file before continuing.");
continue;
}
agentFiles = files;
agentInstructionsSkipped = skipped;
break;
}
}
break;
}
if (integrationMode === "mcp") {
if (isNonInteractive) {
mcpClientSetupSummary = "skipped (non-interactive)";
break;
}
console.log(` MCP server name: ${mcpServerName}`);
while (true) {
let clientSelectionCancelled = false;
let highlightedClient: string | undefined;
const clientResponse = await prompts(
{
type: "multiselect",
name: "clients",
message: "Which AI tools should we configure right now?",
hint: "Space toggles items • Enter confirms (leave empty to skip)",
instructions: false,
choices: [
{ title: "Claude Code", value: "claude" },
{ title: "OpenAI Codex", value: "codex" },
{ title: "Gemini CLI", value: "gemini" },
{ title: "Other (open setup guide)", value: "guide" },
],
onRender: function () {
try {
const promptInstance = this as unknown as {
cursor: number;
value: Array<{ value: string }>;
};
highlightedClient = promptInstance.value?.[promptInstance.cursor]?.value;
} catch {}
return undefined;
},
},
{
onCancel: () => {
clientSelectionCancelled = true;
},
},
);
if (clientSelectionCancelled) {
integrationMode = null;
console.log("");
continue mainSelection;
}
const rawClients = (clientResponse?.clients ?? []) as string[];
const selectedClients = rawClients.length === 0 && highlightedClient ? [highlightedClient] : rawClients;
highlightedClient = undefined;
if (selectedClients.length === 0) {
console.log(" MCP client setup skipped (configure later if needed).");
mcpClientSetupSummary = "skipped";
break;
}
const results: string[] = [];
const mcpGuidelineUpdates: EnsureMcpGuidelinesResult[] = [];
const recordGuidelinesForClient = async (clientKey: string) => {
const instructionFile = MCP_CLIENT_INSTRUCTION_MAP[clientKey];
if (!instructionFile) {
return;
}
const nudgeResult = await ensureMcpGuidelines(cwd, instructionFile);
if (nudgeResult.changed) {
mcpGuidelineUpdates.push(nudgeResult);
}
};
const uniq = (values: string[]) => [...new Set(values)];
for (const client of selectedClients) {
if (client === "claude") {
const result = await runMcpClientCommand("Claude Code", "claude", [
"mcp",
"add",
"-s",
"user",
mcpServerName,
"--",
"backlog",
"mcp",
"start",
]);
results.push(result);
await recordGuidelinesForClient(client);
continue;
}
if (client === "codex") {
const result = await runMcpClientCommand("OpenAI Codex", "codex", [
"mcp",
"add",
mcpServerName,
"backlog",
"mcp",
"start",
]);
results.push(result);
await recordGuidelinesForClient(client);
continue;
}
if (client === "gemini") {
const result = await runMcpClientCommand("Gemini CLI", "gemini", [
"mcp",
"add",
"-s",
"user",
mcpServerName,
"backlog",
"mcp",
"start",
]);
results.push(result);
await recordGuidelinesForClient(client);
continue;
}
if (client === "guide") {
console.log(" Opening MCP setup guide in your browser...");
await openUrlInBrowser(mcpGuideUrl);
results.push("Setup guide opened");
await recordGuidelinesForClient(client);
}
}
if (mcpGuidelineUpdates.length > 0) {
const createdFiles = uniq(
mcpGuidelineUpdates.filter((entry) => entry.created).map((entry) => entry.fileName),
);
const updatedFiles = uniq(
mcpGuidelineUpdates.filter((entry) => !entry.created).map((entry) => entry.fileName),
);
if (createdFiles.length > 0) {
console.log(` Created MCP reminder file(s): ${createdFiles.join(", ")}`);
}
if (updatedFiles.length > 0) {
console.log(` Added MCP reminder to ${updatedFiles.join(", ")}`);
}
}
mcpClientSetupSummary = results.join(", ");
break;
}
break;
}
if (integrationMode === "none") {
agentFiles = [];
agentInstructionsSkipped = false;
break;
}
}
let advancedConfig: Partial<BacklogConfig> = { ...defaultAdvancedConfig };
let advancedConfigured = false;
let installClaudeAgentSelection = false;
let installShellCompletionsSelection = false;
let completionInstallResult: CompletionInstallResult | null = null;
let completionInstallError: string | null = null;
if (isNonInteractive) {
advancedConfig = applyAdvancedOptionOverrides();
installClaudeAgentSelection =
integrationMode === "cli" ? parseBoolean(options.installClaudeAgent, false) : false;
} else {
const advancedPrompt = await prompts(
{
type: "confirm",
name: "configureAdvanced",
message: "Configure advanced settings now?",
hint: "Runs the advanced backlog config wizard",
initial: false,
},
{
onCancel: () => {
console.log("Aborting initialization.");
process.exit(1);
},
},
);
if (advancedPrompt.configureAdvanced) {
const wizardResult = await runAdvancedConfigWizard({
existingConfig,
cancelMessage: "Aborting initialization.",
includeClaudePrompt: integrationMode === "cli",
});
advancedConfig = { ...defaultAdvancedConfig, ...wizardResult.config };
installClaudeAgentSelection = integrationMode === "cli" ? wizardResult.installClaudeAgent : false;
installShellCompletionsSelection = wizardResult.installShellCompletions;
if (wizardResult.installShellCompletions) {
try {
completionInstallResult = await installCompletion();
} catch (error) {
completionInstallError = error instanceof Error ? error.message : String(error);
}
}
advancedConfigured = true;
}
}
// Call shared core init function
const initResult = await initializeProject(core, {
projectName: name,
integrationMode: integrationMode || "none",
mcpClients: [], // MCP clients are handled separately in CLI with interactive prompts
agentInstructions: agentFiles,
installClaudeAgent: installClaudeAgentSelection,
advancedConfig: {
checkActiveBranches: advancedConfig.checkActiveBranches,
remoteOperations: advancedConfig.remoteOperations,
activeBranchDays: advancedConfig.activeBranchDays,
bypassGitHooks: advancedConfig.bypassGitHooks,
autoCommit: advancedConfig.autoCommit,
zeroPaddedIds: advancedConfig.zeroPaddedIds,
defaultEditor: advancedConfig.defaultEditor,
defaultPort: advancedConfig.defaultPort,
autoOpenBrowser: advancedConfig.autoOpenBrowser,
},
existingConfig,
});
const config = initResult.config;
// Show configuration summary
console.log("\nInitialization Summary:");
console.log(` Project Name: ${config.projectName}`);
if (integrationMode === "cli") {
console.log(" AI Integration: CLI commands (legacy)");
if (agentFiles.length > 0) {
console.log(` Agent instructions: ${agentFiles.join(", ")}`);
} else if (agentInstructionsSkipped) {
console.log(" Agent instructions: skipped");
} else {
console.log(" Agent instructions: none");
}
} else if (integrationMode === "mcp") {
console.log(" AI Integration: MCP connector");
console.log(" Agent instruction files: guidance is provided through the MCP connector.");
console.log(` MCP server name: ${mcpServerName}`);
console.log(` MCP client setup: ${mcpClientSetupSummary ?? "skipped"}`);
} else {
console.log(
" AI integration skipped. Configure later via `backlog init` or by registering the MCP server manually.",
);
}
let completionSummary: string;
if (completionInstallResult) {
completionSummary = `installed to ${completionInstallResult.installPath}`;
} else if (installShellCompletionsSelection) {
completionSummary = "installation failed (see warning below)";
} else if (advancedConfigured) {
completionSummary = "skipped";
} else {
completionSummary = "not configured";
}
console.log(` Shell completions: ${completionSummary}`);
if (advancedConfigured) {
console.log(" Advanced settings:");
console.log(` Check active branches: ${config.checkActiveBranches}`);
console.log(` Remote operations: ${config.remoteOperations}`);
console.log(` Active branch days: ${config.activeBranchDays}`);
console.log(` Bypass git hooks: ${config.bypassGitHooks}`);
console.log(` Auto commit: ${config.autoCommit}`);
console.log(` Zero-padded IDs: ${config.zeroPaddedIds ? `${config.zeroPaddedIds} digits` : "disabled"}`);
console.log(` Web UI port: ${config.defaultPort}`);
console.log(` Auto open browser: ${config.autoOpenBrowser}`);
if (config.defaultEditor) {
console.log(` Default editor: ${config.defaultEditor}`);
}
} else {
console.log(" Advanced settings: unchanged (run `backlog config` to customize).");
}
console.log("");
if (completionInstallResult) {
const instructions = completionInstallResult.instructions.trim();
console.log(
[
`Shell completion script installed for ${completionInstallResult.shell}.`,
` Path: ${completionInstallResult.installPath}`,
instructions,
"",
].join("\n"),
);
} else if (completionInstallError) {
const indentedError = completionInstallError
.split("\n")
.map((line) => ` ${line}`)
.join("\n");
console.warn(
`⚠️ Shell completion installation failed:\n${indentedError}\n Run \`backlog completion install\` later to retry.\n`,
);
}
// Log init result
if (initResult.isReInitialization) {
console.log(`Updated backlog project configuration: ${name}`);
} else {
console.log(`Initialized backlog project: ${name}`);
}
// Log agent files result from shared init
if (integrationMode === "cli") {
if (initResult.mcpResults?.agentFiles) {
console.log(`${initResult.mcpResults.agentFiles}`);
} else if (agentInstructionsSkipped) {
console.log("Skipping agent instruction files per selection.");
}
}
// Log Claude agent result from shared init
if (integrationMode === "cli" && initResult.mcpResults?.claudeAgent) {
console.log(`✓ Claude Code Backlog.md agent ${initResult.mcpResults.claudeAgent}`);
}
// Final warning if remote operations were enabled but no git remotes are configured
try {
if (config.remoteOperations) {
// Ensure git ops are ready (config not strictly required for this check)
const hasRemotes = await core.gitOps.hasAnyRemote();
if (!hasRemotes) {
console.warn(
[
"Warning: remoteOperations is enabled but no git remotes are configured.",
"Remote features will be skipped until a remote is added (e.g., 'git remote add origin <url>')",
"or disable remoteOperations via 'backlog config set remoteOperations false'.",
].join(" "),
);
}
}
} catch {
// Ignore failures in final advisory warning
}
} catch (err) {
console.error("Failed to initialize project", err);
process.exitCode = 1;
}
},
);
export async function generateNextDocId(core: Core): Promise<string> {
const config = await core.filesystem.loadConfig();
// Load local documents
const docs = await core.filesystem.listDocuments();
const allIds: string[] = [];
try {
const backlogDir = DEFAULT_DIRECTORIES.BACKLOG;
// Skip remote operations if disabled
if (config?.remoteOperations === false) {
if (process.env.DEBUG) {
console.log("Remote operations disabled - generating ID from local documents only");
}
} else {
await core.gitOps.fetch();
}
const branches = await core.gitOps.listAllBranches();
// Load files from all branches in parallel
const branchFilePromises = branches.map(async (branch) => {
const files = await core.gitOps.listFilesInTree(branch, `${backlogDir}/docs`);
return files
.map((file) => {
const match = file.match(/doc-(\d+)/);
return match ? `doc-${match[1]}` : null;
})
.filter((id): id is string => id !== null);
});
const branchResults = await Promise.all(branchFilePromises);
for (const branchIds of branchResults) {
allIds.push(...branchIds);
}
} catch (error) {
// Suppress errors for offline mode or other git issues
if (process.env.DEBUG) {
console.error("Could not fetch remote document IDs:", error);
}
}
// Add local document IDs
for (const doc of docs) {
allIds.push(doc.id);
}
// Find the highest numeric ID
let max = 0;
for (const id of allIds) {
const match = id.match(/^doc-(\d+)$/);
if (match) {
const num = Number.parseInt(match[1] || "0", 10);
if (num > max) max = num;
}
}
const nextIdNumber = max + 1;
const padding = config?.zeroPaddedIds;
if (padding && typeof padding === "number" && padding > 0) {
const paddedId = String(nextIdNumber).padStart(padding, "0");
return `doc-${paddedId}`;
}
return `doc-${nextIdNumber}`;
}
export async function generateNextDecisionId(core: Core): Promise<string> {
const config = await core.filesystem.loadConfig();
// Load local decisions
const decisions = await core.filesystem.listDecisions();
const allIds: string[] = [];
try {
const backlogDir = DEFAULT_DIRECTORIES.BACKLOG;
// Skip remote operations if disabled
if (config?.remoteOperations === false) {
if (process.env.DEBUG) {
console.log("Remote operations disabled - generating ID from local decisions only");
}
} else {
await core.gitOps.fetch();
}
const branches = await core.gitOps.listAllBranches();
// Load files from all branches in parallel
const branchFilePromises = branches.map(async (branch) => {
const files = await core.gitOps.listFilesInTree(branch, `${backlogDir}/decisions`);
return files
.map((file) => {
const match = file.match(/decision-(\d+)/);
return match ? `decision-${match[1]}` : null;
})
.filter((id): id is string => id !== null);
});
const branchResults = await Promise.all(branchFilePromises);
for (const branchIds of branchResults) {
allIds.push(...branchIds);
}
} catch (error) {
// Suppress errors for offline mode or other git issues
if (process.env.DEBUG) {
console.error("Could not fetch remote decision IDs:", error);
}
}
// Add local decision IDs
for (const decision of decisions) {
allIds.push(decision.id);
}
// Find the highest numeric ID
let max = 0;
for (const id of allIds) {
const match = id.match(/^decision-(\d+)$/);
if (match) {
const num = Number.parseInt(match[1] || "0", 10);
if (num > max) max = num;
}
}
const nextIdNumber = max + 1;
const padding = config?.zeroPaddedIds;
if (padding && typeof padding === "number" && padding > 0) {
const paddedId = String(nextIdNumber).padStart(padding, "0");
return `decision-${paddedId}`;
}
return `decision-${nextIdNumber}`;
}
function normalizeDependencies(dependencies: unknown): string[] {
if (!dependencies) return [];
const normalizeList = (values: string[]): string[] =>
values
.map((value) => value.trim())
.filter((value): value is string => value.length > 0)
.map((value) => normalizeTaskId(value));
if (Array.isArray(dependencies)) {
return normalizeList(
dependencies.flatMap((dep) =>
String(dep)
.split(",")
.map((d) => d.trim()),
),
);
}
return normalizeList(String(dependencies).split(","));
}
async function validateDependencies(
dependencies: string[],
core: Core,
): Promise<{ valid: string[]; invalid: string[] }> {
const valid: string[] = [];
const invalid: string[] = [];
if (dependencies.length === 0) {
return { valid, invalid };
}
// Load both tasks and drafts to validate dependencies
const [tasks, drafts] = await Promise.all([core.queryTasks(), core.fs.listDrafts()]);
const knownIds = [...tasks.map((task) => task.id), ...drafts.map((draft) => draft.id)];
for (const dep of dependencies) {
const match = knownIds.find((id) => taskIdsEqual(dep, id));
if (match) {
valid.push(match);
} else {
invalid.push(dep);
}
}
return { valid, invalid };
}
function buildTaskFromOptions(id: string, title: string, options: Record<string, unknown>): Task {
const parentInput = options.parent ? String(options.parent) : undefined;
const normalizedParent = parentInput ? normalizeTaskId(parentInput) : undefined;
const createdDate = new Date().toISOString().slice(0, 16).replace("T", " ");
// Handle dependencies - they will be validated separately
const dependencies = normalizeDependencies(options.dependsOn || options.dep);
// Validate priority option
const priority = options.priority ? String(options.priority).toLowerCase() : undefined;
const validPriorities = ["high", "medium", "low"];
const validatedPriority =
priority && validPriorities.includes(priority) ? (priority as "high" | "medium" | "low") : undefined;
return {
id,
title,
status: options.status ? String(options.status) : "",
assignee: options.assignee ? [String(options.assignee)] : [],
createdDate,
labels: options.labels
? String(options.labels)
.split(",")
.map((l: string) => l.trim())
.filter(Boolean)
: [],
dependencies,
rawContent: "",
...(options.description || options.desc ? { description: String(options.description || options.desc) } : {}),
...(normalizedParent && { parentTaskId: normalizedParent }),
...(validatedPriority && { priority: validatedPriority }),
};
}
const taskCmd = program.command("task").aliases(["tasks"]);
taskCmd
.command("create <title>")
.option(
"-d, --description <text>",
"task description (multi-line: bash $'Line1\\nLine2', POSIX printf, PowerShell \"Line1`nLine2\")",
)
.option("--desc <text>", "alias for --description")
.option("-a, --assignee <assignee>")
.option("-s, --status <status>")
.option("-l, --labels <labels>")
.option("--priority <priority>", "set task priority (high, medium, low)")
.option("--plain", "use plain text output after creating")
.option("--ac <criteria>", "add acceptance criteria (can be used multiple times)", createMultiValueAccumulator())
.option(
"--acceptance-criteria <criteria>",
"add acceptance criteria (can be used multiple times)",
createMultiValueAccumulator(),
)
.option("--plan <text>", "add implementation plan")
.option("--notes <text>", "add implementation notes")
.option("--hours <number>", "set estimated hours to complete")
.option("--draft")
.option("-p, --parent <taskId>", "specify parent task ID")
.option(
"--depends-on <taskIds>",
"specify task dependencies (comma-separated or use multiple times)",
(value, previous) => {
const soFar = Array.isArray(previous) ? previous : previous ? [previous] : [];
return [...soFar, value];
},
)
.option("--dep <taskIds>", "specify task dependencies (shortcut for --depends-on)", (value, previous) => {
const soFar = Array.isArray(previous) ? previous : previous ? [previous] : [];
return [...soFar, value];
})
.action(async (title: string, options) => {
const cwd = process.cwd();
const core = new Core(cwd);
await core.ensureConfigLoaded();
const id = await core.generateNextId(options.parent);
const task = buildTaskFromOptions(id, title, options);
// Normalize and validate status if provided (case-insensitive)
if (options.status) {
const canonical = await getCanonicalStatus(String(options.status), core);
if (!canonical) {
const configuredStatuses = await getValidStatuses(core);
console.error(
`Invalid status: ${options.status}. Valid statuses are: ${formatValidStatuses(configuredStatuses)}`,
);
process.exitCode = 1;
return;
}
task.status = canonical;
}
// Validate dependencies if provided
if (task.dependencies.length > 0) {
const { valid, invalid } = await validateDependencies(task.dependencies, core);
if (invalid.length > 0) {
console.error(`Error: The following dependencies do not exist: ${invalid.join(", ")}`);
console.error("Please create these tasks first or check the task IDs.");
process.exitCode = 1;
return;
}
task.dependencies = valid;
}
// Handle acceptance criteria for create command (structured only)
const criteria = processAcceptanceCriteriaOptions(options);
if (criteria.length > 0) {
let idx = 1;
task.acceptanceCriteriaItems = criteria.map((text) => ({ index: idx++, text, checked: false }));
}
// Handle implementation plan
if (options.plan) {
task.implementationPlan = String(options.plan);
}
// Handle implementation notes
if (options.notes) {
task.implementationNotes = String(options.notes);
}
// Handle estimated hours
if (options.hours !== undefined) {
const parsed = Number(options.hours);
if (Number.isNaN(parsed) || parsed < 0) {
console.error("Invalid hours. Must be a non-negative number.");
process.exitCode = 1;
return;
}
task.estimatedHours = parsed;
}
// Workaround for bun compile issue with commander options
const isPlainFlag = options.plain || process.argv.includes("--plain");
if (options.draft) {
const filepath = await core.createDraft(task);
if (isPlainFlag) {
console.log(formatTaskPlainText(task, { filePathOverride: filepath }));
return;
}
console.log(`Created draft ${id}`);
console.log(`File: ${filepath}`);
} else {
const filepath = await core.createTask(task);
if (isPlainFlag) {
console.log(formatTaskPlainText(task, { filePathOverride: filepath }));
return;
}
console.log(`Created task ${id}`);
console.log(`File: ${filepath}`);
}
});
program
.command("search [query]")
.description("search tasks, documents, and decisions using the shared index")
.option("--type <type>", "limit results to type (task, document, decision)", createMultiValueAccumulator())
.option("--status <status>", "filter task results by status")
.option("--priority <priority>", "filter task results by priority (high, medium, low)")
.option("--limit <number>", "limit total results returned")
.option("--plain", "print plain text output instead of interactive UI")
.action(async (query: string | undefined, options) => {
const cwd = process.cwd();
const core = new Core(cwd);
const searchService = await core.getSearchService();
const contentStore = await core.getContentStore();
const cleanup = () => {
searchService.dispose();
contentStore.dispose();
};
const rawTypes = options.type ? (Array.isArray(options.type) ? options.type : [options.type]) : undefined;
const allowedTypes: SearchResultType[] = ["task", "document", "decision"];
const types = rawTypes
? rawTypes
.map((value: string) => value.toLowerCase())
.filter((value: string): value is SearchResultType => {
if (!allowedTypes.includes(value as SearchResultType)) {
console.warn(`Ignoring unsupported type '${value}'. Supported: task, document, decision`);
return false;
}
return true;
})
: allowedTypes;
const filters: { status?: string; priority?: SearchPriorityFilter } = {};
if (options.status) {
filters.status = options.status;
}
if (options.priority) {
const priorityLower = String(options.priority).toLowerCase();
const validPriorities: SearchPriorityFilter[] = ["high", "medium", "low"];
if (!validPriorities.includes(priorityLower as SearchPriorityFilter)) {
console.error("Invalid priority. Valid values: high, medium, low");
cleanup();
process.exitCode = 1;
return;
}
filters.priority = priorityLower as SearchPriorityFilter;
}
let limit: number | undefined;
if (options.limit !== undefined) {
const parsed = Number.parseInt(String(options.limit), 10);
if (Number.isNaN(parsed) || parsed <= 0) {
console.error("--limit must be a positive integer");
cleanup();
process.exitCode = 1;
return;
}
limit = parsed;
}
const searchResults = searchService.search({
query: query ?? "",
limit,
types,
filters,
});
const isPlainFlag = options.plain || process.argv.includes("--plain") || !process.stdout.isTTY;
if (isPlainFlag) {
printSearchResults(searchResults);
cleanup();
return;
}
const taskResults = searchResults.filter(isTaskSearchResult);
const searchResultTasks = taskResults.map((result) => result.task);
const allTasks = (await core.queryTasks()).filter(
(task) => task.id && task.id.trim() !== "" && task.id.startsWith("task-"),
);
// If no tasks exist at all, show plain text results
if (allTasks.length === 0) {
printSearchResults(searchResults);
cleanup();
return;
}
// Use the first search result as the selected task, or first available task if no results
const firstTask = searchResultTasks[0] || allTasks[0];
const priorityFilter = filters.priority ? filters.priority : undefined;
const statusFilter = filters.status;
const { runUnifiedView } = await import("./ui/unified-view.ts");
await runUnifiedView({
core,
initialView: "task-list",
selectedTask: firstTask,
tasks: allTasks, // Pass ALL tasks, not just search results
filter: {
title: query ? `Search: ${query}` : "Search",
filterDescription: buildSearchFilterDescription({
status: statusFilter,
priority: priorityFilter,
query: query ?? "",
}),
status: statusFilter,
priority: priorityFilter,
searchQuery: query ?? "", // Pre-populate search with the query
},
});
cleanup();
});
function buildSearchFilterDescription(filters: {
status?: string;
priority?: SearchPriorityFilter;
query?: string;
}): string {
const parts: string[] = [];
if (filters.query) {
parts.push(`Query: ${filters.query}`);
}
if (filters.status) {
parts.push(`Status: ${filters.status}`);
}
if (filters.priority) {
parts.push(`Priority: ${filters.priority}`);
}
return parts.join(" • ");
}
function printSearchResults(results: SearchResult[]): void {
if (results.length === 0) {
console.log("No results found.");
return;
}
const tasks: TaskSearchResult[] = [];
const documents: DocumentSearchResult[] = [];
const decisions: DecisionSearchResult[] = [];
for (const result of results) {
if (result.type === "task") {
tasks.push(result);
continue;
}
if (result.type === "document") {
documents.push(result);
continue;
}
decisions.push(result);
}
const localTasks = tasks.filter((t) => isLocalEditableTask(t.task));
let printed = false;
if (localTasks.length > 0) {
console.log("Tasks:");
for (const taskResult of localTasks) {
const { task } = taskResult;
const scoreText = formatScore(taskResult.score);
const statusText = task.status ? ` (${task.status})` : "";
const priorityText = task.priority ? ` [${task.priority.toUpperCase()}]` : "";
console.log(` ${task.id} - ${task.title}${statusText}${priorityText}${scoreText}`);
}
printed = true;
}
if (documents.length > 0) {
if (printed) {
console.log("");
}
console.log("Documents:");
for (const documentResult of documents) {
const { document } = documentResult;
const scoreText = formatScore(documentResult.score);
console.log(` ${document.id} - ${document.title}${scoreText}`);
}
printed = true;
}
if (decisions.length > 0) {
if (printed) {
console.log("");
}
console.log("Decisions:");
for (const decisionResult of decisions) {
const { decision } = decisionResult;
const scoreText = formatScore(decisionResult.score);
console.log(` ${decision.id} - ${decision.title}${scoreText}`);
}
printed = true;
}
if (!printed) {
console.log("No results found.");
}
}
function formatScore(score: number | null): string {
if (score === null || score === undefined) {
return "";
}
// Invert score so higher is better (Fuse.js uses 0=perfect match, 1=no match)
const invertedScore = 1 - score;
return ` [score ${invertedScore.toFixed(3)}]`;
}
function isTaskSearchResult(result: SearchResult): result is TaskSearchResult {
return result.type === "task";
}
taskCmd
.command("list")
.description("list tasks grouped by status")
.option("-s, --status <status>", "filter tasks by status (case-insensitive)")
.option("-a, --assignee <assignee>", "filter tasks by assignee")
.option("-p, --parent <taskId>", "filter tasks by parent task ID")
.option("--priority <priority>", "filter tasks by priority (high, medium, low)")
.option("--sort <field>", "sort tasks by field (priority, id)")
.option("--plain", "use plain text output instead of interactive UI")
.action(async (options) => {
const cwd = process.cwd();
const core = new Core(cwd);
const cleanup = () => {
core.disposeSearchService();
core.disposeContentStore();
};
const baseFilters: TaskListFilter = {};
if (options.status) {
baseFilters.status = options.status;
}
if (options.assignee) {
baseFilters.assignee = options.assignee;
}
if (options.priority) {
const priorityLower = options.priority.toLowerCase();
const validPriorities = ["high", "medium", "low"] as const;
if (!validPriorities.includes(priorityLower as (typeof validPriorities)[number])) {
console.error(`Invalid priority: ${options.priority}. Valid values are: high, medium, low`);
process.exitCode = 1;
cleanup();
return;
}
baseFilters.priority = priorityLower as (typeof validPriorities)[number];
}
let parentId: string | undefined;
if (options.parent) {
const parentInput = String(options.parent);
parentId = normalizeTaskId(parentInput);
baseFilters.parentTaskId = parentInput;
}
if (options.sort) {
const validSortFields = ["priority", "id"];
const sortField = options.sort.toLowerCase();
if (!validSortFields.includes(sortField)) {
console.error(`Invalid sort field: ${options.sort}. Valid values are: priority, id`);
process.exitCode = 1;
cleanup();
return;
}
}
const isPlainFlag = options.plain || process.argv.includes("--plain");
if (isPlainFlag) {
const tasks = await core.queryTasks({ filters: baseFilters, includeCrossBranch: false });
const config = await core.filesystem.loadConfig();
if (parentId) {
const parentExists = (await core.queryTasks({ includeCrossBranch: false })).some((task) =>
taskIdsEqual(parentId, task.id),
);
if (!parentExists) {
console.error(`Parent task ${parentId} not found.`);
process.exitCode = 1;
cleanup();
return;
}
}
let sortedTasks = tasks;
if (options.sort) {
const validSortFields = ["priority", "id"];
const sortField = options.sort.toLowerCase();
if (!validSortFields.includes(sortField)) {
console.error(`Invalid sort field: ${options.sort}. Valid values are: priority, id`);
process.exitCode = 1;
cleanup();
return;
}
sortedTasks = sortTasks(tasks, sortField);
} else {
sortedTasks = sortTasks(tasks, "priority");
}
let filtered = sortedTasks;
if (parentId) {
filtered = filtered.filter((task) => task.parentTaskId && taskIdsEqual(parentId, task.parentTaskId));
}
if (filtered.length === 0) {
if (options.parent) {
const canonicalParent = normalizeTaskId(String(options.parent));
console.log(`No child tasks found for parent task ${canonicalParent}.`);
} else {
console.log("No tasks found.");
}
cleanup();
return;
}
if (options.sort && options.sort.toLowerCase() === "priority") {
const sortedByPriority = sortTasks(filtered, "priority");
console.log("Tasks (sorted by priority):");
for (const t of sortedByPriority) {
const priorityIndicator = t.priority ? `[${t.priority.toUpperCase()}] ` : "";
const statusIndicator = t.status ? ` (${t.status})` : "";
console.log(` ${priorityIndicator}${t.id} - ${t.title}${statusIndicator}`);
}
cleanup();
return;
}
const canonicalByLower = new Map<string, string>();
const statuses = config?.statuses || [];
for (const status of statuses) {
canonicalByLower.set(status.toLowerCase(), status);
}
const groups = new Map<string, Task[]>();
for (const task of filtered) {
const rawStatus = (task.status || "").trim();
const canonicalStatus = canonicalByLower.get(rawStatus.toLowerCase()) || rawStatus;
const list = groups.get(canonicalStatus) || [];
list.push(task);
groups.set(canonicalStatus, list);
}
const orderedStatuses = [
...statuses.filter((status) => groups.has(status)),
...Array.from(groups.keys()).filter((status) => !statuses.includes(status)),
];
for (const status of orderedStatuses) {
const list = groups.get(status);
if (!list) continue;
let sortedList = list;
if (options.sort) {
sortedList = sortTasks(list, options.sort.toLowerCase());
}
console.log(`${status || "No Status"}:`);
sortedList.forEach((task) => {
const priorityIndicator = task.priority ? `[${task.priority.toUpperCase()}] ` : "";
console.log(` ${priorityIndicator}${task.id} - ${task.title}`);
});
console.log();
}
cleanup();
return;
}
let filterDescription = "";
let title = "Tasks";
const activeFilters: string[] = [];
if (options.status) activeFilters.push(`Status: ${options.status}`);
if (options.assignee) activeFilters.push(`Assignee: ${options.assignee}`);
if (options.parent) {
activeFilters.push(`Parent: ${normalizeTaskId(String(options.parent))}`);
}
if (options.priority) activeFilters.push(`Priority: ${options.priority}`);
if (options.sort) activeFilters.push(`Sort: ${options.sort}`);
if (activeFilters.length > 0) {
filterDescription = activeFilters.join(", ");
title = `Tasks (${activeFilters.join(" • ")})`;
}
const { runUnifiedView } = await import("./ui/unified-view.ts");
await runUnifiedView({
core,
initialView: "task-list",
tasksLoader: async (updateProgress) => {
updateProgress("Loading configuration...");
const config = await core.filesystem.loadConfig();
updateProgress("Loading tasks...");
const tasks = await core.queryTasks({ filters: baseFilters, includeCrossBranch: false });
if (parentId) {
const parentExists = (await core.queryTasks({ includeCrossBranch: false })).some((task) =>
taskIdsEqual(parentId, task.id),
);
if (!parentExists) {
throw new Error(`Parent task ${parentId} not found.`);
}
}
let sortedTasks = tasks;
if (options.sort) {
const validSortFields = ["priority", "id"];
const sortField = options.sort.toLowerCase();
if (!validSortFields.includes(sortField)) {
throw new Error(`Invalid sort field: ${options.sort}. Valid values are: priority, id`);
}
sortedTasks = sortTasks(tasks, sortField);
} else {
sortedTasks = sortTasks(tasks, "priority");
}
let filtered = sortedTasks;
if (parentId) {
filtered = filtered.filter((task) => task.parentTaskId && taskIdsEqual(parentId, task.parentTaskId));
}
return {
tasks: filtered,
statuses: config?.statuses || [],
};
},
filter: {
status: options.status,
assignee: options.assignee,
priority: options.priority,
sort: options.sort,
title,
filterDescription,
parentTaskId: parentId,
},
});
cleanup();
});
taskCmd
.command("edit <taskId>")
.description("edit an existing task")
.option("-t, --title <title>")
.option(
"-d, --description <text>",
"task description (multi-line: bash $'Line1\\nLine2', POSIX printf, PowerShell \"Line1`nLine2\")",
)
.option("--desc <text>", "alias for --description")
.option("-a, --assignee <assignee>")
.option("-s, --status <status>")
.option("-l, --label <labels>")
.option("--priority <priority>", "set task priority (high, medium, low)")
.option("--ordinal <number>", "set task ordinal for custom ordering")
.option("--plain", "use plain text output after editing")
.option("--add-label <label>")
.option("--remove-label <label>")
.option("--ac <criteria>", "add acceptance criteria (can be used multiple times)", createMultiValueAccumulator())
.option(
"--remove-ac <index>",
"remove acceptance criterion by index (1-based, can be used multiple times)",
createMultiValueAccumulator(),
)
.option(
"--check-ac <index>",
"check acceptance criterion by index (1-based, can be used multiple times)",
createMultiValueAccumulator(),
)
.option(
"--uncheck-ac <index>",
"uncheck acceptance criterion by index (1-based, can be used multiple times)",
createMultiValueAccumulator(),
)
.option("--acceptance-criteria <criteria>", "set acceptance criteria (comma-separated or use multiple times)")
.option("--plan <text>", "set implementation plan")
.option("--notes <text>", "set implementation notes (replaces existing)")
.option(
"--append-notes <text>",
"append to implementation notes (can be used multiple times)",
createMultiValueAccumulator(),
)
.option(
"--depends-on <taskIds>",
"set task dependencies (comma-separated or use multiple times)",
(value, previous) => {
const soFar = Array.isArray(previous) ? previous : previous ? [previous] : [];
return [...soFar, value];
},
)
.option("--dep <taskIds>", "set task dependencies (shortcut for --depends-on)", (value, previous) => {
const soFar = Array.isArray(previous) ? previous : previous ? [previous] : [];
return [...soFar, value];
})
.option("--hours <number>", "set estimated hours to complete")
.action(async (taskId: string, options) => {
const cwd = process.cwd();
const core = new Core(cwd);
const canonicalId = normalizeTaskId(taskId);
const existingTask = await core.loadTaskById(canonicalId);
if (!existingTask) {
console.error(`Task ${taskId} not found.`);
process.exitCode = 1;
return;
}
const parseCommaSeparated = (value: unknown): string[] => {
return toStringArray(value)
.flatMap((entry) => String(entry).split(","))
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
};
let canonicalStatus: string | undefined;
if (options.status) {
const canonical = await getCanonicalStatus(String(options.status), core);
if (!canonical) {
const configuredStatuses = await getValidStatuses(core);
console.error(
`Invalid status: ${options.status}. Valid statuses are: ${formatValidStatuses(configuredStatuses)}`,
);
process.exitCode = 1;
return;
}
canonicalStatus = canonical;
}
let normalizedPriority: "high" | "medium" | "low" | undefined;
if (options.priority) {
const priority = String(options.priority).toLowerCase();
const validPriorities = ["high", "medium", "low"] as const;
if (!validPriorities.includes(priority as (typeof validPriorities)[number])) {
console.error(`Invalid priority: ${priority}. Valid values are: high, medium, low`);
process.exitCode = 1;
return;
}
normalizedPriority = priority as "high" | "medium" | "low";
}
let ordinalValue: number | undefined;
if (options.ordinal !== undefined) {
const parsed = Number(options.ordinal);
if (Number.isNaN(parsed) || parsed < 0) {
console.error(`Invalid ordinal: ${options.ordinal}. Must be a non-negative number.`);
process.exitCode = 1;
return;
}
ordinalValue = parsed;
}
let removeCriteria: number[] | undefined;
let checkCriteria: number[] | undefined;
let uncheckCriteria: number[] | undefined;
try {
const removes = parsePositiveIndexList(options.removeAc);
if (removes.length > 0) {
removeCriteria = removes;
}
const checks = parsePositiveIndexList(options.checkAc);
if (checks.length > 0) {
checkCriteria = checks;
}
const unchecks = parsePositiveIndexList(options.uncheckAc);
if (unchecks.length > 0) {
uncheckCriteria = unchecks;
}
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
return;
}
const labelValues = parseCommaSeparated(options.label);
const addLabelValues = parseCommaSeparated(options.addLabel);
const removeLabelValues = parseCommaSeparated(options.removeLabel);
const assigneeValues = parseCommaSeparated(options.assignee);
const acceptanceAdditions = processAcceptanceCriteriaOptions(options);
const combinedDependencies = [...toStringArray(options.dependsOn), ...toStringArray(options.dep)];
const dependencyValues = combinedDependencies.length > 0 ? normalizeDependencies(combinedDependencies) : undefined;
const notesAppendValues = toStringArray(options.appendNotes);
const editArgs: TaskEditArgs = {};
if (options.title) {
editArgs.title = String(options.title);
}
const descriptionOption = options.description ?? options.desc;
if (descriptionOption !== undefined) {
editArgs.description = String(descriptionOption);
}
if (canonicalStatus) {
editArgs.status = canonicalStatus;
}
if (normalizedPriority) {
editArgs.priority = normalizedPriority;
}
if (ordinalValue !== undefined) {
editArgs.ordinal = ordinalValue;
}
if (labelValues.length > 0) {
editArgs.labels = labelValues;
}
if (addLabelValues.length > 0) {
editArgs.addLabels = addLabelValues;
}
if (removeLabelValues.length > 0) {
editArgs.removeLabels = removeLabelValues;
}
if (assigneeValues.length > 0) {
editArgs.assignee = assigneeValues;
}
if (dependencyValues && dependencyValues.length > 0) {
editArgs.dependencies = dependencyValues;
}
if (typeof options.plan === "string") {
editArgs.planSet = String(options.plan);
}
if (typeof options.notes === "string") {
editArgs.notesSet = String(options.notes);
}
if (notesAppendValues.length > 0) {
editArgs.notesAppend = notesAppendValues;
}
if (acceptanceAdditions.length > 0) {
editArgs.acceptanceCriteriaAdd = acceptanceAdditions;
}
if (removeCriteria) {
editArgs.acceptanceCriteriaRemove = removeCriteria;
}
if (checkCriteria) {
editArgs.acceptanceCriteriaCheck = checkCriteria;
}
if (uncheckCriteria) {
editArgs.acceptanceCriteriaUncheck = uncheckCriteria;
}
if (options.hours !== undefined) {
const parsed = Number(options.hours);
if (Number.isNaN(parsed) || parsed < 0) {
console.error("Invalid hours. Must be a non-negative number.");
process.exitCode = 1;
return;
}
editArgs.estimatedHours = parsed;
}
let updatedTask: Task;
try {
const updateInput = buildTaskUpdateInput(editArgs);
updatedTask = await core.editTask(canonicalId, updateInput);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
return;
}
const isPlainFlag = options.plain || process.argv.includes("--plain");
if (isPlainFlag) {
console.log(formatTaskPlainText(updatedTask));
return;
}
console.log(`Updated task ${updatedTask.id}`);
});
// Note: Implementation notes appending is handled via `task edit --append-notes` only.
taskCmd
.command("view <taskId>")
.description("display task details")
.option("--plain", "use plain text output instead of interactive UI")
.action(async (taskId: string, options) => {
const cwd = process.cwd();
const core = new Core(cwd);
const task = await core.loadTaskById(taskId);
if (!task) {
console.error(`Task ${taskId} not found.`);
return;
}
// Plain text output for AI agents
if (options && (("plain" in options && options.plain) || process.argv.includes("--plain"))) {
console.log(formatTaskPlainText(task));
return;
}
// Use enhanced task viewer with detail focus
await viewTaskEnhanced(task, { startWithDetailFocus: true, core });
});
taskCmd
.command("archive <taskId>")
.description("archive a task")
.action(async (taskId: string) => {
const cwd = process.cwd();
const core = new Core(cwd);
const success = await core.archiveTask(taskId);
if (success) {
console.log(`Archived task ${taskId}`);
} else {
console.error(`Task ${taskId} not found.`);
}
});
taskCmd
.command("demote <taskId>")
.description("move task back to drafts")
.action(async (taskId: string) => {
const cwd = process.cwd();
const core = new Core(cwd);
const success = await core.demoteTask(taskId);
if (success) {
console.log(`Demoted task ${taskId}`);
} else {
console.error(`Task ${taskId} not found.`);
}
});
taskCmd
.argument("[taskId]")
.option("--plain", "use plain text output")
.action(async (taskId: string | undefined, options: { plain?: boolean }) => {
const cwd = process.cwd();
const core = new Core(cwd);
// Don't handle commands that should be handled by specific command handlers
const reservedCommands = ["create", "list", "edit", "view", "archive", "demote"];
if (taskId && reservedCommands.includes(taskId)) {
console.error(`Unknown command: ${taskId}`);
taskCmd.help();
return;
}
// Handle single task view only
if (!taskId) {
taskCmd.help();
return;
}
const task = await core.loadTaskById(taskId);
if (!task) {
console.error(`Task ${taskId} not found.`);
return;
}
// Plain text output for AI agents
if (options && (options.plain || process.argv.includes("--plain"))) {
console.log(formatTaskPlainText(task));
return;
}
// Use unified view with detail focus and Tab switching support
const allTasks = await core.queryTasks();
const { runUnifiedView } = await import("./ui/unified-view.ts");
await runUnifiedView({
core,
initialView: "task-detail",
selectedTask: task,
tasks: allTasks,
});
});
const draftCmd = program.command("draft");
draftCmd
.command("list")
.description("list all drafts")
.option("--sort <field>", "sort drafts by field (priority, id)")
.option("--plain", "use plain text output")
.action(async (options: { plain?: boolean; sort?: string }) => {
const cwd = process.cwd();
const core = new Core(cwd);
await core.ensureConfigLoaded();
const drafts = await core.filesystem.listDrafts();
if (!drafts || drafts.length === 0) {
console.log("No drafts found.");
return;
}
// Apply sorting - default to priority sorting like the web UI
const { sortTasks } = await import("./utils/task-sorting.ts");
let sortedDrafts = drafts;
if (options.sort) {
const validSortFields = ["priority", "id"];
const sortField = options.sort.toLowerCase();
if (!validSortFields.includes(sortField)) {
console.error(`Invalid sort field: ${options.sort}. Valid values are: priority, id`);
process.exitCode = 1;
return;
}
sortedDrafts = sortTasks(drafts, sortField);
} else {
// Default to priority sorting to match web UI behavior
sortedDrafts = sortTasks(drafts, "priority");
}
if (options.plain || process.argv.includes("--plain")) {
// Plain text output for AI agents
console.log("Drafts:");
for (const draft of sortedDrafts) {
const priorityIndicator = draft.priority ? `[${draft.priority.toUpperCase()}] ` : "";
console.log(` ${priorityIndicator}${draft.id} - ${draft.title}`);
}
} else {
// Interactive UI - use unified view with draft support
const firstDraft = sortedDrafts[0];
if (!firstDraft) return;
const { runUnifiedView } = await import("./ui/unified-view.ts");
await runUnifiedView({
core,
initialView: "task-list",
selectedTask: firstDraft,
tasks: sortedDrafts,
filter: {
filterDescription: "All Drafts",
},
title: "Drafts",
});
}
});
draftCmd
.command("create <title>")
.option(
"-d, --description <text>",
"task description (multi-line: bash $'Line1\\nLine2', POSIX printf, PowerShell \"Line1`nLine2\")",
)
.option("--desc <text>", "alias for --description")
.option("-a, --assignee <assignee>")
.option("-s, --status <status>")
.option("-l, --labels <labels>")
.action(async (title: string, options) => {
const cwd = process.cwd();
const core = new Core(cwd);
await core.ensureConfigLoaded();
const id = await core.generateNextId();
const task = buildTaskFromOptions(id, title, options);
const filepath = await core.createDraft(task);
console.log(`Created draft ${id}`);
console.log(`File: ${filepath}`);
});
draftCmd
.command("archive <taskId>")
.description("archive a draft")
.action(async (taskId: string) => {
const cwd = process.cwd();
const core = new Core(cwd);
const success = await core.archiveDraft(taskId);
if (success) {
console.log(`Archived draft ${taskId}`);
} else {
console.error(`Draft ${taskId} not found.`);
}
});
draftCmd
.command("promote <taskId>")
.description("promote draft to task")
.action(async (taskId: string) => {
const cwd = process.cwd();
const core = new Core(cwd);
const success = await core.promoteDraft(taskId);
if (success) {
console.log(`Promoted draft ${taskId}`);
} else {
console.error(`Draft ${taskId} not found.`);
}
});
draftCmd
.command("view <taskId>")
.description("display draft details")
.option("--plain", "use plain text output instead of interactive UI")
.action(async (taskId: string, options) => {
const cwd = process.cwd();
const core = new Core(cwd);
const { getDraftPath } = await import("./utils/task-path.ts");
const filePath = await getDraftPath(taskId, core);
if (!filePath) {
console.error(`Draft ${taskId} not found.`);
return;
}
const draft = await core.filesystem.loadDraft(taskId);
if (!draft) {
console.error(`Draft ${taskId} not found.`);
return;
}
// Plain text output for AI agents
if (options && (("plain" in options && options.plain) || process.argv.includes("--plain"))) {
console.log(formatTaskPlainText(draft));
return;
}
// Use enhanced task viewer with detail focus
await viewTaskEnhanced(draft, { startWithDetailFocus: true, core });
});
draftCmd
.argument("[taskId]")
.option("--plain", "use plain text output")
.action(async (taskId: string | undefined, options: { plain?: boolean }) => {
if (!taskId) {
draftCmd.help();
return;
}
const cwd = process.cwd();
const core = new Core(cwd);
const { getDraftPath } = await import("./utils/task-path.ts");
const filePath = await getDraftPath(taskId, core);
if (!filePath) {
console.error(`Draft ${taskId} not found.`);
return;
}
const draft = await core.filesystem.loadDraft(taskId);
if (!draft) {
console.error(`Draft ${taskId} not found.`);
return;
}
// Plain text output for AI agents
if (options && (options.plain || process.argv.includes("--plain"))) {
console.log(formatTaskPlainText(draft, { filePathOverride: filePath }));
return;
}
// Use enhanced task viewer with detail focus
await viewTaskEnhanced(draft, { startWithDetailFocus: true, core });
});
const boardCmd = program.command("board");
function addBoardOptions(cmd: Command) {
return cmd
.option("-l, --layout <layout>", "board layout (horizontal|vertical)", "horizontal")
.option("--vertical", "use vertical layout (shortcut for --layout vertical)");
}
async function handleBoardView(options: { layout?: string; vertical?: boolean }) {
const cwd = process.cwd();
const core = new Core(cwd);
const config = await core.filesystem.loadConfig();
const _layout = options.vertical ? "vertical" : (options.layout as "horizontal" | "vertical") || "horizontal";
const _maxColumnWidth = config?.maxColumnWidth || 20; // Default for terminal display
const statuses = config?.statuses || [];
// Use unified view for Tab switching support
const { runUnifiedView } = await import("./ui/unified-view.ts");
await runUnifiedView({
core,
initialView: "kanban",
tasksLoader: async (updateProgress) => {
const tasks = await core.loadTasks((msg) => {
updateProgress(msg);
});
return {
tasks: tasks.map((t) => ({ ...t, status: t.status || "" })),
statuses,
};
},
});
}
addBoardOptions(boardCmd).description("display tasks in a Kanban board").action(handleBoardView);
addBoardOptions(boardCmd.command("view").description("display tasks in a Kanban board")).action(handleBoardView);
boardCmd
.command("export [filename]")
.description("export kanban board to markdown file")
.option("--force", "overwrite existing file without confirmation")
.option("--readme", "export to README.md with markers")
.option("--export-version <version>", "version to include in the export")
.action(async (filename, options) => {
const cwd = process.cwd();
const core = new Core(cwd);
const config = await core.filesystem.loadConfig();
const statuses = config?.statuses || [];
// Load tasks with progress tracking
const loadingScreen = await createLoadingScreen("Loading tasks for export");
let finalTasks: Task[];
try {
// Use the shared Core method for loading board tasks
finalTasks = await core.loadTasks((msg) => {
loadingScreen?.update(msg);
});
loadingScreen?.update(`Total tasks: ${finalTasks.length}`);
// Close loading screen before export
loadingScreen?.close();
// Get project name from config or use directory name
const { basename } = await import("node:path");
const projectName = config?.projectName || basename(cwd);
if (options.readme) {
// Use version from option if provided, otherwise use the CLI version
const exportVersion = options.exportVersion || version;
await updateReadmeWithBoard(finalTasks, statuses, projectName, exportVersion);
console.log("Updated README.md with Kanban board.");
} else {
// Use filename argument or default to Backlog.md
const outputFile = filename || "Backlog.md";
const outputPath = join(cwd, outputFile as string);
// Check if file exists and handle overwrite confirmation
const fileExists = await Bun.file(outputPath).exists();
if (fileExists && !options.force) {
const rl = createInterface({ input });
try {
const answer = await rl.question(`File "${outputPath}" already exists. Overwrite? (y/N): `);
if (!answer.toLowerCase().startsWith("y")) {
console.log("Export cancelled.");
return;
}
} finally {
rl.close();
}
}
await exportKanbanBoardToFile(finalTasks, statuses, outputPath, projectName, options.force || !fileExists);
console.log(`Exported board to ${outputPath}`);
}
} catch (error) {
loadingScreen?.close();
throw error;
}
});
const docCmd = program.command("doc");
docCmd
.command("create <title>")
.option("-p, --path <path>")
.option("-t, --type <type>")
.action(async (title: string, options) => {
const cwd = process.cwd();
const core = new Core(cwd);
const id = await generateNextDocId(core);
const document: DocType = {
id,
title: title as string,
type: (options.type || "other") as DocType["type"],
createdDate: new Date().toISOString().slice(0, 16).replace("T", " "),
rawContent: "",
};
await core.createDocument(document, undefined, options.path || "");
console.log(`Created document ${id}`);
});
docCmd
.command("list")
.option("--plain", "use plain text output instead of interactive UI")
.action(async (options) => {
const cwd = process.cwd();
const core = new Core(cwd);
const docs = await core.filesystem.listDocuments();
if (docs.length === 0) {
console.log("No docs found.");
return;
}
// Plain text output
const isPlainFlag = options.plain || process.argv.includes("--plain");
if (isPlainFlag) {
for (const d of docs) {
console.log(`${d.id} - ${d.title}`);
}
return;
}
// Interactive UI
const selected = await genericSelectList("Select a document", docs);
if (selected) {
// Show document details (recursive search)
const files = await Array.fromAsync(new Bun.Glob("**/*.md").scan({ cwd: core.filesystem.docsDir }));
const docFile = files.find(
(f) => f.startsWith(`${selected.id} -`) || f.endsWith(`/${selected.id}.md`) || f === `${selected.id}.md`,
);
if (docFile) {
const filePath = join(core.filesystem.docsDir, docFile);
const content = await Bun.file(filePath).text();
await scrollableViewer(content);
}
}
});
// Document view command
docCmd
.command("view <docId>")
.description("view a document")
.action(async (docId: string) => {
const cwd = process.cwd();
const core = new Core(cwd);
try {
const content = await core.getDocumentContent(docId);
if (content === null) {
console.error(`Document ${docId} not found.`);
return;
}
await scrollableViewer(content);
} catch {
console.error(`Document ${docId} not found.`);
}
});
const decisionCmd = program.command("decision");
decisionCmd
.command("create <title>")
.option("-s, --status <status>")
.action(async (title: string, options) => {
const cwd = process.cwd();
const core = new Core(cwd);
const id = await generateNextDecisionId(core);
const decision: Decision = {
id,
title: title as string,
date: new Date().toISOString().slice(0, 16).replace("T", " "),
status: (options.status || "proposed") as Decision["status"],
context: "",
decision: "",
consequences: "",
rawContent: "",
};
await core.createDecision(decision);
console.log(`Created decision ${id}`);
});
// Agents command group
const agentsCmd = program.command("agents");
agentsCmd
.description("manage agent instruction files")
.option(
"--update-instructions",
"update agent instruction files (CLAUDE.md, AGENTS.md, GEMINI.md, .github/copilot-instructions.md)",
)
.action(async (options) => {
if (!options.updateInstructions) {
agentsCmd.help();
return;
}
try {
const cwd = process.cwd();
const core = new Core(cwd);
// Check if backlog project is initialized
const config = await core.filesystem.loadConfig();
if (!config) {
console.error("No backlog project found. Initialize one first with: backlog init");
process.exit(1);
}
const _agentOptions = ["CLAUDE.md", "AGENTS.md", "GEMINI.md", ".github/copilot-instructions.md"] as const;
const { files: selected } = await prompts({
type: "multiselect",
name: "files",
message: "Select agent instruction files to update",
choices: [
{ title: "CLAUDE.md (Claude Code)", value: "CLAUDE.md" },
{ title: "AGENTS.md (Codex, Jules, Amp, Cursor, Zed, Warp, Aider, GitHub, RooCode)", value: "AGENTS.md" },
{ title: "GEMINI.md (Google CLI)", value: "GEMINI.md" },
{ title: "Copilot (GitHub Copilot)", value: ".github/copilot-instructions.md" },
],
hint: "Space to select, Enter to confirm\n",
instructions: false,
});
const files: AgentInstructionFile[] = (selected ?? []) as AgentInstructionFile[];
if (files.length > 0) {
// Get autoCommit setting from config
const config = await core.filesystem.loadConfig();
const shouldAutoCommit = config?.autoCommit ?? false;
await addAgentInstructions(cwd, core.gitOps, files, shouldAutoCommit);
console.log(`Updated ${files.length} agent instruction file(s): ${files.join(", ")}`);
} else {
console.log("No files selected for update.");
}
} catch (err) {
console.error("Failed to update agent instructions", err);
process.exitCode = 1;
}
});
// Config command group
const configCmd = program
.command("config")
.description("manage backlog configuration")
.action(async () => {
try {
const cwd = process.cwd();
const core = new Core(cwd);
const existingConfig = await core.filesystem.loadConfig();
if (!existingConfig) {
console.error("No backlog project found. Initialize one first with: backlog init");
process.exit(1);
}
const {
mergedConfig,
installClaudeAgent: shouldInstallClaude,
installShellCompletions: shouldInstallCompletions,
} = await configureAdvancedSettings(core);
let completionResult: CompletionInstallResult | null = null;
let completionError: string | null = null;
if (shouldInstallCompletions) {
try {
completionResult = await installCompletion();
} catch (error) {
completionError = error instanceof Error ? error.message : String(error);
}
}
console.log("\nAdvanced configuration updated.");
console.log(` Check active branches: ${mergedConfig.checkActiveBranches ?? true}`);
console.log(` Remote operations: ${mergedConfig.remoteOperations ?? true}`);
console.log(
` Zero-padded IDs: ${
typeof mergedConfig.zeroPaddedIds === "number" ? `${mergedConfig.zeroPaddedIds} digits` : "disabled"
}`,
);
console.log(` Web UI port: ${mergedConfig.defaultPort ?? 6420}`);
console.log(` Auto open browser: ${mergedConfig.autoOpenBrowser ?? true}`);
console.log(` Bypass git hooks: ${mergedConfig.bypassGitHooks ?? false}`);
console.log(` Auto commit: ${mergedConfig.autoCommit ?? false}`);
if (completionResult) {
console.log(` Shell completions: installed to ${completionResult.installPath}`);
} else if (completionError) {
console.log(" Shell completions: installation failed (see warning below)");
} else {
console.log(" Shell completions: skipped");
}
if (mergedConfig.defaultEditor) {
console.log(` Default editor: ${mergedConfig.defaultEditor}`);
}
if (shouldInstallClaude) {
await installClaudeAgent(cwd);
console.log("✓ Claude Code Backlog.md agent installed to .claude/agents/");
}
if (completionResult) {
const instructions = completionResult.instructions.trim();
console.log(
[
"",
`Shell completion script installed for ${completionResult.shell}.`,
` Path: ${completionResult.installPath}`,
instructions,
"",
].join("\n"),
);
} else if (completionError) {
const indentedError = completionError
.split("\n")
.map((line) => ` ${line}`)
.join("\n");
console.warn(
`⚠️ Shell completion installation failed:\n${indentedError}\n Run \`backlog completion install\` later to retry.\n`,
);
}
console.log("\nUse `backlog config list` to review all configuration values.");
} catch (err) {
console.error("Failed to update configuration", err);
process.exitCode = 1;
}
});
// Sequences command group
const sequenceCmd = program.command("sequence");
sequenceCmd
.description("list and inspect execution sequences computed from task dependencies")
.command("list")
.description("list sequences (interactive by default; use --plain for text output)")
.option("--plain", "use plain text output instead of interactive UI")
.action(async (options) => {
const cwd = process.cwd();
const core = new Core(cwd);
const tasks = await core.queryTasks();
// Exclude tasks marked as Done from sequences (case-insensitive)
const activeTasks = tasks.filter((t) => (t.status || "").toLowerCase() !== "done");
const { unsequenced, sequences } = computeSequences(activeTasks);
// Workaround for bun compile issue with commander options
const isPlainFlag = options.plain || process.argv.includes("--plain");
if (isPlainFlag) {
if (unsequenced.length > 0) {
console.log("Unsequenced:");
for (const t of unsequenced) {
console.log(` ${t.id} - ${t.title}`);
}
console.log("");
}
for (const seq of sequences) {
console.log(`Sequence ${seq.index}:`);
for (const t of seq.tasks) {
console.log(` ${t.id} - ${t.title}`);
}
console.log("");
}
return;
}
// Interactive default: TUI view (215.01 + 215.02 navigation/detail)
const { runSequencesView } = await import("./ui/sequences.ts");
await runSequencesView({ unsequenced, sequences }, core);
});
configCmd
.command("get <key>")
.description("get a configuration value")
.action(async (key: string) => {
try {
const cwd = process.cwd();
const core = new Core(cwd);
const config = await core.filesystem.loadConfig();
if (!config) {
console.error("No backlog project found. Initialize one first with: backlog init");
process.exit(1);
}
// Handle specific config keys
switch (key) {
case "defaultEditor":
if (config.defaultEditor) {
console.log(config.defaultEditor);
} else {
console.log("defaultEditor is not set");
process.exit(1);
}
break;
case "projectName":
console.log(config.projectName);
break;
case "defaultStatus":
console.log(config.defaultStatus || "");
break;
case "statuses":
console.log(config.statuses.join(", "));
break;
case "labels":
console.log(config.labels.join(", "));
break;
case "milestones":
console.log(config.milestones.join(", "));
break;
case "dateFormat":
console.log(config.dateFormat);
break;
case "maxColumnWidth":
console.log(config.maxColumnWidth?.toString() || "");
break;
case "defaultPort":
console.log(config.defaultPort?.toString() || "");
break;
case "autoOpenBrowser":
console.log(config.autoOpenBrowser?.toString() || "");
break;
case "remoteOperations":
console.log(config.remoteOperations?.toString() || "");
break;
case "autoCommit":
console.log(config.autoCommit?.toString() || "");
break;
case "bypassGitHooks":
console.log(config.bypassGitHooks?.toString() || "");
break;
case "zeroPaddedIds":
console.log(config.zeroPaddedIds?.toString() || "(disabled)");
break;
case "checkActiveBranches":
console.log(config.checkActiveBranches?.toString() || "true");
break;
case "activeBranchDays":
console.log(config.activeBranchDays?.toString() || "30");
break;
default:
console.error(`Unknown config key: ${key}`);
console.error(
"Available keys: defaultEditor, projectName, defaultStatus, statuses, labels, milestones, dateFormat, maxColumnWidth, defaultPort, autoOpenBrowser, remoteOperations, autoCommit, bypassGitHooks, zeroPaddedIds, checkActiveBranches, activeBranchDays",
);
process.exit(1);
}
} catch (err) {
console.error("Failed to get config value", err);
process.exitCode = 1;
}
});
configCmd
.command("set <key> <value>")
.description("set a configuration value")
.action(async (key: string, value: string) => {
try {
const cwd = process.cwd();
const core = new Core(cwd);
const config = await core.filesystem.loadConfig();
if (!config) {
console.error("No backlog project found. Initialize one first with: backlog init");
process.exit(1);
}
// Handle specific config keys
switch (key) {
case "defaultEditor": {
// Validate that the editor command exists
const { isEditorAvailable } = await import("./utils/editor.ts");
const isAvailable = await isEditorAvailable(value);
if (!isAvailable) {
console.error(`Editor command not found: ${value}`);
console.error("Please ensure the editor is installed and available in your PATH");
process.exit(1);
}
config.defaultEditor = value;
break;
}
case "projectName":
config.projectName = value;
break;
case "defaultStatus":
config.defaultStatus = value;
break;
case "dateFormat":
config.dateFormat = value;
break;
case "maxColumnWidth": {
const width = Number.parseInt(value, 10);
if (Number.isNaN(width) || width <= 0) {
console.error("maxColumnWidth must be a positive number");
process.exit(1);
}
config.maxColumnWidth = width;
break;
}
case "autoOpenBrowser": {
const boolValue = value.toLowerCase();
if (boolValue === "true" || boolValue === "1" || boolValue === "yes") {
config.autoOpenBrowser = true;
} else if (boolValue === "false" || boolValue === "0" || boolValue === "no") {
config.autoOpenBrowser = false;
} else {
console.error("autoOpenBrowser must be true or false");
process.exit(1);
}
break;
}
case "defaultPort": {
const port = Number.parseInt(value, 10);
if (Number.isNaN(port) || port < 1 || port > 65535) {
console.error("defaultPort must be a valid port number (1-65535)");
process.exit(1);
}
config.defaultPort = port;
break;
}
case "remoteOperations": {
const boolValue = value.toLowerCase();
if (boolValue === "true" || boolValue === "1" || boolValue === "yes") {
config.remoteOperations = true;
} else if (boolValue === "false" || boolValue === "0" || boolValue === "no") {
config.remoteOperations = false;
} else {
console.error("remoteOperations must be true or false");
process.exit(1);
}
break;
}
case "autoCommit": {
const boolValue = value.toLowerCase();
if (boolValue === "true" || boolValue === "1" || boolValue === "yes") {
config.autoCommit = true;
} else if (boolValue === "false" || boolValue === "0" || boolValue === "no") {
config.autoCommit = false;
} else {
console.error("autoCommit must be true or false");
process.exit(1);
}
break;
}
case "bypassGitHooks": {
const boolValue = value.toLowerCase();
if (boolValue === "true" || boolValue === "1" || boolValue === "yes") {
config.bypassGitHooks = true;
} else if (boolValue === "false" || boolValue === "0" || boolValue === "no") {
config.bypassGitHooks = false;
} else {
console.error("bypassGitHooks must be true or false");
process.exit(1);
}
break;
}
case "zeroPaddedIds": {
const padding = Number.parseInt(value, 10);
if (Number.isNaN(padding) || padding < 0) {
console.error("zeroPaddedIds must be a non-negative number.");
process.exit(1);
}
// Set to undefined if 0 to remove it from config
config.zeroPaddedIds = padding > 0 ? padding : undefined;
break;
}
case "checkActiveBranches": {
const boolValue = value.toLowerCase();
if (boolValue === "true" || boolValue === "1" || boolValue === "yes") {
config.checkActiveBranches = true;
} else if (boolValue === "false" || boolValue === "0" || boolValue === "no") {
config.checkActiveBranches = false;
} else {
console.error("checkActiveBranches must be true or false");
process.exit(1);
}
break;
}
case "activeBranchDays": {
const days = Number.parseInt(value, 10);
if (Number.isNaN(days) || days < 0) {
console.error("activeBranchDays must be a non-negative number.");
process.exit(1);
}
config.activeBranchDays = days;
break;
}
case "statuses":
case "labels":
case "milestones":
console.error(`${key} cannot be set directly. Use 'backlog config list-${key}' to view current values.`);
console.error("Array values should be edited in the config file directly.");
process.exit(1);
break;
default:
console.error(`Unknown config key: ${key}`);
console.error(
"Available keys: defaultEditor, projectName, defaultStatus, dateFormat, maxColumnWidth, autoOpenBrowser, defaultPort, remoteOperations, autoCommit, bypassGitHooks, zeroPaddedIds, checkActiveBranches, activeBranchDays",
);
process.exit(1);
}
await core.filesystem.saveConfig(config);
console.log(`Set ${key} = ${value}`);
} catch (err) {
console.error("Failed to set config value", err);
process.exitCode = 1;
}
});
configCmd
.command("list")
.description("list all configuration values")
.action(async () => {
try {
const cwd = process.cwd();
const core = new Core(cwd);
const config = await core.filesystem.loadConfig();
if (!config) {
console.error("No backlog project found. Initialize one first with: backlog init");
process.exit(1);
}
console.log("Configuration:");
console.log(` projectName: ${config.projectName}`);
console.log(` defaultEditor: ${config.defaultEditor || "(not set)"}`);
console.log(` defaultStatus: ${config.defaultStatus || "(not set)"}`);
console.log(` statuses: [${config.statuses.join(", ")}]`);
console.log(` labels: [${config.labels.join(", ")}]`);
console.log(` milestones: [${config.milestones.join(", ")}]`);
console.log(` dateFormat: ${config.dateFormat}`);
console.log(` maxColumnWidth: ${config.maxColumnWidth || "(not set)"}`);
console.log(` autoOpenBrowser: ${config.autoOpenBrowser ?? "(not set)"}`);
console.log(` defaultPort: ${config.defaultPort ?? "(not set)"}`);
console.log(` remoteOperations: ${config.remoteOperations ?? "(not set)"}`);
console.log(` autoCommit: ${config.autoCommit ?? "(not set)"}`);
console.log(` bypassGitHooks: ${config.bypassGitHooks ?? "(not set)"}`);
console.log(` zeroPaddedIds: ${config.zeroPaddedIds ?? "(disabled)"}`);
console.log(` checkActiveBranches: ${config.checkActiveBranches ?? "true"}`);
console.log(` activeBranchDays: ${config.activeBranchDays ?? "30"}`);
} catch (err) {
console.error("Failed to list config values", err);
process.exitCode = 1;
}
});
// Cleanup command for managing completed tasks
program
.command("cleanup")
.description("move completed tasks to completed folder based on age")
.action(async () => {
try {
const cwd = process.cwd();
const core = new Core(cwd);
// Check if backlog project is initialized
const config = await core.filesystem.loadConfig();
if (!config) {
console.error("No backlog project found. Initialize one first with: backlog init");
process.exit(1);
}
// Get all Done tasks
const tasks = await core.queryTasks();
const doneTasks = tasks.filter((task) => task.status === "Done");
if (doneTasks.length === 0) {
console.log("No completed tasks found to clean up.");
return;
}
console.log(`Found ${doneTasks.length} tasks marked as Done.`);
const ageOptions = [
{ title: "1 day", value: 1 },
{ title: "1 week", value: 7 },
{ title: "2 weeks", value: 14 },
{ title: "3 weeks", value: 21 },
{ title: "1 month", value: 30 },
{ title: "3 months", value: 90 },
{ title: "1 year", value: 365 },
];
const { selectedAge } = await prompts({
type: "select",
name: "selectedAge",
message: "Move tasks to completed folder if they are older than:",
choices: ageOptions,
hint: "Tasks in completed folder are still accessible but won't clutter the main board",
});
if (selectedAge === undefined) {
console.log("Cleanup cancelled.");
return;
}
// Get tasks older than selected period
const tasksToMove = await core.getDoneTasksByAge(selectedAge);
if (tasksToMove.length === 0) {
console.log(`No tasks found that are older than ${ageOptions.find((o) => o.value === selectedAge)?.title}.`);
return;
}
console.log(
`\nFound ${tasksToMove.length} tasks older than ${ageOptions.find((o) => o.value === selectedAge)?.title}:`,
);
for (const task of tasksToMove.slice(0, 5)) {
const date = task.updatedDate || task.createdDate;
console.log(` - ${task.id}: ${task.title} (${date})`);
}
if (tasksToMove.length > 5) {
console.log(` ... and ${tasksToMove.length - 5} more`);
}
const { confirmed } = await prompts({
type: "confirm",
name: "confirmed",
message: `Move ${tasksToMove.length} tasks to completed folder?`,
initial: false,
});
if (!confirmed) {
console.log("Cleanup cancelled.");
return;
}
// Move tasks to completed folder
let successCount = 0;
const shouldAutoCommit = config.autoCommit ?? false;
console.log("Moving tasks...");
const movedTasks: Array<{ fromPath: string; toPath: string; taskId: string }> = [];
for (const task of tasksToMove) {
const fromPath = task.filePath ?? (await core.getTask(task.id))?.filePath ?? null;
if (!fromPath) {
console.error(`Failed to locate file for task ${task.id}`);
continue;
}
const taskFilename = basename(fromPath);
const toPath = join(core.filesystem.completedDir, taskFilename);
const success = await core.completeTask(task.id);
if (success) {
successCount++;
movedTasks.push({ fromPath, toPath, taskId: task.id });
} else {
console.error(`Failed to move task ${task.id}`);
}
}
// If autoCommit is disabled, stage the moves so Git recognizes them
if (successCount > 0 && !shouldAutoCommit) {
console.log("Staging file moves for Git...");
for (const { fromPath, toPath } of movedTasks) {
try {
await core.gitOps.stageFileMove(fromPath, toPath);
} catch (error) {
console.warn(`Warning: Could not stage move for Git: ${error}`);
}
}
}
console.log(`Successfully moved ${successCount} of ${tasksToMove.length} tasks to completed folder.`);
if (successCount > 0 && !shouldAutoCommit) {
console.log("Files have been staged. To commit: git commit -m 'cleanup: Move completed tasks'");
}
} catch (err) {
console.error("Failed to run cleanup", err);
process.exitCode = 1;
}
});
// Browser command for web UI
program
.command("browser")
.description("open browser interface for task management (press Ctrl+C or Cmd+C to stop)")
.option("-p, --port <port>", "port to run server on")
.option("--no-open", "don't automatically open browser")
.action(async (options) => {
try {
const cwd = process.cwd();
const { BacklogServer } = await import("./server/index.ts");
const server = new BacklogServer(cwd);
// Load config to get default port
const core = new Core(cwd);
const config = await core.filesystem.loadConfig();
const defaultPort = config?.defaultPort ?? 6420;
const port = Number.parseInt(options.port || defaultPort.toString(), 10);
if (Number.isNaN(port) || port < 1 || port > 65535) {
console.error("Invalid port number. Must be between 1 and 65535.");
process.exit(1);
}
await server.start(port, options.open !== false);
// Graceful shutdown on common termination signals (register once)
let shuttingDown = false;
const shutdown = async (signal: string) => {
if (shuttingDown) return;
shuttingDown = true;
console.log(`\nReceived ${signal}. Shutting down server...`);
try {
const stopPromise = server.stop();
const timeout = new Promise<void>((resolve) => setTimeout(resolve, 1500));
await Promise.race([stopPromise, timeout]);
} finally {
process.exit(0);
}
};
process.once("SIGINT", () => void shutdown("SIGINT"));
process.once("SIGTERM", () => void shutdown("SIGTERM"));
process.once("SIGQUIT", () => void shutdown("SIGQUIT"));
} catch (err) {
console.error("Failed to start browser interface", err);
process.exitCode = 1;
}
});
// Overview command for statistics
program
.command("overview")
.description("display project statistics and metrics")
.action(async () => {
try {
const cwd = process.cwd();
const core = new Core(cwd);
const config = await core.filesystem.loadConfig();
if (!config) {
console.error("No backlog project found. Initialize one first with: backlog init");
process.exit(1);
}
// Import and run the overview command
const { runOverviewCommand } = await import("./commands/overview.ts");
await runOverviewCommand(core);
} catch (err) {
console.error("Failed to display project overview", err);
process.exitCode = 1;
}
});
// Aggregator command for multi-project view
program
.command("aggregator")
.description("start the multi-project backlog aggregator with real-time updates")
.option("-p, --port <port>", "port to run server on", "6420")
.option("--paths <paths>", "comma-separated list of directories to scan for backlog projects")
.action(async (options) => {
try {
const { BacklogAggregator } = await import("./aggregator/index.ts");
const port = Number.parseInt(options.port, 10) || 6420;
const scanPaths = options.paths ? options.paths.split(",").map((p: string) => p.trim()) : [process.cwd()];
const aggregator = new BacklogAggregator({ port, scanPaths });
const shutdown = async (signal: string) => {
console.log(`\n${signal} received, shutting down...`);
try {
const stopPromise = aggregator.stop();
const timeout = new Promise<void>((resolve) => setTimeout(resolve, 3000));
await Promise.race([stopPromise, timeout]);
} finally {
process.exit(0);
}
};
process.once("SIGINT", () => void shutdown("SIGINT"));
process.once("SIGTERM", () => void shutdown("SIGTERM"));
process.once("SIGQUIT", () => void shutdown("SIGQUIT"));
await aggregator.start();
} catch (err) {
console.error("Failed to start aggregator", err);
process.exitCode = 1;
}
});
// Completion command group
registerCompletionCommand(program);
// MCP command group
registerMcpCommand(program);
program.parseAsync(process.argv).finally(() => {
// Restore BUN_OPTIONS after CLI parsing completes so it's available for subsequent commands
if (originalBunOptions) {
process.env.BUN_OPTIONS = originalBunOptions;
}
});