Skip to content

Commit 504ede4

Browse files
tsutomu-nclaude
andcommitted
feat: Add restore command and enhance UX with improved error handling (v0.5.1)
New features: - kc restore command for recovering snapshots from trash - Typo suggestions for unknown commands (edit distance algorithm) - --no-header and --machine options for list command Security improvements: - Terminal escape sequence protection with input sanitization - DoS protection in edit distance algorithm (length limits) - Enhanced input validation with detailed error messages UX enhancements: - Detailed error messages with specific reasons and examples - Similar ID suggestions when snapshots not found - Parallel processing for restore operations (batched) - Machine-readable TSV output format 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 4892454 commit 504ede4

File tree

13 files changed

+556
-50
lines changed

13 files changed

+556
-50
lines changed

README.ja.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ Kodama Claude は **人間の意思決定ログ** を外部に構造化して保
3737
curl -fsSL https://github.com/tsutomu-n/kodama-claude/releases/latest/download/install.sh | bash
3838
```
3939

40+
> 📌 **重要**: インストールやアップデート時、既存のスナップショットやデータは**完全に保持されます**
41+
> データは `~/.local/share/kodama-claude/` に保存され、バイナリ更新時には一切触れられません。
42+
4043
**自動処理内容:**
4144
- 古いバージョン(v0.1.0、v0.2.0)を自動検出・削除
4245
- アーキテクチャに適したバイナリをダウンロード
@@ -172,6 +175,7 @@ kc list # 保存済みスナップショットを一覧表示(v0.4.1+)
172175
# スナップショット管理(v0.5.0+)
173176
kc show # スナップショットの詳細表示
174177
kc delete # スナップショットの安全な削除(ゴミ箱機能付き)
178+
kc restore # ゴミ箱から復元(v0.5.1+)
175179
kc search # スナップショット全文検索
176180

177181
# メンテナンス
@@ -388,6 +392,8 @@ kc delete --list-trash
388392

389393
# ゴミ箱から復元
390394
kc delete --restore abc123
395+
# または復元専用コマンド(v0.5.1+)
396+
kc restore abc123
391397

392398
# ゴミ箱を空にする
393399
kc delete --empty-trash
@@ -417,7 +423,7 @@ $ kc delete c4d56789 # 部分IDで指定
417423
✅ スナップショット 'c4d56789...' をゴミ箱に移動しました
418424

419425
# 3. 間違えて削除した場合は復元可能
420-
$ kc delete --restore c4d56789
426+
$ kc restore c4d56789
421427
✅ スナップショット 'c4d56789...' をゴミ箱から復元しました
422428

423429
# 4. 1週間以上前の作業用スナップショットを一括削除
@@ -791,6 +797,9 @@ A: 単一バイナリ配布、高速起動、開発体験が良いから。
791797
**Q: VS Code と統合できるか?**
792798
A: エディタ非依存。どのエディタでも使える。
793799

800+
**Q: インストール/アップデート時にスナップショットは削除されますか?**
801+
A: いいえ。Kodama Claudeは設計上、インストールやアップデート時にユーザーデータに一切触れません。すべてのスナップショットは `~/.local/share/kodama-claude/` に安全に保存され、バイナリ更新の影響を受けません。データ削除には `kc uninstall --remove-all` の明示的なコマンドが必要です。
802+
794803
**Q: 複数マシンでスナップショットを同期できるか?**
795804
A: `~/.local/share/kodama-claude` を Git で管理するか、シンボリックリンクを使う。
796805

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ Kodama stores **human decision logs** in structured format. When `/clear` erases
2020
curl -fsSL https://github.com/tsutomu-n/kodama-claude/releases/latest/download/install.sh | bash
2121
```
2222

23+
> 📌 **Important**: Your snapshots and data are **fully preserved** during installation or updates.
24+
> Data stored in `~/.local/share/kodama-claude/` is never touched during binary updates.
25+
2326
**What it does:**
2427
- Automatically detects and removes old versions (v0.1.0, v0.2.0)
2528
- Downloads correct binary for your architecture
@@ -109,6 +112,7 @@ kc list # List saved snapshots (v0.4.1+)
109112
# Snapshot Management (v0.5.0+)
110113
kc show # Display detailed snapshot information
111114
kc delete # Safe snapshot deletion (with trash/restore)
115+
kc restore # Restore from trash (v0.5.1+)
112116
kc search # Full-text search across snapshots
113117

114118
# Maintenance
@@ -643,6 +647,9 @@ A: `kc go` uses `claude -c -p "<context>"` to inject, then `claude --continue` t
643647
**Q: Why no token percentages?**
644648
A: Claude doesn't reliably expose this. We use heuristic-based 4-value status (🟢/🟡/🔴/❓) instead.
645649

650+
**Q: Are my snapshots deleted during installation/updates?**
651+
A: No. Kodama Claude is designed to never touch user data during installation or updates. All snapshots are safely stored in `~/.local/share/kodama-claude/` and are unaffected by binary updates. Data deletion requires explicit commands like `kc uninstall --remove-all`.
652+
646653
**Q: Why use snapshots instead of Git?**
647654
A: Git and snapshots are complementary:
648655

bin/kc.ts

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,8 @@ program
116116
.option("-t, --tags <tags>", "Filter by tags (comma/space separated)")
117117
.option("--sort <field>", "Sort by: date (default), title, size", "date")
118118
.option("--reverse", "Reverse sort order")
119+
.option("--no-header", "Omit header for script usage")
120+
.option("--machine", "Machine-readable TSV output")
119121
.action(async (options) => {
120122
const { list } = await import("../src/commands/list");
121123
await list({
@@ -129,7 +131,9 @@ program
129131
thisWeek: options.thisWeek,
130132
tags: options.tags,
131133
sort: options.sort,
132-
reverse: options.reverse
134+
reverse: options.reverse,
135+
noHeader: options.noHeader,
136+
machine: options.machine
133137
});
134138
});
135139

@@ -204,5 +208,82 @@ program
204208
});
205209
});
206210

211+
// Restore command (restore from trash)
212+
program
213+
.command("restore")
214+
.description("Restore snapshots from trash")
215+
.argument("<snapshot-ids...>", "Snapshot IDs to restore from trash")
216+
.option("-v, --verbose", "Show detailed restoration information")
217+
.option("--dry-run", "Show what would be restored without restoring")
218+
.action(async (snapshotIds, options) => {
219+
const { restore } = await import("../src/commands/restore");
220+
await restore(snapshotIds, {
221+
verbose: options.verbose,
222+
dryRun: options.dryRun
223+
});
224+
});
225+
226+
// Handle unknown commands with suggestions
227+
program.on('command:*', function (operands) {
228+
const unknownCommand = String(operands[0] || '').slice(0, 50); // Limit length for DoS protection
229+
const availableCommands = ['go', 'save', 'status', 'uninstall', 'restart', 'tags', 'resume', 'list', 'show', 'delete', 'search', 'restore'];
230+
231+
// Calculate edit distance for suggestions with DoS protection
232+
function editDistance(a: string, b: string): number {
233+
// DoS protection: limit string lengths
234+
const maxLen = 20;
235+
a = a.slice(0, maxLen);
236+
b = b.slice(0, maxLen);
237+
238+
// Early return for same strings
239+
if (a === b) return 0;
240+
241+
// Early return if length difference is too large
242+
if (Math.abs(a.length - b.length) > 3) return 999;
243+
244+
const dp = Array(a.length + 1).fill(null).map(() => Array(b.length + 1).fill(0));
245+
246+
for (let i = 0; i <= a.length; i++) dp[i][0] = i;
247+
for (let j = 0; j <= b.length; j++) dp[0][j] = j;
248+
249+
for (let i = 1; i <= a.length; i++) {
250+
for (let j = 1; j <= b.length; j++) {
251+
if (a[i - 1] === b[j - 1]) {
252+
dp[i][j] = dp[i - 1][j - 1];
253+
} else {
254+
dp[i][j] = Math.min(
255+
dp[i - 1][j] + 1, // deletion
256+
dp[i][j - 1] + 1, // insertion
257+
dp[i - 1][j - 1] + 1 // substitution
258+
);
259+
}
260+
}
261+
}
262+
return dp[a.length][b.length];
263+
}
264+
265+
// Find similar commands (edit distance <= 2)
266+
const suggestions = availableCommands
267+
.map(cmd => ({ cmd, distance: editDistance(unknownCommand, cmd) }))
268+
.filter(({ distance }) => distance <= 2)
269+
.sort((a, b) => a.distance - b.distance)
270+
.map(({ cmd }) => cmd)
271+
.slice(0, 3);
272+
273+
// Sanitize command name in error output
274+
const { sanitizeForOutput } = require("../src/utils/sanitize");
275+
console.error(`❌ Error: Unknown command '${sanitizeForOutput(unknownCommand)}'`);
276+
277+
if (suggestions.length > 0) {
278+
console.error("💡 Did you mean:");
279+
suggestions.forEach(cmd => console.error(` • kc ${cmd}`));
280+
} else {
281+
console.error("💡 Available commands: " + availableCommands.join(', '));
282+
}
283+
284+
console.error("\n📖 Use 'kc --help' to see all available commands");
285+
process.exit(1);
286+
});
287+
207288
// Parse and execute
208289
program.parse();

docs/en/getting-started.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,9 @@ Copy and paste this one line:
148148
curl -fsSL https://github.com/tsutomu-n/kodama-claude/releases/latest/download/install.sh | bash
149149
```
150150

151+
> 📌 **Important**: Your snapshots and data are **fully preserved** during installation or updates.
152+
> Data stored in `~/.local/share/kodama-claude/` is never touched during binary updates.
153+
151154
What happens:
152155
1. Downloads the installer
153156
2. Gets the right version for your computer

docs/ja/getting-started.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@
2525
curl -fsSL https://github.com/tsutomu-n/kodama-claude/releases/latest/download/install.sh | bash
2626
```
2727

28+
> 📌 **重要**: インストールや更新時、**スナップショットとデータは完全に保護されます**
29+
> `~/.local/share/kodama-claude/` に保存されたデータは、バイナリ更新時に一切触れられません。
30+
2831
このコマンドは:
2932
- 古いバージョンを自動検出・削除
3033
- アーキテクチャに合ったバイナリをダウンロード

src/commands/delete.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,18 @@ async function findSnapshotsByIds(snapshotDir: string, snapshotIds: string[]): P
174174
inputId.includes('\\') ||
175175
inputId.length < 4 ||
176176
inputId.length > 100) {
177-
throw new Error(`Invalid snapshot ID: ${inputId}. Must be 4-100 characters without path components.`);
177+
let errorMsg = `Snapshot ID '${inputId}' is invalid.`;
178+
if (inputId.length < 4) {
179+
errorMsg += ` Reason: Too short (minimum 4 characters required for safety to prevent accidental matches).`;
180+
}
181+
if (inputId.includes('..') || inputId.includes('/') || inputId.includes('\\')) {
182+
errorMsg += ` Reason: Contains path separators (security restriction).`;
183+
}
184+
if (inputId.length > 100) {
185+
errorMsg += ` Reason: Too long (maximum 100 characters).`;
186+
}
187+
errorMsg += ` Examples of valid IDs: abc123, test-2024, feature_login`;
188+
throw new Error(errorMsg);
178189
}
179190

180191
// Find matching files
@@ -190,11 +201,25 @@ async function findSnapshotsByIds(snapshotDir: string, snapshotIds: string[]): P
190201
}
191202

192203
if (matchingFiles.length === 0) {
193-
throw new Error(`No snapshot found matching ID: ${inputId}`);
204+
// Suggest similar IDs if any exist
205+
const similarIds = allFiles
206+
.map(f => f.replace(".json", ""))
207+
.filter(id => id.toLowerCase().includes(inputId.toLowerCase()) ||
208+
inputId.toLowerCase().includes(id.toLowerCase().slice(0, 4)))
209+
.slice(0, 3);
210+
211+
let errorMsg = `No snapshot found matching ID: ${inputId}`;
212+
if (similarIds.length > 0) {
213+
errorMsg += `. Did you mean: ${similarIds.join(', ')}?`;
214+
} else {
215+
errorMsg += `. Use 'kc list' to see available snapshots.`;
216+
}
217+
throw new Error(errorMsg);
194218
}
195219

196220
if (matchingFiles.length > 1) {
197-
throw new Error(`Multiple snapshots match ID '${inputId}': ${matchingFiles.map(f => f.replace('.json', '')).join(', ')}`);
221+
const matches = matchingFiles.map(f => f.replace('.json', '')).join(', ');
222+
throw new Error(`Multiple snapshots match ID '${inputId}' (${matchingFiles.length} found): ${matches}. Use a more specific ID to avoid ambiguity. Example: try '${matchingFiles[0].replace('.json', '').slice(0, Math.min(8, matchingFiles[0].length - 5))}'`);
198223
}
199224

200225
const fileName = matchingFiles[0];

src/commands/list.ts

Lines changed: 47 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ interface ListOptions {
2121
tags?: string;
2222
sort?: string;
2323
reverse?: boolean;
24+
noHeader?: boolean;
25+
machine?: boolean; // Machine-readable output (TSV format)
2426
}
2527

2628
export async function list(options: ListOptions = {}): Promise<void> {
@@ -36,7 +38,9 @@ export async function list(options: ListOptions = {}): Promise<void> {
3638
thisWeek = false,
3739
tags,
3840
sort = "date",
39-
reverse = false
41+
reverse = false,
42+
noHeader = false,
43+
machine = false
4044
} = options;
4145

4246
try {
@@ -164,8 +168,23 @@ export async function list(options: ListOptions = {}): Promise<void> {
164168
// Output results
165169
if (json) {
166170
console.log(JSON.stringify({ snapshots }, null, 2));
171+
} else if (machine) {
172+
// Machine-readable TSV format
173+
if (!noHeader) {
174+
console.log("ID\tTitle\tTimestamp\tStep\tTags");
175+
}
176+
177+
snapshots.forEach((snap) => {
178+
const safeTitle = (snap.title || "Untitled").replace(/[\x00-\x1F\x7F\t]/g, " ");
179+
const safeTags = snap.tags ? snap.tags.join(",") : "";
180+
const safeStep = snap.step || "";
181+
182+
console.log(`${snap.id}\t${safeTitle}\t${snap.timestamp}\t${safeStep}\t${safeTags}`);
183+
});
167184
} else {
168-
console.log(`📚 Recent Snapshots (showing ${snapshots.length}/${files.length}):\n`);
185+
if (!noHeader) {
186+
console.log(`📚 Recent Snapshots (showing ${snapshots.length}/${files.length}):\n`);
187+
}
169188

170189
snapshots.forEach((snap, index) => {
171190
const date = new Date(snap.timestamp);
@@ -174,30 +193,36 @@ export async function list(options: ListOptions = {}): Promise<void> {
174193

175194
// Security: Escape potential control characters in title
176195
const safeTitle = (snap.title || "Untitled").replace(/[\x00-\x1F\x7F]/g, "");
177-
console.log(`${index + 1}. ${safeTitle}`);
178-
console.log(` 📅 ${formattedDate} (${timeAgo})`);
179-
180-
if (snap.step) {
181-
console.log(` 📊 Step: ${snap.step}`);
182-
}
183196

184-
if (snap.tags && snap.tags.length > 0) {
185-
// Security: Escape tags and limit display
186-
const safeTags = snap.tags
187-
.map(t => String(t).replace(/[\x00-\x1F\x7F]/g, ""))
188-
.join(", ");
189-
console.log(` 🏷️ Tags: ${safeTags}`);
190-
}
191-
192-
if (verbose) {
193-
console.log(` 🆔 ID: ${snap.id}`);
194-
console.log(` 📁 File: ${snap.file}`);
197+
if (noHeader) {
198+
// Simple format for --no-header: just ID and title
199+
console.log(`${snap.id} ${safeTitle}`);
200+
} else {
201+
console.log(`${index + 1}. ${safeTitle}`);
202+
console.log(` 📅 ${formattedDate} (${timeAgo})`);
203+
204+
if (snap.step) {
205+
console.log(` 📊 Step: ${snap.step}`);
206+
}
207+
208+
if (snap.tags && snap.tags.length > 0) {
209+
// Security: Escape tags and limit display
210+
const safeTags = snap.tags
211+
.map(t => String(t).replace(/[\x00-\x1F\x7F]/g, ""))
212+
.join(", ");
213+
console.log(` 🏷️ Tags: ${safeTags}`);
214+
}
215+
216+
if (verbose) {
217+
console.log(` 🆔 ID: ${snap.id}`);
218+
console.log(` 📁 File: ${snap.file}`);
219+
}
220+
221+
console.log("");
195222
}
196-
197-
console.log("");
198223
});
199224

200-
if (files.length > limit) {
225+
if (files.length > limit && !noHeader) {
201226
console.log(`💡 Showing ${limit} of ${files.length} snapshots. Use --limit to see more.`);
202227
}
203228
}

0 commit comments

Comments
 (0)