Files
claude-config/get-shit-done/bin/lib/commands.cjs
Yaojia Wang 2876cca8fe chore: initial backup of Claude Code configuration
Includes: CLAUDE.md, settings.json, agents, commands, rules, skills,
hooks, contexts, evals, get-shit-done, plugin configs (installed list
and marketplace sources). Excludes credentials, runtime caches,
telemetry, session data, and plugin binary cache.
2026-03-24 22:26:05 +01:00

710 lines
24 KiB
JavaScript

/**
* Commands — Standalone utility commands
*/
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const { safeReadFile, loadConfig, isGitIgnored, execGit, normalizePhaseName, comparePhaseNum, getArchivedPhaseDirs, generateSlugInternal, getMilestoneInfo, getMilestonePhaseFilter, resolveModelInternal, stripShippedMilestones, extractCurrentMilestone, toPosixPath, output, error, findPhaseInternal } = require('./core.cjs');
const { extractFrontmatter } = require('./frontmatter.cjs');
const { MODEL_PROFILES } = require('./model-profiles.cjs');
function cmdGenerateSlug(text, raw) {
if (!text) {
error('text required for slug generation');
}
const slug = text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
const result = { slug };
output(result, raw, slug);
}
function cmdCurrentTimestamp(format, raw) {
const now = new Date();
let result;
switch (format) {
case 'date':
result = now.toISOString().split('T')[0];
break;
case 'filename':
result = now.toISOString().replace(/:/g, '-').replace(/\..+/, '');
break;
case 'full':
default:
result = now.toISOString();
break;
}
output({ timestamp: result }, raw, result);
}
function cmdListTodos(cwd, area, raw) {
const pendingDir = path.join(cwd, '.planning', 'todos', 'pending');
let count = 0;
const todos = [];
try {
const files = fs.readdirSync(pendingDir).filter(f => f.endsWith('.md'));
for (const file of files) {
try {
const content = fs.readFileSync(path.join(pendingDir, file), 'utf-8');
const createdMatch = content.match(/^created:\s*(.+)$/m);
const titleMatch = content.match(/^title:\s*(.+)$/m);
const areaMatch = content.match(/^area:\s*(.+)$/m);
const todoArea = areaMatch ? areaMatch[1].trim() : 'general';
// Apply area filter if specified
if (area && todoArea !== area) continue;
count++;
todos.push({
file,
created: createdMatch ? createdMatch[1].trim() : 'unknown',
title: titleMatch ? titleMatch[1].trim() : 'Untitled',
area: todoArea,
path: toPosixPath(path.join('.planning', 'todos', 'pending', file)),
});
} catch {}
}
} catch {}
const result = { count, todos };
output(result, raw, count.toString());
}
function cmdVerifyPathExists(cwd, targetPath, raw) {
if (!targetPath) {
error('path required for verification');
}
const fullPath = path.isAbsolute(targetPath) ? targetPath : path.join(cwd, targetPath);
try {
const stats = fs.statSync(fullPath);
const type = stats.isDirectory() ? 'directory' : stats.isFile() ? 'file' : 'other';
const result = { exists: true, type };
output(result, raw, 'true');
} catch {
const result = { exists: false, type: null };
output(result, raw, 'false');
}
}
function cmdHistoryDigest(cwd, raw) {
const phasesDir = path.join(cwd, '.planning', 'phases');
const digest = { phases: {}, decisions: [], tech_stack: new Set() };
// Collect all phase directories: archived + current
const allPhaseDirs = [];
// Add archived phases first (oldest milestones first)
const archived = getArchivedPhaseDirs(cwd);
for (const a of archived) {
allPhaseDirs.push({ name: a.name, fullPath: a.fullPath, milestone: a.milestone });
}
// Add current phases
if (fs.existsSync(phasesDir)) {
try {
const currentDirs = fs.readdirSync(phasesDir, { withFileTypes: true })
.filter(e => e.isDirectory())
.map(e => e.name)
.sort();
for (const dir of currentDirs) {
allPhaseDirs.push({ name: dir, fullPath: path.join(phasesDir, dir), milestone: null });
}
} catch {}
}
if (allPhaseDirs.length === 0) {
digest.tech_stack = [];
output(digest, raw);
return;
}
try {
for (const { name: dir, fullPath: dirPath } of allPhaseDirs) {
const summaries = fs.readdirSync(dirPath).filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
for (const summary of summaries) {
try {
const content = fs.readFileSync(path.join(dirPath, summary), 'utf-8');
const fm = extractFrontmatter(content);
const phaseNum = fm.phase || dir.split('-')[0];
if (!digest.phases[phaseNum]) {
digest.phases[phaseNum] = {
name: fm.name || dir.split('-').slice(1).join(' ') || 'Unknown',
provides: new Set(),
affects: new Set(),
patterns: new Set(),
};
}
// Merge provides
if (fm['dependency-graph'] && fm['dependency-graph'].provides) {
fm['dependency-graph'].provides.forEach(p => digest.phases[phaseNum].provides.add(p));
} else if (fm.provides) {
fm.provides.forEach(p => digest.phases[phaseNum].provides.add(p));
}
// Merge affects
if (fm['dependency-graph'] && fm['dependency-graph'].affects) {
fm['dependency-graph'].affects.forEach(a => digest.phases[phaseNum].affects.add(a));
}
// Merge patterns
if (fm['patterns-established']) {
fm['patterns-established'].forEach(p => digest.phases[phaseNum].patterns.add(p));
}
// Merge decisions
if (fm['key-decisions']) {
fm['key-decisions'].forEach(d => {
digest.decisions.push({ phase: phaseNum, decision: d });
});
}
// Merge tech stack
if (fm['tech-stack'] && fm['tech-stack'].added) {
fm['tech-stack'].added.forEach(t => digest.tech_stack.add(typeof t === 'string' ? t : t.name));
}
} catch (e) {
// Skip malformed summaries
}
}
}
// Convert Sets to Arrays for JSON output
Object.keys(digest.phases).forEach(p => {
digest.phases[p].provides = [...digest.phases[p].provides];
digest.phases[p].affects = [...digest.phases[p].affects];
digest.phases[p].patterns = [...digest.phases[p].patterns];
});
digest.tech_stack = [...digest.tech_stack];
output(digest, raw);
} catch (e) {
error('Failed to generate history digest: ' + e.message);
}
}
function cmdResolveModel(cwd, agentType, raw) {
if (!agentType) {
error('agent-type required');
}
const config = loadConfig(cwd);
const profile = config.model_profile || 'balanced';
const model = resolveModelInternal(cwd, agentType);
const agentModels = MODEL_PROFILES[agentType];
const result = agentModels
? { model, profile }
: { model, profile, unknown_agent: true };
output(result, raw, model);
}
function cmdCommit(cwd, message, files, raw, amend) {
if (!message && !amend) {
error('commit message required');
}
const config = loadConfig(cwd);
// Check commit_docs config
if (!config.commit_docs) {
const result = { committed: false, hash: null, reason: 'skipped_commit_docs_false' };
output(result, raw, 'skipped');
return;
}
// Check if .planning is gitignored
if (isGitIgnored(cwd, '.planning')) {
const result = { committed: false, hash: null, reason: 'skipped_gitignored' };
output(result, raw, 'skipped');
return;
}
// Stage files
const filesToStage = files && files.length > 0 ? files : ['.planning/'];
for (const file of filesToStage) {
execGit(cwd, ['add', file]);
}
// Commit
const commitArgs = amend ? ['commit', '--amend', '--no-edit'] : ['commit', '-m', message];
const commitResult = execGit(cwd, commitArgs);
if (commitResult.exitCode !== 0) {
if (commitResult.stdout.includes('nothing to commit') || commitResult.stderr.includes('nothing to commit')) {
const result = { committed: false, hash: null, reason: 'nothing_to_commit' };
output(result, raw, 'nothing');
return;
}
const result = { committed: false, hash: null, reason: 'nothing_to_commit', error: commitResult.stderr };
output(result, raw, 'nothing');
return;
}
// Get short hash
const hashResult = execGit(cwd, ['rev-parse', '--short', 'HEAD']);
const hash = hashResult.exitCode === 0 ? hashResult.stdout : null;
const result = { committed: true, hash, reason: 'committed' };
output(result, raw, hash || 'committed');
}
function cmdSummaryExtract(cwd, summaryPath, fields, raw) {
if (!summaryPath) {
error('summary-path required for summary-extract');
}
const fullPath = path.join(cwd, summaryPath);
if (!fs.existsSync(fullPath)) {
output({ error: 'File not found', path: summaryPath }, raw);
return;
}
const content = fs.readFileSync(fullPath, 'utf-8');
const fm = extractFrontmatter(content);
// Parse key-decisions into structured format
const parseDecisions = (decisionsList) => {
if (!decisionsList || !Array.isArray(decisionsList)) return [];
return decisionsList.map(d => {
const colonIdx = d.indexOf(':');
if (colonIdx > 0) {
return {
summary: d.substring(0, colonIdx).trim(),
rationale: d.substring(colonIdx + 1).trim(),
};
}
return { summary: d, rationale: null };
});
};
// Build full result
const fullResult = {
path: summaryPath,
one_liner: fm['one-liner'] || null,
key_files: fm['key-files'] || [],
tech_added: (fm['tech-stack'] && fm['tech-stack'].added) || [],
patterns: fm['patterns-established'] || [],
decisions: parseDecisions(fm['key-decisions']),
requirements_completed: fm['requirements-completed'] || [],
};
// If fields specified, filter to only those fields
if (fields && fields.length > 0) {
const filtered = { path: summaryPath };
for (const field of fields) {
if (fullResult[field] !== undefined) {
filtered[field] = fullResult[field];
}
}
output(filtered, raw);
return;
}
output(fullResult, raw);
}
async function cmdWebsearch(query, options, raw) {
const apiKey = process.env.BRAVE_API_KEY;
if (!apiKey) {
// No key = silent skip, agent falls back to built-in WebSearch
output({ available: false, reason: 'BRAVE_API_KEY not set' }, raw, '');
return;
}
if (!query) {
output({ available: false, error: 'Query required' }, raw, '');
return;
}
const params = new URLSearchParams({
q: query,
count: String(options.limit || 10),
country: 'us',
search_lang: 'en',
text_decorations: 'false'
});
if (options.freshness) {
params.set('freshness', options.freshness);
}
try {
const response = await fetch(
`https://api.search.brave.com/res/v1/web/search?${params}`,
{
headers: {
'Accept': 'application/json',
'X-Subscription-Token': apiKey
}
}
);
if (!response.ok) {
output({ available: false, error: `API error: ${response.status}` }, raw, '');
return;
}
const data = await response.json();
const results = (data.web?.results || []).map(r => ({
title: r.title,
url: r.url,
description: r.description,
age: r.age || null
}));
output({
available: true,
query,
count: results.length,
results
}, raw, results.map(r => `${r.title}\n${r.url}\n${r.description}`).join('\n\n'));
} catch (err) {
output({ available: false, error: err.message }, raw, '');
}
}
function cmdProgressRender(cwd, format, raw) {
const phasesDir = path.join(cwd, '.planning', 'phases');
const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md');
const milestone = getMilestoneInfo(cwd);
const phases = [];
let totalPlans = 0;
let totalSummaries = 0;
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b));
for (const dir of dirs) {
const dm = dir.match(/^(\d+(?:\.\d+)*)-?(.*)/);
const phaseNum = dm ? dm[1] : dir;
const phaseName = dm && dm[2] ? dm[2].replace(/-/g, ' ') : '';
const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length;
const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length;
totalPlans += plans;
totalSummaries += summaries;
let status;
if (plans === 0) status = 'Pending';
else if (summaries >= plans) status = 'Complete';
else if (summaries > 0) status = 'In Progress';
else status = 'Planned';
phases.push({ number: phaseNum, name: phaseName, plans, summaries, status });
}
} catch {}
const percent = totalPlans > 0 ? Math.min(100, Math.round((totalSummaries / totalPlans) * 100)) : 0;
if (format === 'table') {
// Render markdown table
const barWidth = 10;
const filled = Math.round((percent / 100) * barWidth);
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
let out = `# ${milestone.version} ${milestone.name}\n\n`;
out += `**Progress:** [${bar}] ${totalSummaries}/${totalPlans} plans (${percent}%)\n\n`;
out += `| Phase | Name | Plans | Status |\n`;
out += `|-------|------|-------|--------|\n`;
for (const p of phases) {
out += `| ${p.number} | ${p.name} | ${p.summaries}/${p.plans} | ${p.status} |\n`;
}
output({ rendered: out }, raw, out);
} else if (format === 'bar') {
const barWidth = 20;
const filled = Math.round((percent / 100) * barWidth);
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
const text = `[${bar}] ${totalSummaries}/${totalPlans} plans (${percent}%)`;
output({ bar: text, percent, completed: totalSummaries, total: totalPlans }, raw, text);
} else {
// JSON format
output({
milestone_version: milestone.version,
milestone_name: milestone.name,
phases,
total_plans: totalPlans,
total_summaries: totalSummaries,
percent,
}, raw);
}
}
function cmdTodoComplete(cwd, filename, raw) {
if (!filename) {
error('filename required for todo complete');
}
const pendingDir = path.join(cwd, '.planning', 'todos', 'pending');
const completedDir = path.join(cwd, '.planning', 'todos', 'completed');
const sourcePath = path.join(pendingDir, filename);
if (!fs.existsSync(sourcePath)) {
error(`Todo not found: ${filename}`);
}
// Ensure completed directory exists
fs.mkdirSync(completedDir, { recursive: true });
// Read, add completion timestamp, move
let content = fs.readFileSync(sourcePath, 'utf-8');
const today = new Date().toISOString().split('T')[0];
content = `completed: ${today}\n` + content;
fs.writeFileSync(path.join(completedDir, filename), content, 'utf-8');
fs.unlinkSync(sourcePath);
output({ completed: true, file: filename, date: today }, raw, 'completed');
}
function cmdScaffold(cwd, type, options, raw) {
const { phase, name } = options;
const padded = phase ? normalizePhaseName(phase) : '00';
const today = new Date().toISOString().split('T')[0];
// Find phase directory
const phaseInfo = phase ? findPhaseInternal(cwd, phase) : null;
const phaseDir = phaseInfo ? path.join(cwd, phaseInfo.directory) : null;
if (phase && !phaseDir && type !== 'phase-dir') {
error(`Phase ${phase} directory not found`);
}
let filePath, content;
switch (type) {
case 'context': {
filePath = path.join(phaseDir, `${padded}-CONTEXT.md`);
content = `---\nphase: "${padded}"\nname: "${name || phaseInfo?.phase_name || 'Unnamed'}"\ncreated: ${today}\n---\n\n# Phase ${phase}: ${name || phaseInfo?.phase_name || 'Unnamed'} — Context\n\n## Decisions\n\n_Decisions will be captured during /gsd:discuss-phase ${phase}_\n\n## Discretion Areas\n\n_Areas where the executor can use judgment_\n\n## Deferred Ideas\n\n_Ideas to consider later_\n`;
break;
}
case 'uat': {
filePath = path.join(phaseDir, `${padded}-UAT.md`);
content = `---\nphase: "${padded}"\nname: "${name || phaseInfo?.phase_name || 'Unnamed'}"\ncreated: ${today}\nstatus: pending\n---\n\n# Phase ${phase}: ${name || phaseInfo?.phase_name || 'Unnamed'} — User Acceptance Testing\n\n## Test Results\n\n| # | Test | Status | Notes |\n|---|------|--------|-------|\n\n## Summary\n\n_Pending UAT_\n`;
break;
}
case 'verification': {
filePath = path.join(phaseDir, `${padded}-VERIFICATION.md`);
content = `---\nphase: "${padded}"\nname: "${name || phaseInfo?.phase_name || 'Unnamed'}"\ncreated: ${today}\nstatus: pending\n---\n\n# Phase ${phase}: ${name || phaseInfo?.phase_name || 'Unnamed'} — Verification\n\n## Goal-Backward Verification\n\n**Phase Goal:** [From ROADMAP.md]\n\n## Checks\n\n| # | Requirement | Status | Evidence |\n|---|------------|--------|----------|\n\n## Result\n\n_Pending verification_\n`;
break;
}
case 'phase-dir': {
if (!phase || !name) {
error('phase and name required for phase-dir scaffold');
}
const slug = generateSlugInternal(name);
const dirName = `${padded}-${slug}`;
const phasesParent = path.join(cwd, '.planning', 'phases');
fs.mkdirSync(phasesParent, { recursive: true });
const dirPath = path.join(phasesParent, dirName);
fs.mkdirSync(dirPath, { recursive: true });
output({ created: true, directory: `.planning/phases/${dirName}`, path: dirPath }, raw, dirPath);
return;
}
default:
error(`Unknown scaffold type: ${type}. Available: context, uat, verification, phase-dir`);
}
if (fs.existsSync(filePath)) {
output({ created: false, reason: 'already_exists', path: filePath }, raw, 'exists');
return;
}
fs.writeFileSync(filePath, content, 'utf-8');
const relPath = toPosixPath(path.relative(cwd, filePath));
output({ created: true, path: relPath }, raw, relPath);
}
function cmdStats(cwd, format, raw) {
const phasesDir = path.join(cwd, '.planning', 'phases');
const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md');
const reqPath = path.join(cwd, '.planning', 'REQUIREMENTS.md');
const statePath = path.join(cwd, '.planning', 'STATE.md');
const milestone = getMilestoneInfo(cwd);
const isDirInMilestone = getMilestonePhaseFilter(cwd);
// Phase & plan stats (reuse progress pattern)
const phasesByNumber = new Map();
let totalPlans = 0;
let totalSummaries = 0;
try {
const roadmapContent = extractCurrentMilestone(fs.readFileSync(roadmapPath, 'utf-8'), cwd);
const headingPattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
let match;
while ((match = headingPattern.exec(roadmapContent)) !== null) {
phasesByNumber.set(match[1], {
number: match[1],
name: match[2].replace(/\(INSERTED\)/i, '').trim(),
plans: 0,
summaries: 0,
status: 'Not Started',
});
}
} catch {}
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
const dirs = entries
.filter(e => e.isDirectory())
.map(e => e.name)
.filter(isDirInMilestone)
.sort((a, b) => comparePhaseNum(a, b));
for (const dir of dirs) {
const dm = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i);
const phaseNum = dm ? dm[1] : dir;
const phaseName = dm && dm[2] ? dm[2].replace(/-/g, ' ') : '';
const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length;
const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length;
totalPlans += plans;
totalSummaries += summaries;
let status;
if (plans === 0) status = 'Not Started';
else if (summaries >= plans) status = 'Complete';
else if (summaries > 0) status = 'In Progress';
else status = 'Planned';
const existing = phasesByNumber.get(phaseNum);
phasesByNumber.set(phaseNum, {
number: phaseNum,
name: existing?.name || phaseName,
plans,
summaries,
status,
});
}
} catch {}
const phases = [...phasesByNumber.values()].sort((a, b) => comparePhaseNum(a.number, b.number));
const completedPhases = phases.filter(p => p.status === 'Complete').length;
const planPercent = totalPlans > 0 ? Math.min(100, Math.round((totalSummaries / totalPlans) * 100)) : 0;
const percent = phases.length > 0 ? Math.min(100, Math.round((completedPhases / phases.length) * 100)) : 0;
// Requirements stats
let requirementsTotal = 0;
let requirementsComplete = 0;
try {
if (fs.existsSync(reqPath)) {
const reqContent = fs.readFileSync(reqPath, 'utf-8');
const checked = reqContent.match(/^- \[x\] \*\*/gm);
const unchecked = reqContent.match(/^- \[ \] \*\*/gm);
requirementsComplete = checked ? checked.length : 0;
requirementsTotal = requirementsComplete + (unchecked ? unchecked.length : 0);
}
} catch {}
// Last activity from STATE.md
let lastActivity = null;
try {
if (fs.existsSync(statePath)) {
const stateContent = fs.readFileSync(statePath, 'utf-8');
const activityMatch = stateContent.match(/^last_activity:\s*(.+)$/im)
|| stateContent.match(/\*\*Last Activity:\*\*\s*(.+)/i)
|| stateContent.match(/^Last Activity:\s*(.+)$/im)
|| stateContent.match(/^Last activity:\s*(.+)$/im);
if (activityMatch) lastActivity = activityMatch[1].trim();
}
} catch {}
// Git stats
let gitCommits = 0;
let gitFirstCommitDate = null;
const commitCount = execGit(cwd, ['rev-list', '--count', 'HEAD']);
if (commitCount.exitCode === 0) {
gitCommits = parseInt(commitCount.stdout, 10) || 0;
}
const rootHash = execGit(cwd, ['rev-list', '--max-parents=0', 'HEAD']);
if (rootHash.exitCode === 0 && rootHash.stdout) {
const firstCommit = rootHash.stdout.split('\n')[0].trim();
const firstDate = execGit(cwd, ['show', '-s', '--format=%as', firstCommit]);
if (firstDate.exitCode === 0) {
gitFirstCommitDate = firstDate.stdout || null;
}
}
const result = {
milestone_version: milestone.version,
milestone_name: milestone.name,
phases,
phases_completed: completedPhases,
phases_total: phases.length,
total_plans: totalPlans,
total_summaries: totalSummaries,
percent,
plan_percent: planPercent,
requirements_total: requirementsTotal,
requirements_complete: requirementsComplete,
git_commits: gitCommits,
git_first_commit_date: gitFirstCommitDate,
last_activity: lastActivity,
};
if (format === 'table') {
const barWidth = 10;
const filled = Math.round((percent / 100) * barWidth);
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
let out = `# ${milestone.version} ${milestone.name} \u2014 Statistics\n\n`;
out += `**Progress:** [${bar}] ${completedPhases}/${phases.length} phases (${percent}%)\n`;
if (totalPlans > 0) {
out += `**Plans:** ${totalSummaries}/${totalPlans} complete (${planPercent}%)\n`;
}
out += `**Phases:** ${completedPhases}/${phases.length} complete\n`;
if (requirementsTotal > 0) {
out += `**Requirements:** ${requirementsComplete}/${requirementsTotal} complete\n`;
}
out += '\n';
out += `| Phase | Name | Plans | Completed | Status |\n`;
out += `|-------|------|-------|-----------|--------|\n`;
for (const p of phases) {
out += `| ${p.number} | ${p.name} | ${p.plans} | ${p.summaries} | ${p.status} |\n`;
}
if (gitCommits > 0) {
out += `\n**Git:** ${gitCommits} commits`;
if (gitFirstCommitDate) out += ` (since ${gitFirstCommitDate})`;
out += '\n';
}
if (lastActivity) out += `**Last activity:** ${lastActivity}\n`;
output({ rendered: out }, raw, out);
} else {
output(result, raw);
}
}
module.exports = {
cmdGenerateSlug,
cmdCurrentTimestamp,
cmdListTodos,
cmdVerifyPathExists,
cmdHistoryDigest,
cmdResolveModel,
cmdCommit,
cmdSummaryExtract,
cmdWebsearch,
cmdProgressRender,
cmdTodoComplete,
cmdScaffold,
cmdStats,
};