diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 968ac906..39f375fc 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -20,7 +20,7 @@ { "name": "claudian", "description": "Obsidian integration for Claude Code. Auto-saves conversation notes and decisions to your Obsidian vault (田中雄一郎OS保管庫).", - "version": "1.3.0", + "version": "1.4.0", "author": { "name": "YUICHIRO TANAKA" }, diff --git a/plugins/claudian/.claude-plugin/plugin.json b/plugins/claudian/.claude-plugin/plugin.json index c6a95115..d8f341e7 100644 --- a/plugins/claudian/.claude-plugin/plugin.json +++ b/plugins/claudian/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "claudian", - "version": "1.3.0", + "version": "1.4.0", "description": "Obsidian integration for Claude Code. Auto-saves conversation notes and decisions to your Obsidian vault (田中雄一郎OS保管庫).", "author": { "name": "YUICHIRO TANAKA" diff --git a/plugins/claudian/README.md b/plugins/claudian/README.md index eda99cc1..09243da0 100644 --- a/plugins/claudian/README.md +++ b/plugins/claudian/README.md @@ -93,12 +93,18 @@ order: 1. `--vault ` on the command line 2. `CLAUDIAN_VAULT_ROOT` environment variable 3. `vaultRoot` in `~/.claudian/config.json` (path overridable with `CLAUDIAN_CONFIG`) -4. Auto-detection of a directory named `田中雄一郎OS保管庫` under: - `~/TANAKA-BRAIN`, `~`, `~/Documents`, +4. Auto-detection of a directory named `田中雄一郎OS保管庫` under, in order: + `~` (the current layout — `/Users/nesty/田中雄一郎OS保管庫`), + `~/TANAKA-BRAIN` (the old layout), `~/Documents`, `~/Library/Mobile Documents/iCloud~md~obsidian/Documents`, `~/Library/Mobile Documents/com~apple~CloudDocs`, `~/Dropbox`, `~/Google Drive`, `~/obsidian` +The home folder is checked first on purpose: a backup or an old copy restored +from the Trash must not outrank the live vault. When more than one candidate +exists the script uses the first and prints the rest on stderr, so a duplicate +never swallows notes unnoticed. + If the vault lives in one of those places, nothing to configure. Otherwise pin it once per machine: diff --git a/plugins/claudian/scripts/obsidian-save.mjs b/plugins/claudian/scripts/obsidian-save.mjs index 6f9a5a51..fc7f6ce6 100644 --- a/plugins/claudian/scripts/obsidian-save.mjs +++ b/plugins/claudian/scripts/obsidian-save.mjs @@ -66,8 +66,10 @@ export function configFilePath(env = process.env, home = homedir()) { export function vaultCandidates(vaultName = DEFAULT_VAULT_NAME, home = homedir()) { return [ - join(home, "TANAKA-BRAIN", vaultName), join(home, vaultName), + // Legacy layout, kept last-resort on purpose: an old copy restored from the + // Trash must never outrank the vault that actually sits in the home folder. + join(home, "TANAKA-BRAIN", vaultName), join(home, "Documents", vaultName), join(home, "Library", "Mobile Documents", "iCloud~md~obsidian", "Documents", vaultName), join(home, "Library", "Mobile Documents", "com~apple~CloudDocs", vaultName), @@ -121,9 +123,11 @@ export function resolveVaultRoot({ explicit = null, env = process.env, home = ho } const candidates = vaultCandidates(vaultName, home); - const found = candidates.find(isDirectory); - if (found) { - return { root: found, source: "自動検出", vaultName }; + const matches = candidates.filter(isDirectory); + if (matches.length > 0) { + // Duplicates and restored backups are common while a vault is being + // reorganised, so report the ones that lost rather than picking silently. + return { root: matches[0], source: "自動検出", vaultName, alternatives: matches.slice(1) }; } return { root: null, source: null, vaultName, candidates }; @@ -304,7 +308,13 @@ export function listFolders(argv, { env = process.env, home = homedir() } = {}) const resolved = requireVault({ explicit: vault, createVault, env, home }); const { folders, sources } = resolveFolders({ vaultRoot: resolved.root, env, home }); - return { vaultRoot: resolved.root, source: resolved.source, folders, sources }; + return { + vaultRoot: resolved.root, + source: resolved.source, + alternatives: resolved.alternatives ?? [], + folders, + sources + }; } /** @@ -369,6 +379,7 @@ export function scanFolders(argv, { env = process.env, home = homedir() } = {}) return { vaultRoot: resolved.root, source: resolved.source, + alternatives: resolved.alternatives ?? [], configPath, directories, proposed, @@ -411,7 +422,13 @@ export function saveNote(argv, { env = process.env, home = homedir(), now = new const filePath = uniqueFilePath(dirPath, `${date}_${sanitizeTitle(title)}`); writeFileSync(filePath, renderNote({ title, content, tags, date }), "utf8"); - return { filePath, vaultRoot: resolved.root, source: resolved.source, folder: relativeDir }; + return { + filePath, + vaultRoot: resolved.root, + source: resolved.source, + alternatives: resolved.alternatives ?? [], + folder: relativeDir + }; } const WIDE_CHARACTER = /[ᄀ-ᅟ⺀-꓏가-힣豈-﫿︰-﹯＀-⦆¢-₩]/; @@ -424,6 +441,17 @@ function displayWidth(text) { return width; } +function warnAboutAlternatives(alternatives) { + if (!alternatives || alternatives.length === 0) { + return; + } + console.error(`注意: 保管庫の候補が ${alternatives.length + 1} 件見つかりました。使わなかったもの:`); + for (const alternative of alternatives) { + console.error(` - ${alternative}`); + } + console.error("こちらが正しい場合は CLAUDIAN_VAULT_ROOT で固定してください。"); +} + function main() { try { const argv = process.argv.slice(2); @@ -431,6 +459,7 @@ function main() { if (argv.includes("--scan-folders")) { const scan = scanFolders(argv); console.log(`保管庫: ${scan.vaultRoot} (${scan.source})`); + warnAboutAlternatives(scan.alternatives); console.log(`検出したフォルダ (${scan.directories.length}):`); for (const directory of scan.directories) { console.log(` ${directory}`); @@ -451,8 +480,9 @@ function main() { } if (argv.includes("--list-folders")) { - const { vaultRoot, source, folders, sources } = listFolders(argv); + const { vaultRoot, source, alternatives, folders, sources } = listFolders(argv); console.log(`保管庫: ${vaultRoot} (${source})`); + warnAboutAlternatives(alternatives); console.log(`フォルダ定義: ${sources.join(" → ")}`); const width = Math.max(0, ...Object.keys(folders).map(displayWidth)); for (const [alias, directory] of Object.entries(folders)) { @@ -461,8 +491,9 @@ function main() { return; } - const { filePath, vaultRoot, source, folder } = saveNote(argv); + const { filePath, vaultRoot, source, alternatives, folder } = saveNote(argv); console.error(`保管庫: ${vaultRoot}/${folder} (${source})`); + warnAboutAlternatives(alternatives); console.log(basename(filePath)); } catch (error) { console.error(error instanceof Error ? error.message : String(error)); diff --git a/plugins/claudian/skills/obsidian-save/SKILL.md b/plugins/claudian/skills/obsidian-save/SKILL.md index 827363cc..2775c23e 100644 --- a/plugins/claudian/skills/obsidian-save/SKILL.md +++ b/plugins/claudian/skills/obsidian-save/SKILL.md @@ -75,9 +75,14 @@ place. Resolution order: 1. `--vault ` 2. `CLAUDIAN_VAULT_ROOT` 3. `vaultRoot` in `~/.claudian/config.json` -4. Auto-detection of `田中雄一郎OS保管庫` under `~/TANAKA-BRAIN`, `~`, +4. Auto-detection of `田中雄一郎OS保管庫` under `~` (the real layout: + `/Users/nesty/田中雄一郎OS保管庫`), then `~/TANAKA-BRAIN` (old layout), `~/Documents`, the Obsidian/iCloud Drive folders, `~/Dropbox`, `~/Google Drive` +If several candidates exist, the script uses the first and prints the others on +stderr. Pass that warning on to the user — it usually means a backup or a copy +restored from the Trash is sitting next to the real vault. + Default folder mapping (overridden by `/.claudian.json`): | folder value | directory | diff --git a/tests/obsidian-save.test.mjs b/tests/obsidian-save.test.mjs index d78dc7aa..312ec705 100644 --- a/tests/obsidian-save.test.mjs +++ b/tests/obsidian-save.test.mjs @@ -143,6 +143,53 @@ test("resolveVaultRoot honours --vault, the env var, the config file, then auto- assert.equal(detected.source, "自動検出"); }); +test("resolveVaultRoot finds the vault directly under the home folder", () => { + const home = makeTempDir("claudian-home-"); + const vault = path.join(home, DEFAULT_VAULT_NAME); + fs.mkdirSync(vault, { recursive: true }); + + const resolved = resolveVaultRoot({ env: {}, home }); + + assert.equal(resolved.root, vault); + assert.equal(resolved.source, "自動検出"); + assert.deepEqual(resolved.alternatives, []); +}); + +test("the home-folder vault outranks an old copy restored under TANAKA-BRAIN", () => { + const home = makeTempDir("claudian-home-"); + const vault = path.join(home, DEFAULT_VAULT_NAME); + const legacy = path.join(home, "TANAKA-BRAIN", DEFAULT_VAULT_NAME); + fs.mkdirSync(vault, { recursive: true }); + fs.mkdirSync(legacy, { recursive: true }); + + const resolved = resolveVaultRoot({ env: {}, home }); + + assert.equal(resolved.root, vault); + assert.deepEqual(resolved.alternatives, [legacy]); + + // The losing candidate is reported so a duplicate cannot swallow notes unnoticed. + const saved = saveNote(["--title", "メモ", "--content", "本文"], { env: {}, home }); + assert.equal(saved.vaultRoot, vault); + assert.deepEqual(saved.alternatives, [legacy]); +}); + +test("the CLI warns when more than one vault candidate exists", () => { + const home = makeTempDir("claudian-home-"); + const vault = path.join(home, DEFAULT_VAULT_NAME); + const legacy = path.join(home, "TANAKA-BRAIN", DEFAULT_VAULT_NAME); + fs.mkdirSync(vault, { recursive: true }); + fs.mkdirSync(legacy, { recursive: true }); + + const result = run(process.execPath, [SCRIPT, "--title", "メモ", "--content", "本文"], { + env: { ...process.env, HOME: home, CLAUDIAN_VAULT_ROOT: "", CLAUDIAN_CONFIG: path.join(home, "absent.json") } + }); + + assert.equal(result.status, 0); + assert.match(result.stderr, /保管庫の候補が 2 件見つかりました/); + assert.match(result.stderr, new RegExp(legacy.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + assert.equal(fs.existsSync(path.join(vault, "00_INBOX", result.stdout.trim())), true); +}); + test("resolveVaultRoot expands ~ and honours CLAUDIAN_VAULT_NAME", () => { const home = makeTempDir("claudian-home-"); const named = path.join(home, "Documents", "別の保管庫");