@nakedved/ano
Version:
Claude Code plugin for collaborative annotation and review
288 lines (287 loc) • 12.6 kB
JavaScript
/**
* Annotation management commands
*
* Additional commands for managing annotations.
*
* Usage:
* ano reply <file> <id> "message" Add a threaded reply
* ano delete <file> <id> Delete an annotation
* ano sync <file> Sync annotations after file changes
* ano export <file> [output] Export annotations to JSON
* ano import <file> <source> Import annotations from JSON
*/
import { Command } from 'commander';
import chalk from 'chalk';
import { resolve, basename } from 'node:path';
import { readFile, writeFile } from 'node:fs/promises';
import { readAnnotationFile, writeAnnotationFile, getOrCreateAnnotationFile, addReply, deleteAnnotation, } from '../../core/annotations.js';
import { syncAnnotations } from '../../core/anchoring.js';
import { getAuthorString } from '../../core/config.js';
// ============================================
// reply - Add threaded reply
// ============================================
export const replyCommand = new Command('reply')
.description('Add a threaded reply to an annotation')
.argument('<file>', 'File containing the annotation')
.argument('<id>', 'Annotation ID (partial OK)')
.argument('<message>', 'Reply message')
.action(async (file, id, message) => {
const filePath = resolve(file);
const annotationFile = await readAnnotationFile(filePath);
if (!annotationFile) {
console.error(chalk.red('No annotations found for this file'));
process.exit(1);
}
// Find annotation by partial ID
const annotation = annotationFile.annotations.find((a) => a.id.startsWith(id) || a.id.includes(id));
if (!annotation) {
console.error(chalk.red(`Annotation not found: ${id}`));
console.error(chalk.dim(' Use "ano list <file>" to see available IDs'));
process.exit(1);
}
const author = await getAuthorString();
const reply = await addReply(filePath, annotation.id, author, message);
console.log(chalk.green('Reply added'));
console.log(chalk.dim(` To: #${annotation.id.slice(0, 8)}`));
console.log(chalk.dim(` "${message}"`));
});
// ============================================
// delete - Delete annotation
// ============================================
export const deleteCommand = new Command('delete')
.description('Delete an annotation')
.argument('<file>', 'File containing the annotation')
.argument('<id>', 'Annotation ID (partial OK)')
.option('-f, --force', 'Skip confirmation')
.action(async (file, id, options) => {
const filePath = resolve(file);
const annotationFile = await readAnnotationFile(filePath);
if (!annotationFile) {
console.error(chalk.red('No annotations found for this file'));
process.exit(1);
}
// Find annotation by partial ID
const annotation = annotationFile.annotations.find((a) => a.id.startsWith(id) || a.id.includes(id));
if (!annotation) {
console.error(chalk.red(`Annotation not found: ${id}`));
process.exit(1);
}
// Show what will be deleted
if (!options.force) {
console.log(chalk.yellow('Will delete:'));
console.log(` ${chalk.dim(`#${annotation.id.slice(0, 8)}`)} L${annotation.anchor.line}`);
console.log(` ${annotation.type}: "${annotation.content.slice(0, 50)}..."`);
console.log();
console.log(chalk.dim('Use --force to skip this confirmation'));
// In a real CLI you'd prompt for confirmation
// For now, require --force flag
process.exit(1);
}
await deleteAnnotation(filePath, annotation.id);
console.log(chalk.green('Annotation deleted'));
console.log(chalk.dim(` #${annotation.id.slice(0, 8)}`));
});
// ============================================
// sync - Sync annotations after file changes
// ============================================
export const syncCommand = new Command('sync')
.description('Sync annotation positions after file changes')
.argument('<file>', 'File to sync annotations for')
.option('--dry-run', 'Show what would change without saving')
.action(async (file, options) => {
const filePath = resolve(file);
const annotationFile = await readAnnotationFile(filePath);
if (!annotationFile) {
console.log(chalk.dim('No annotations to sync'));
return;
}
// Read current file content
const content = await readFile(filePath, 'utf-8');
// Sync annotations
const { synced, orphaned } = syncAnnotations(annotationFile.annotations, content);
// Report results
console.log();
console.log(chalk.bold(' Annotation Sync'));
console.log();
// Relocated annotations
const relocated = synced.filter((s, i) => s.anchor.line !== annotationFile.annotations[i]?.anchor.line);
if (relocated.length > 0) {
console.log(` ${chalk.green('Relocated:')} ${relocated.length} annotation(s)`);
for (const ann of relocated) {
const original = annotationFile.annotations.find((a) => a.id === ann.id);
if (original) {
console.log(chalk.dim(` #${ann.id.slice(0, 8)}: L${original.anchor.line} → L${ann.anchor.line}`));
}
}
}
else {
console.log(chalk.dim(' No annotations needed relocation'));
}
// Orphaned annotations
if (orphaned.length > 0) {
console.log();
console.log(` ${chalk.yellow('Orphaned:')} ${orphaned.length} annotation(s)`);
console.log(chalk.dim(' (Could not find matching content in file)'));
for (const ann of orphaned) {
console.log(chalk.yellow(` #${ann.id.slice(0, 8)}: "${ann.content.slice(0, 40)}..."`));
}
}
// Save if not dry run
if (!options.dryRun && (relocated.length > 0 || orphaned.length > 0)) {
// Update the annotation file with synced positions
// Keep orphaned ones but they'll have stale positions
annotationFile.annotations = [...synced, ...orphaned];
await writeAnnotationFile(filePath, annotationFile);
console.log();
console.log(chalk.green(' Changes saved'));
}
else if (options.dryRun) {
console.log();
console.log(chalk.dim(' Dry run - no changes saved'));
}
console.log();
});
// ============================================
// export - Export annotations to file
// ============================================
export const exportCommand = new Command('export')
.description('Export annotations to a JSON file')
.argument('<file>', 'Source file with annotations')
.argument('[output]', 'Output file path (default: stdout)')
.option('--pretty', 'Pretty-print JSON output', true)
.action(async (file, output, options) => {
const filePath = resolve(file);
const annotationFile = await readAnnotationFile(filePath);
if (!annotationFile) {
console.error(chalk.red('No annotations found for this file'));
process.exit(1);
}
// Create export object (same as annotation file but portable)
const exportData = {
exportedAt: new Date().toISOString(),
sourceFile: basename(filePath),
...annotationFile,
};
const json = options.pretty
? JSON.stringify(exportData, null, 2)
: JSON.stringify(exportData);
if (output) {
const outputPath = resolve(output);
await writeFile(outputPath, json, 'utf-8');
console.log(chalk.green(`Exported ${annotationFile.annotations.length} annotation(s) to ${outputPath}`));
}
else {
console.log(json);
}
});
// ============================================
// import - Import annotations from file
// ============================================
export const importCommand = new Command('import')
.description('Import annotations from a JSON file')
.argument('<file>', 'Target file to attach annotations to')
.argument('<source>', 'Source JSON file to import from')
.option('--merge', 'Merge with existing annotations (default: replace)', false)
.option('--dry-run', 'Show what would be imported without saving')
.action(async (file, source, options) => {
const filePath = resolve(file);
const sourcePath = resolve(source);
// Read import data
let importData;
try {
const content = await readFile(sourcePath, 'utf-8');
importData = JSON.parse(content);
}
catch (error) {
console.error(chalk.red(`Failed to read import file: ${sourcePath}`));
if (error instanceof SyntaxError) {
console.error(chalk.dim(' Invalid JSON format'));
}
process.exit(1);
}
// Validate import data
if (!importData.annotations || !Array.isArray(importData.annotations)) {
console.error(chalk.red('Invalid annotation file format'));
console.error(chalk.dim(' Missing or invalid "annotations" array'));
process.exit(1);
}
// Get or create target annotation file
const targetFile = options.merge
? await getOrCreateAnnotationFile(filePath)
: await getOrCreateAnnotationFile(filePath);
// Count what we're importing
const newAnnotations = importData.annotations.length;
const newApprovals = importData.approvals?.length || 0;
const existingAnnotations = targetFile.annotations.length;
const existingApprovals = targetFile.approvals.length;
console.log();
console.log(chalk.bold(' Annotation Import'));
console.log();
console.log(` Source: ${chalk.cyan(basename(sourcePath))}`);
console.log(` Target: ${chalk.cyan(basename(filePath))}`);
console.log();
if (options.merge) {
console.log(` Mode: ${chalk.yellow('merge')}`);
console.log(` Existing: ${existingAnnotations} annotation(s), ${existingApprovals} approval(s)`);
}
else {
console.log(` Mode: ${chalk.yellow('replace')}`);
if (existingAnnotations > 0 || existingApprovals > 0) {
console.log(chalk.dim(` Will replace ${existingAnnotations} annotation(s) and ${existingApprovals} approval(s)`));
}
}
console.log(` Importing: ${newAnnotations} annotation(s), ${newApprovals} approval(s)`);
console.log();
if (options.dryRun) {
console.log(chalk.dim(' Dry run - no changes saved'));
console.log();
// Show preview of annotations
if (newAnnotations > 0) {
console.log(' Annotations to import:');
for (const ann of importData.annotations.slice(0, 5)) {
console.log(` ${chalk.dim(`#${ann.id.slice(0, 8)}`)} L${ann.anchor.line} ${ann.type}: "${ann.content.slice(0, 40)}..."`);
}
if (newAnnotations > 5) {
console.log(chalk.dim(` ... and ${newAnnotations - 5} more`));
}
console.log();
}
return;
}
// Perform import
if (options.merge) {
// Merge: add new annotations, skip duplicates by ID
const existingIds = new Set(targetFile.annotations.map((a) => a.id));
const toAdd = importData.annotations.filter((a) => !existingIds.has(a.id));
targetFile.annotations.push(...toAdd);
// Merge approvals by author (newer wins)
for (const approval of importData.approvals || []) {
const existingIndex = targetFile.approvals.findIndex((a) => a.author === approval.author);
if (existingIndex >= 0) {
// Check which is newer
const existingDate = new Date(targetFile.approvals[existingIndex].timestamp);
const importDate = new Date(approval.timestamp);
if (importDate > existingDate) {
targetFile.approvals[existingIndex] = approval;
}
}
else {
targetFile.approvals.push(approval);
}
}
console.log(chalk.green(` Added ${toAdd.length} new annotation(s)`));
if (newAnnotations - toAdd.length > 0) {
console.log(chalk.dim(` Skipped ${newAnnotations - toAdd.length} duplicate(s)`));
}
}
else {
// Replace: overwrite everything
targetFile.annotations = importData.annotations;
targetFile.approvals = importData.approvals || [];
console.log(chalk.green(` Imported ${newAnnotations} annotation(s) and ${newApprovals} approval(s)`));
}
// Save
await writeAnnotationFile(filePath, targetFile);
console.log(chalk.green(' Changes saved'));
console.log();
});