diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8b5502d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,45 @@ +# SideNote2 Agent Routing + +When a user is working with real SideNote2 comments in an Obsidian vault, do not start from plugin internals. + +Use the SideNote2 note workflow first. + +## Use The `sidenote2` Skill + +Switch to `skills/sidenote2/SKILL.md` when the user: + +- pastes an `obsidian://side-note2-comment?...` URI +- says `reply to this`, `reply to this thread`, `answer this side note`, or `add to thread` +- says `update this side note`, `rewrite this side comment`, or `edit this stored side note` +- says `resolve this side note`, `mark this thread resolved`, or `archive this side note` +- provides a `commentId` plus a vault note path +- asks about the trailing `` block in a real markdown note + +## Source Of Truth + +- The markdown note is canonical. +- The trailing `` block is the canonical stored comment data. +- `SideNote2 index.md` is derived output. Use it for discovery only. + +## Write Path + +For SideNote2 thread writes, prefer the helper scripts over hand-editing JSON: + +- `node scripts/append-note-comment-entry.mjs` +- `node scripts/resolve-note-comment.mjs` +- `node scripts/update-note-comment.mjs` + +If the user already supplied an `obsidian://side-note2-comment?...` URI, prefer the URI-based CLI path: + +- `--uri "obsidian://side-note2-comment?..."` + +## Intent Mapping + +- `reply`, `continue`, `answer this`, `add another note under this` + Treat as append-to-thread. +- `update`, `rewrite`, `replace this comment` + Treat as replace-existing-entry. +- `resolve`, `mark resolved`, `archive this side note` + Treat as resolve-thread. + +Do not overwrite an existing SideNote2 thread when the user clearly asked to reply. diff --git a/README.md b/README.md index 9869e82..aa0ae7f 100644 --- a/README.md +++ b/README.md @@ -59,15 +59,25 @@ It is built for a minimal workflow: humans work in the sidebar, while agents can

Install SideNote2 with BRAT

-3. Optional: use the bundled SideNote2 agent instructions. +3. Agent support for SideNote2 workflows is automatic. -Example install for Codex CLI: -```bash -python ~/.codex/skills/.system/skill-installer/scripts/install-skill-from-github.py --repo \ - vicky469/SideNote2 --path skills/side-note2-note-comments skills/canvas-design -``` +If an agent is working directly inside the Obsidian vault, SideNote2 manages a vault-root `AGENTS.md`. +When the plugin is enabled, it creates the file when missing, or inserts and refreshes a SideNote2-managed block inside an existing `AGENTS.md` without overwriting unrelated user instructions. -`side-note2-note-comments` helps agents read and update SideNote2-backed notes. `canvas-design` is a bundled Obsidian canvas-layout skill for cleaner spacing, grouping, and board readability. If you use Claude Code or another assistant, adapt the same instructions there. +You can run it manually from the command palette if needed: + +- `SideNote2: Sync AGENTS.md in vault root` + +If you want to remove the SideNote2-managed agent support later, use: + +- `SideNote2: Remove SideNote2 agent support from vault` + +That vault `AGENTS.md` is the main routing layer for SideNote2 note workflows such as: + +- resolving `obsidian://side-note2-comment?...` links +- replying to an existing side note thread +- resolving a side note thread +- updating a stored SideNote2 comment in a real markdown note ## Workflow @@ -119,6 +129,8 @@ If multiple side comments in the same note use the same selected text, include a ## Command - `SideNote2: Add comment to selection` +- `SideNote2: Sync AGENTS.md in vault root` +- `SideNote2: Remove SideNote2 agent support from vault` ## Storage diff --git a/bin/sidenote2.mjs b/bin/sidenote2.mjs index f080394..1d2780a 100644 --- a/bin/sidenote2.mjs +++ b/bin/sidenote2.mjs @@ -22,7 +22,9 @@ function printMainUsage(stream = process.stderr) { " comment:migrate-legacy Maintenance: rewrite one note from legacy flat comments to threaded storage", " comment:append Append one entry to an existing SideNote2 comment thread in a note", " comment:update Update one stored SideNote2 comment body in a note", + " comment:resolve Mark one SideNote2 comment thread as resolved in a note", " install-skill Copy bundled SideNote2 Codex skill(s) into the Codex skills directory", + " uninstall-agent-support Remove SideNote2 AGENTS routing from a vault and uninstall bundled skills", "", "Run `sidenote2 --help` for command-specific usage.", ].join("\n") + "\n", @@ -33,10 +35,11 @@ function printCommentUpdateUsage(stream = process.stderr) { stream.write( [ "Usage:", - " sidenote2 comment:update --file --id (--comment | --comment-file | --stdin) [--settle-ms ]", + " sidenote2 comment:update (--file --id | --uri ) (--comment | --comment-file | --stdin) [--settle-ms ]", "", "Examples:", " sidenote2 comment:update --file ./note.md --id comment-1 --comment-file ./comment.md", + " sidenote2 comment:update --uri \"obsidian://side-note2-comment?...\" --comment-file ./comment.md", " sidenote2 comment:update --file ./note.md --id comment-1 --comment-file ./comment.md --settle-ms 2000", " printf 'Updated body\\n' | sidenote2 comment:update --file ./note.md --id comment-1 --stdin", ].join("\n") + "\n", @@ -47,16 +50,31 @@ function printCommentAppendUsage(stream = process.stderr) { stream.write( [ "Usage:", - " sidenote2 comment:append --file --id (--comment | --comment-file | --stdin) [--settle-ms ]", + " sidenote2 comment:append (--file --id | --uri ) (--comment | --comment-file | --stdin) [--settle-ms ]", "", "Examples:", " sidenote2 comment:append --file ./note.md --id comment-1 --comment-file ./reply.md", + " sidenote2 comment:append --uri \"obsidian://side-note2-comment?...\" --comment-file ./reply.md", " sidenote2 comment:append --file ./note.md --id comment-1 --comment-file ./reply.md --settle-ms 2000", " printf 'Reply body\\n' | sidenote2 comment:append --file ./note.md --id comment-1 --stdin", ].join("\n") + "\n", ); } +function printCommentResolveUsage(stream = process.stderr) { + stream.write( + [ + "Usage:", + " sidenote2 comment:resolve (--file --id | --uri ) [--settle-ms ]", + "", + "Examples:", + " sidenote2 comment:resolve --file ./note.md --id comment-1", + " sidenote2 comment:resolve --uri \"obsidian://side-note2-comment?...\"", + " sidenote2 comment:resolve --file ./note.md --id comment-1 --settle-ms 2000", + ].join("\n") + "\n", + ); +} + function printCommentMigrateLegacyUsage(stream = process.stderr) { stream.write( [ @@ -96,10 +114,28 @@ function printSkillUsage(command, stream = process.stderr) { ); } +function printUninstallAgentSupportUsage(stream = process.stderr) { + stream.write( + [ + "Usage:", + " sidenote2 uninstall-agent-support (--vault-root | --vault ) [--skills-root ]", + "", + "Defaults:", + " --skills-root defaults to $CODEX_HOME/skills or ~/.codex/skills", + "", + "Examples:", + " sidenote2 uninstall-agent-support --vault-root /path/to/vault", + " sidenote2 uninstall-agent-support --vault public", + " sidenote2 uninstall-agent-support --vault-root /path/to/vault --skills-root /tmp/skills", + ].join("\n") + "\n", + ); +} + function parseCommentUpdateArgs(argv) { const options = { file: "", id: "", + uri: "", comment: null, commentFile: "", stdin: false, @@ -117,6 +153,10 @@ function parseCommentUpdateArgs(argv) { options.id = argv[index + 1] ?? ""; index += 1; break; + case "--uri": + options.uri = argv[index + 1] ?? ""; + index += 1; + break; case "--comment": options.comment = argv[index + 1] ?? ""; index += 1; @@ -141,8 +181,11 @@ function parseCommentUpdateArgs(argv) { } const contentSources = [options.comment !== null, Boolean(options.commentFile), options.stdin].filter(Boolean).length; - if (!options.file || !options.id || contentSources !== 1) { - throw new Error("Expected --file, --id, and exactly one comment source."); + const hasFileOrIdTarget = Boolean(options.file || options.id); + const hasFileAndIdTarget = Boolean(options.file && options.id); + const hasUriTarget = Boolean(options.uri); + if (contentSources !== 1 || (hasFileOrIdTarget ? 1 : 0) + (hasUriTarget ? 1 : 0) !== 1 || (hasFileOrIdTarget && !hasFileAndIdTarget)) { + throw new Error("Expected exactly one target form: either --file with --id, or --uri, plus exactly one comment source."); } return options; @@ -152,6 +195,51 @@ function parseCommentAppendArgs(argv) { return parseCommentUpdateArgs(argv); } +function parseCommentResolveArgs(argv) { + const options = { + file: "", + id: "", + uri: "", + settleMs: 0, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + switch (arg) { + case "--file": + options.file = argv[index + 1] ?? ""; + index += 1; + break; + case "--id": + options.id = argv[index + 1] ?? ""; + index += 1; + break; + case "--uri": + options.uri = argv[index + 1] ?? ""; + index += 1; + break; + case "--settle-ms": + options.settleMs = parseNonNegativeIntegerOption(argv[index + 1] ?? "", "--settle-ms"); + index += 1; + break; + case "--help": + case "-h": + return null; + default: + throw new Error(`Unknown argument: ${arg}`); + } + } + + const hasFileOrIdTarget = Boolean(options.file || options.id); + const hasFileAndIdTarget = Boolean(options.file && options.id); + const hasUriTarget = Boolean(options.uri); + if ((hasFileOrIdTarget ? 1 : 0) + (hasUriTarget ? 1 : 0) !== 1 || (hasFileOrIdTarget && !hasFileAndIdTarget)) { + throw new Error("Expected exactly one target form: either --file with --id, or --uri."); + } + + return options; +} + function parseCommentMigrateLegacyArgs(argv) { const options = { file: "", @@ -227,6 +315,44 @@ function parseSkillArgs(argv) { return options; } +function parseUninstallAgentSupportArgs(argv) { + const options = { + vaultRoot: "", + vaultName: "", + skillsRoot: getDefaultSkillsRoot(), + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + switch (arg) { + case "--vault-root": + options.vaultRoot = argv[index + 1] ?? ""; + index += 1; + break; + case "--vault": + options.vaultName = argv[index + 1] ?? ""; + index += 1; + break; + case "--skills-root": + options.skillsRoot = path.resolve(process.cwd(), argv[index + 1] ?? ""); + index += 1; + break; + case "--help": + case "-h": + return null; + default: + throw new Error(`Unknown argument: ${arg}`); + } + } + + const targetCount = [Boolean(options.vaultRoot), Boolean(options.vaultName)].filter(Boolean).length; + if (targetCount !== 1) { + throw new Error("Expected exactly one of --vault-root or --vault."); + } + + return options; +} + function getDefaultSkillsRoot() { const codexHome = process.env.CODEX_HOME?.trim(); return codexHome @@ -367,6 +493,127 @@ async function loadCommentBody(options) { return readStdin(); } +const COMMENT_LOCATION_PROTOCOL = "side-note2-comment"; +const VAULT_AGENTS_MANAGED_BLOCK_START_PREFIX = ""; + +function parseCommentProtocolUri(uri) { + try { + const parsed = new URL(uri); + if (parsed.protocol !== "obsidian:" || parsed.hostname !== COMMENT_LOCATION_PROTOCOL) { + return null; + } + + const vaultName = parsed.searchParams.get("vault"); + const filePath = parsed.searchParams.get("file"); + const commentId = parsed.searchParams.get("commentId"); + if (!(vaultName && filePath && commentId)) { + return null; + } + + return { + vaultName, + filePath, + commentId, + }; + } catch { + return null; + } +} + +function getObsidianConfigPath() { + const explicitConfigPath = process.env.OBSIDIAN_CONFIG_PATH?.trim(); + return explicitConfigPath + ? path.resolve(process.cwd(), explicitConfigPath) + : path.join(homedir(), ".config", "obsidian", "obsidian.json"); +} + +async function resolveVaultRootByName(vaultName) { + const configPath = getObsidianConfigPath(); + let config; + try { + config = JSON.parse(await readFile(configPath, "utf8")); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error(`Could not read Obsidian vault config at ${configPath}: ${reason}`); + } + + const configuredVaults = config?.vaults; + if (!configuredVaults || typeof configuredVaults !== "object") { + throw new Error(`Obsidian vault config at ${configPath} does not contain a valid vault list.`); + } + + const matchingVaultRoots = []; + for (const value of Object.values(configuredVaults)) { + if (!value || typeof value !== "object") { + continue; + } + + const vaultPath = typeof value.path === "string" ? value.path : ""; + if (!vaultPath) { + continue; + } + + const resolvedVaultPath = path.resolve(vaultPath); + if (path.basename(resolvedVaultPath) === vaultName) { + matchingVaultRoots.push(resolvedVaultPath); + } + } + + if (matchingVaultRoots.length === 0) { + throw new Error(`Could not resolve Obsidian vault "${vaultName}" from ${configPath}.`); + } + + if (matchingVaultRoots.length > 1) { + throw new Error(`Found multiple Obsidian vaults named "${vaultName}" in ${configPath}.`); + } + + return matchingVaultRoots[0]; +} + +async function resolveVaultRootForAgentSupport(options) { + if (options.vaultRoot) { + return path.resolve(process.cwd(), options.vaultRoot); + } + + return resolveVaultRootByName(options.vaultName); +} + +function resolveVaultRelativeNotePath(vaultRoot, filePath) { + const resolvedNotePath = path.resolve(vaultRoot, filePath); + const relativePath = path.relative(vaultRoot, resolvedNotePath); + if ( + relativePath.startsWith("..") + || path.isAbsolute(relativePath) + || relativePath === "" + || relativePath === "." + ) { + throw new Error(`Comment URI file path escapes the resolved vault root: ${filePath}`); + } + + return resolvedNotePath; +} + +async function resolveCommentWriteTarget(options) { + if (options.uri) { + const uriTarget = parseCommentProtocolUri(options.uri); + if (!uriTarget) { + throw new Error("Expected --uri to be an obsidian://side-note2-comment link with vault, file, and commentId."); + } + + const vaultRoot = await resolveVaultRootByName(uriTarget.vaultName); + return { + notePath: resolveVaultRelativeNotePath(vaultRoot, uriTarget.filePath), + commentId: uriTarget.commentId, + }; + } + + return { + notePath: path.resolve(process.cwd(), options.file), + commentId: options.id, + }; +} + async function getBundledSkills() { const repoRoot = getRepoRoot(import.meta.url); const skillsRoot = path.join(repoRoot, "skills"); @@ -397,6 +644,79 @@ async function getBundledSkills() { return { repoRoot, skillDirectories }; } +function normalizeTextLineEndings(content) { + return content.replace(/\r\n/g, "\n"); +} + +function findVaultAgentsManagedBlockRange(content) { + const normalizedContent = normalizeTextLineEndings(content); + const start = normalizedContent.indexOf(VAULT_AGENTS_MANAGED_BLOCK_START_PREFIX); + if (start === -1) { + return null; + } + + const endMarkerStart = normalizedContent.indexOf(VAULT_AGENTS_MANAGED_BLOCK_END, start); + if (endMarkerStart === -1) { + return null; + } + + let end = endMarkerStart + VAULT_AGENTS_MANAGED_BLOCK_END.length; + if (normalizedContent.charAt(end) === "\n") { + end += 1; + } + + return { start, end }; +} + +function isLegacyManagedVaultAgentsFileContent(content) { + const normalizedContent = normalizeTextLineEndings(content).trim(); + return normalizedContent.startsWith("# SideNote2 Vault Agent Routing") + && normalizedContent.includes("When a user is working with real SideNote2 comments in this vault:") + && normalizedContent.includes("obsidian://side-note2-comment?..."); +} + +function removeManagedBlockFromDocument(existingContent, blockRange) { + const normalizedContent = normalizeTextLineEndings(existingContent); + const before = normalizedContent.slice(0, blockRange.start).replace(/\s+$/u, ""); + const after = normalizedContent.slice(blockRange.end).replace(/^\s+/u, ""); + + if (!before && !after) { + return ""; + } + + if (!before) { + return `${after}\n`; + } + + if (!after) { + return `${before}\n`; + } + + return `${before}\n\n${after}\n`; +} + +function stripSideNote2ManagedAgentsContent(existingContent) { + const managedBlockRange = findVaultAgentsManagedBlockRange(existingContent); + if (managedBlockRange) { + return { + kind: "removed", + nextContent: removeManagedBlockFromDocument(existingContent, managedBlockRange), + }; + } + + if (isLegacyManagedVaultAgentsFileContent(existingContent)) { + return { + kind: "removed", + nextContent: "", + }; + } + + return { + kind: "noop", + nextContent: normalizeTextLineEndings(existingContent), + }; +} + async function loadStorageModule(repoRoot) { const entryPoint = path.resolve(repoRoot, "src/core/storage/noteCommentStorage.ts"); const result = await esbuild.build({ @@ -704,11 +1024,20 @@ async function runCommentUpdate(argv, streamOut, streamErr) { } const repoRoot = getRepoRoot(import.meta.url); - const notePath = path.resolve(process.cwd(), options.file); + let writeTarget; + try { + writeTarget = await resolveCommentWriteTarget(options); + } catch (error) { + streamErr.write(`${error instanceof Error ? error.message : String(error)}\n`); + return 1; + } + + const notePath = writeTarget.notePath; + const commentId = writeTarget.commentId; const nextCommentBody = await loadCommentBody(options); const noteContent = await readFile(notePath, "utf8"); const storageModule = await loadStorageModule(repoRoot); - const updated = storageModule.replaceNoteCommentBodyById(noteContent, notePath, options.id, nextCommentBody); + const updated = storageModule.replaceNoteCommentBodyById(noteContent, notePath, commentId, nextCommentBody); if (typeof updated !== "string") { const plan = buildLegacyMigrationPlan(notePath, noteContent, storageModule); @@ -721,7 +1050,7 @@ async function runCommentUpdate(argv, streamOut, streamErr) { return 1; } - streamErr.write(`Comment id not found: ${options.id}\n`); + streamErr.write(`Comment id not found: ${commentId}\n`); return 1; } @@ -736,7 +1065,7 @@ async function runCommentUpdate(argv, streamOut, streamErr) { return 1; } - streamOut.write(`Updated comment ${options.id} in ${notePath}\n`); + streamOut.write(`Updated comment ${commentId} in ${notePath}\n`); return 0; } @@ -756,11 +1085,20 @@ async function runCommentAppend(argv, streamOut, streamErr) { } const repoRoot = getRepoRoot(import.meta.url); - const notePath = path.resolve(process.cwd(), options.file); + let writeTarget; + try { + writeTarget = await resolveCommentWriteTarget(options); + } catch (error) { + streamErr.write(`${error instanceof Error ? error.message : String(error)}\n`); + return 1; + } + + const notePath = writeTarget.notePath; + const commentId = writeTarget.commentId; const nextCommentBody = await loadCommentBody(options); const noteContent = await readFile(notePath, "utf8"); const storageModule = await loadStorageModule(repoRoot); - const updated = storageModule.appendNoteCommentEntryById(noteContent, notePath, options.id, { + const updated = storageModule.appendNoteCommentEntryById(noteContent, notePath, commentId, { id: randomUUID(), body: nextCommentBody, timestamp: Date.now(), @@ -789,7 +1127,7 @@ async function runCommentAppend(argv, streamOut, streamErr) { return 1; } - streamErr.write(`Comment id not found: ${options.id}\n`); + streamErr.write(`Comment id not found: ${commentId}\n`); return 1; } @@ -804,7 +1142,79 @@ async function runCommentAppend(argv, streamOut, streamErr) { return 1; } - streamOut.write(`Appended a new entry to comment ${options.id} in ${notePath}\n`); + streamOut.write(`Appended a new entry to comment ${commentId} in ${notePath}\n`); + return 0; +} + +async function runCommentResolve(argv, streamOut, streamErr) { + let options; + try { + options = parseCommentResolveArgs(argv); + } catch (error) { + streamErr.write(`${error instanceof Error ? error.message : String(error)}\n`); + printCommentResolveUsage(streamErr); + return 1; + } + + if (options === null) { + printCommentResolveUsage(streamOut); + return 0; + } + + const repoRoot = getRepoRoot(import.meta.url); + let writeTarget; + try { + writeTarget = await resolveCommentWriteTarget(options); + } catch (error) { + streamErr.write(`${error instanceof Error ? error.message : String(error)}\n`); + return 1; + } + + const notePath = writeTarget.notePath; + const commentId = writeTarget.commentId; + const noteContent = await readFile(notePath, "utf8"); + const storageModule = await loadStorageModule(repoRoot); + const updated = storageModule.resolveNoteCommentById(noteContent, notePath, commentId); + + if (typeof updated !== "string") { + const plan = buildLegacyMigrationPlan(notePath, noteContent, storageModule); + if (plan.kind === "legacy") { + streamErr.write( + `Note still uses legacy flat SideNote2 comments: ${notePath}\n` + + "Open the vault once in SideNote2 2.0.1+ so startup can auto-migrate it, " + + "or use `sidenote2 comment:migrate-legacy` only for out-of-band maintenance.\n", + ); + return 1; + } + + if (plan.kind === "unsupported") { + streamErr.write( + `Found a SideNote2 comments block in ${notePath}, but it is not a supported threaded payload.\n`, + ); + return 1; + } + + if (plan.kind === "no-managed-block") { + streamErr.write(`No SideNote2 comments block found in ${notePath}\n`); + return 1; + } + + streamErr.write(`Comment id not found: ${commentId}\n`); + return 1; + } + + const writeResult = await writeObservedNoteSafely(notePath, createContentFingerprint(noteContent), updated, { + settleMs: options.settleMs, + }); + if (writeResult.kind === "changed") { + streamErr.write( + `Skipped resolving ${notePath} because ${writeResult.reason}. ` + + "Rerun after Obsidian Sync or other local edits settle.\n", + ); + return 1; + } + + streamOut.write(`Resolved comment ${commentId} in ${notePath}\n`); return 0; } @@ -984,6 +1394,65 @@ async function runInstallSkill(argv, streamOut, streamErr) { return 0; } +async function runUninstallAgentSupport(argv, streamOut, streamErr) { + let options; + try { + options = parseUninstallAgentSupportArgs(argv); + } catch (error) { + streamErr.write(`${error instanceof Error ? error.message : String(error)}\n`); + printUninstallAgentSupportUsage(streamErr); + return 1; + } + + if (options === null) { + printUninstallAgentSupportUsage(streamOut); + return 0; + } + + const vaultRoot = await resolveVaultRootForAgentSupport(options); + const vaultAgentsPath = path.join(vaultRoot, "AGENTS.md"); + let agentsResult = "No AGENTS.md found."; + + if (await pathExists(vaultAgentsPath)) { + const existingAgentsContent = await readFile(vaultAgentsPath, "utf8"); + const stripResult = stripSideNote2ManagedAgentsContent(existingAgentsContent); + if (stripResult.kind === "removed") { + if (stripResult.nextContent.trim().length === 0) { + await rm(vaultAgentsPath, { force: true }); + agentsResult = `Removed SideNote2 AGENTS.md content and deleted ${vaultAgentsPath}.`; + } else { + await writeFileAtomically(vaultAgentsPath, stripResult.nextContent); + agentsResult = `Removed the SideNote2-managed AGENTS block from ${vaultAgentsPath}.`; + } + } else { + agentsResult = `No SideNote2-managed AGENTS block found in ${vaultAgentsPath}.`; + } + } + + const { skillDirectories } = await getBundledSkills(); + const removedSkillNames = []; + for (const skillName of [...skillDirectories.keys()].sort((left, right) => left.localeCompare(right))) { + const destinationDir = path.join(options.skillsRoot, skillName); + if (!(await pathExists(destinationDir))) { + continue; + } + + await rm(destinationDir, { recursive: true, force: true }); + removedSkillNames.push(skillName); + } + + streamOut.write(`${agentsResult}\n`); + if (removedSkillNames.length > 0) { + for (const skillName of removedSkillNames) { + streamOut.write(`Removed skill ${skillName} from ${path.join(options.skillsRoot, skillName)}\n`); + } + } else { + streamOut.write(`No bundled SideNote2 skills found under ${options.skillsRoot}\n`); + } + streamOut.write("Restart Codex to drop any cached skills.\n"); + return 0; +} + export { createContentFingerprint, writeObservedNoteSafely, @@ -998,8 +1467,12 @@ export async function runCli(argv, io = { stdout: process.stdout, stderr: proces return runCommentAppend(rest, io.stdout, io.stderr); case "comment:update": return runCommentUpdate(rest, io.stdout, io.stderr); + case "comment:resolve": + return runCommentResolve(rest, io.stdout, io.stderr); case "install-skill": return runInstallSkill(rest, io.stdout, io.stderr); + case "uninstall-agent-support": + return runUninstallAgentSupport(rest, io.stdout, io.stderr); case "--help": case "-h": case undefined: diff --git a/docs/README-dev.md b/docs/README-dev.md index 0b7dbd1..b9a55b2 100644 --- a/docs/README-dev.md +++ b/docs/README-dev.md @@ -80,11 +80,19 @@ SIDENOTE2_HOT_RELOAD=0 npm run dev - `npm run build` creates a production bundle. - `npm test` runs the Node test suite. -- `npm run skill:install` copies the packaged SideNote2 Codex skills into the default Codex skills directory. By default it installs every bundled skill under `skills/`; pass `-- --name ` to install just one. This matches the end-user install flow. +- `npm run skill:install` copies the packaged SideNote2 Codex skills into the default Codex skills directory. By default it installs every bundled top-level skill under `skills/`; pass `-- --name ` to install just one. This is optional global Codex setup, not the default end-user flow inside Obsidian. - `npm run comment:append -- --file "/abs/path/note.md" --id "" --comment-file "/abs/path/reply.md"` appends one new entry to an existing SideNote2 thread using the same managed block format as the plugin. +- `npm run comment:append -- --uri "obsidian://side-note2-comment?..." --comment-file "/abs/path/reply.md"` does the same when you already have a SideNote2 comment URI instead of a note path and comment id. - `npm run comment:migrate-legacy -- --file "/abs/path/note.md" --dry-run` is a temporary maintenance fallback for a note that somehow missed the automatic `2.0.1` startup migration. - `npm run comment:migrate-legacy -- --root "/abs/path/to/vault" --dry-run` is the same fallback at vault scope for out-of-band repair work, not the normal upgrade path. +- `npm run comment:resolve -- --file "/abs/path/note.md" --id ""` marks one stored SideNote2 thread resolved using the same managed block format as the plugin. +- `npm run comment:resolve -- --uri "obsidian://side-note2-comment?..."` resolves a stored SideNote2 thread directly from a SideNote2 comment URI. - `npm run comment:update -- --file "/abs/path/note.md" --id "" --comment-file "/abs/path/comment.md"` updates one stored comment body using the same managed block format as the plugin. +- `npm run comment:update -- --uri "obsidian://side-note2-comment?..." --comment-file "/abs/path/comment.md"` updates a stored comment body directly from a SideNote2 comment URI. +- On startup, the plugin now ensures the vault root has SideNote2 agent instructions by creating `AGENTS.md` when missing, or inserting/updating a SideNote2-managed block inside an existing `AGENTS.md`. Existing user instructions stay intact outside that managed block. +- The plugin command `SideNote2: Sync AGENTS.md in vault root` creates, inserts, or updates the SideNote2-managed instructions in the active Obsidian vault root using the vault name and, on desktop, the vault base path from the Obsidian runtime API. +- The plugin command `SideNote2: Remove SideNote2 agent support from vault` removes the SideNote2-managed block from the active vault `AGENTS.md`, and deletes the file if that managed block was the only content. +- `npm run agent:uninstall -- --vault-root "/abs/path/to/vault"` removes the SideNote2-managed vault `AGENTS.md` block and uninstalls bundled SideNote2 Codex skills from the default skills root. Use `--skills-root` to target a different Codex skills directory, or `--vault ""` to resolve the vault through Obsidian config instead of an explicit path. - The plugin auto-migrates legacy flat note comments once per vault on startup in `2.0.1`. Normal upgrades should not require any manual migration command. - `comment:append`, `comment:migrate-legacy`, and `comment:update` now write atomically and refuse to overwrite a note if it changed after the script first read it. If Obsidian Sync or another editor is active, pass `-- --settle-ms 2000` to require a short quiet window before each write, then rerun any skipped notes after Sync settles. Treat skipped-note runs as partial success and retry them instead of hand-editing the managed JSON. - `npm version patch|minor|major` updates `package.json`, `manifest.json`, `versions.json`, and the README beta badge together for a release bump. diff --git a/package.json b/package.json index 35e1c75..2a08b36 100755 --- a/package.json +++ b/package.json @@ -11,8 +11,10 @@ "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", "comment:append": "node bin/sidenote2.mjs comment:append", "comment:migrate-legacy": "node bin/sidenote2.mjs comment:migrate-legacy", + "comment:resolve": "node bin/sidenote2.mjs comment:resolve", "comment:update": "node bin/sidenote2.mjs comment:update", "fixture:graph-1000": "node scripts/generate-large-graph-fixture.mjs", + "agent:uninstall": "node bin/sidenote2.mjs uninstall-agent-support", "skill:install": "node bin/sidenote2.mjs install-skill", "test": "rm -rf .test-dist && tsc -p tsconfig.test.json && find .test-dist/tests -type f -name '*.test.js' -exec node --test {} + && find tests -type f -name '*.test.mjs' -exec node --test {} +", "release:check": "npm run test && npm run build", diff --git a/scripts/resolve-note-comment.mjs b/scripts/resolve-note-comment.mjs new file mode 100644 index 0000000..2f35793 --- /dev/null +++ b/scripts/resolve-note-comment.mjs @@ -0,0 +1,5 @@ +#!/usr/bin/env node + +import { main } from "../bin/sidenote2.mjs"; + +await main(["comment:resolve", ...process.argv.slice(2)]); diff --git a/skills/dev/obsidian-plugin-dev/SKILL.md b/skills/dev/obsidian-plugin-dev/SKILL.md index c364c39..47f0a7d 100644 --- a/skills/dev/obsidian-plugin-dev/SKILL.md +++ b/skills/dev/obsidian-plugin-dev/SKILL.md @@ -28,9 +28,9 @@ description: Use when building, debugging, releasing, or submitting an Obsidian - In this repo, treat the repo-local `skills/*/SKILL.md` files as the canonical agent instructions. - Use this skill for general plugin repo, API, UI, build, and release work. -- When the task is about real SideNote2-backed vault notes, hidden `` blocks, or helper-script based note comment edits, switch to `skills/side-note2-note-comments/SKILL.md`. +- When the task is about real SideNote2-backed vault notes, `obsidian://side-note2-comment?...` links, hidden `` blocks, or helper-script based note comment edits, switch to `skills/sidenote2/SKILL.md`. - Do not keep separate Claude-only and Codex-only copies of the same repo-local skill text unless the workflows genuinely diverge. -- If you need to test the packaged global install flow from this repo, run `npm run skill:install` or `npm run skill:install -- --name obsidian-plugin-dev`. +- If you need to test the packaged global install flow from this repo, run `npm run skill:install` or `npm run skill:install -- --name sidenote2`. ## Reference Sets diff --git a/skills/side-note2-note-comments/SKILL.md b/skills/side-note2-note-comments/SKILL.md deleted file mode 100644 index d6b60ce..0000000 --- a/skills/side-note2-note-comments/SKILL.md +++ /dev/null @@ -1,140 +0,0 @@ ---- -name: side-note2-note-comments -description: Use when reading or editing SideNote2-backed Obsidian notes. Covers finding the relevant markdown note in the vault, reading the note body plus trailing `` JSON block, identifying stored comments by `id` or `selectedText`, and using the repo helper script or direct note edits when the user asks the agent to write changes. ---- - -# SideNote2 Obsidian Notes - -Use this skill when the user wants work done against real Obsidian notes that use SideNote2 note-backed comments. - -## Scope - -- Read note content directly from markdown files in the vault. -- Read SideNote2 comment data from the trailing `` block in the same note. -- Write note content directly when the user asks to edit the note body. -- Write SideNote2 comment bodies through the helper script when possible. -- Append new entries to an existing SideNote2 thread when the user says things like `reply`, `answer in the side comment`, `add another note under this`, or `add to thread`. - -## Replacement Lookup Paths - -Use one of these two paths to locate the side comment that should be replaced: - -1. `SideNote2 index.md` - - Use this to discover the note path and comment target when the user refers to a side comment indirectly. - - Treat it as a jump index only, not canonical storage. -2. The user’s active note in source mode - - Use the trailing `` block at the bottom of the active markdown note. - - Treat this as the authoritative source when reading or writing a stored comment. - -## Finding Notes - -1. Prefer an explicit absolute path from the user. -2. Resolve the actual Obsidian vault root before searching broadly. - - Do not assume the plugin repo root is the vault root. - - If Obsidian CLI is available, check `obsidian vaults verbose` to map vault names to paths. - - Check `~/.config/obsidian/obsidian.json` for vault entries marked `"open": true` when you need the vault(s) currently open in Obsidian. - - Check `/.obsidian/workspace.json` for the current workspace state, active leaves, and recent files when you need the note the user is actively working in. - - If the repo is nested inside a larger vault, search the outer vault root for notes and keep the repo root only for helper scripts. -3. If the user gives only a note title or fragment, search the resolved vault root for matching `.md` files. -4. Treat the markdown note itself as the source of truth. -5. Do not rely on the Obsidian UI state alone when the note file can be read directly. - -## Reading Workflow - -1. Confirm the target note path. -2. If needed, use `SideNote2 index.md` to find the relevant note path first. -3. Open the note itself, not just plugin code or generated index files. -4. Read both: - - the main markdown content - - the trailing `` block, if present -5. When the user asks about a specific side note: - - match by `id` if available - - otherwise match by `selectedText` and surrounding context - - do not rely on `timestamp` if a stronger identifier exists - -## Writing Workflow - -If the user asks to edit the note body: - -1. Edit the markdown note directly. -2. Preserve the trailing SideNote2 managed block unless the task explicitly changes comments. - -If the user asks to add a page-level tag in a SideNote2 note: - -1. Inspect the note's leading YAML frontmatter only if it starts at the top of the file. -2. If that frontmatter already contains a `tags` field, add the tag there and avoid duplicates. -3. Preserve the note's existing `tags` style when practical, such as a YAML list versus inline form. -4. If the note does not have a leading frontmatter `tags` field, do not create one just for this request. -5. In that fallback case, add the tag as a SideNote2 page note instead. - -If the user asks to reply to a side comment, answer a question in a side comment, add another note under a side comment, add to thread, or split one longer side comment into several short notes in the same place: - -1. Treat this as an append-to-thread request. -2. Do not overwrite the existing comment body unless the user explicitly asks to edit or rewrite that entry. -3. Do not create a separate markdown file just to continue the same side comment thread. -4. Confirm the target note path. -5. Identify the target thread by `id` when it is available. - - If `id` is not provided, match by `selectedText` and surrounding note context. - - If multiple stored comments in the same note share the same `selectedText`, ask for more context or use the `id`. -6. Prefer the append helper script from the repo root: - -```bash -cd "/abs/path/to/SideNote2" -node scripts/append-note-comment-entry.mjs --file "/abs/path/to/note.md" --id "" --comment-file "/abs/path/to/reply.md" -``` - -Short replies can use `--comment "Reply body"` instead of `--comment-file`. -If Sync is active, add `--settle-ms 2000` here too so the script skips notes that changed after it read them instead of overwriting them. - -7. If the note is outside the writable workspace, request escalation before running the script. -8. If the helper script cannot be used, append one new object to the end of the target thread's `entries[]` array and preserve all existing entries. -9. Use this pattern for: - - answering a question the user wrote inside an existing side comment - - splitting one comment into multiple shorter thread entries for readability, similar to a short post thread -10. Verify that only the target thread changed and that exactly one new entry was appended. - -If the user asks to edit a stored SideNote2 comment: - -Use this workflow only when the user wants to replace or rewrite an existing stored comment body. If the user says `reply`, `add another note under`, `continue this`, or similar, use the append-to-thread workflow above instead. - -1. Confirm the target note path. -2. If needed, use `SideNote2 index.md` to locate the target note and comment first. -3. Inspect the trailing `` block in the active markdown note. -4. Identify the target comment by `id` when it is available. - - Natural-language requests such as `Update the side comment for "selected text" in "/path/to/note.md" to: ...` should be interpreted as a `selectedText`-based replacement request. - - Natural-language requests such as `Reply to the side comment for "selected text"` or `Add another note under this side comment` are append-to-thread requests, not replacement requests. - - If `id` is not provided, match by `selectedText` and surrounding note context. - - If multiple stored comments in the same note share the same `selectedText`, ask for more context or use the `id`. -5. Prefer the helper script from the repo root: - -```bash -cd "/abs/path/to/SideNote2" -node scripts/update-note-comment.mjs --file "/abs/path/to/note.md" --id "" --comment-file "/abs/path/to/comment.md" -``` - -Short replacements can use `--comment "New body"` instead of `--comment-file`. -If Sync is active, add `--settle-ms 2000` here too so the script skips notes that changed after it read them instead of overwriting them. - -6. If the note is outside the writable workspace, request escalation before running the script. -7. Verify the note still contains exactly one managed block and that only the target comment thread or entry body changed. - -## Important Details - -- SideNote2 stores comments as strict JSON in the trailing hidden block. -- Treat threaded `entries[]` storage as canonical. Starting in SideNote2 `2.0.1`, the plugin auto-migrates older flat note-comment payloads on startup, so agents should not build manual legacy migration steps into normal workflows. -- In threaded storage, `entries[]` order is meaningful. Replies and `add another note under this` requests should usually append one new entry at the end. -- Use `scripts/append-note-comment-entry.mjs` for append-to-thread requests and `scripts/update-note-comment.mjs` only for replacement requests. -- The helper scripts write atomically and skip notes that changed after the initial read. Treat a skipped note as a retry case, not as a signal to hand-edit the managed JSON. -- Multiline comment bodies must stay JSON-escaped in source; do not paste raw block text into the JSON string by hand unless necessary. -- The note itself is the source of truth. Sidebar state and aggregate views are derived from the note. -- `SideNote2 index.md` is generated output, not canonical storage. - -## Fallback - -If the helper script cannot be used: - -1. Edit the source-mode block directly. -2. Preserve `id`, anchor coordinates, `selectedText`, `selectedTextHash`, timestamps, and `resolved`. -3. For replacement requests, change only the target `entries[*].body`. -4. For append-to-thread requests, append one new object to the target thread's `entries[]` array without modifying the existing entries. -5. If a note still shows an old flat `comment` payload unexpectedly, stop and ask the user to open the vault once in SideNote2 `2.0.1+` so the startup migration can finish instead of hand-migrating JSON in the agent workflow. diff --git a/skills/sidenote2/SKILL.md b/skills/sidenote2/SKILL.md new file mode 100644 index 0000000..7fdf4f9 --- /dev/null +++ b/skills/sidenote2/SKILL.md @@ -0,0 +1,95 @@ +--- +name: sidenote2 +description: Use when a user is working with SideNote2 comments in real Obsidian notes, especially if they paste an `obsidian://side-note2-comment?...` link or ask to reply to, update, or inspect a stored side note thread. +--- + +# SideNote2 + +Use this as the user-facing SideNote2 skill. + +This skill exists so an agent can recognize normal user phrasing without requiring the user to know internal repo skill names. + +## Trigger Phrases + +Use this skill when the user: + +- pastes an `obsidian://side-note2-comment?...` URI +- says `reply to this` +- says `answer this side note` +- says `add another note under this` +- says `update this side note` +- says `resolve this side note` +- says `edit this stored comment` +- gives a `commentId` and wants to act on a SideNote2 thread + +## Source Of Truth + +- The markdown note itself is canonical. +- SideNote2 stores comments in the trailing `` block in that note. +- `SideNote2 index.md` is derived output. Use it to discover a note path, not as canonical storage. + +## Working Rules + +1. If the user provided an `obsidian://side-note2-comment?...` URI: + - treat it as the exact thread target + - prefer the URI-based CLI path instead of re-discovering the note manually +2. If no URI is provided, locate the real markdown note and read the trailing SideNote2 managed block. +3. Distinguish: + - `reply`, `continue`, `answer this`, `add another note under this` + append to the thread + - `update`, `rewrite`, `replace` + replace the targeted stored comment body + - `resolve`, `mark resolved`, `archive this side note` + mark the targeted thread resolved +4. Prefer helper scripts over hand-editing JSON. +5. Preserve all existing thread entries unless the user explicitly asked to replace one. + +## Preferred CLI Shapes + +Append: + +```bash +node scripts/append-note-comment-entry.mjs --uri "obsidian://side-note2-comment?..." --comment-file /abs/path/reply.md +``` + +Or with an explicit file and comment id: + +```bash +node scripts/append-note-comment-entry.mjs --file /abs/path/note.md --id "" --comment-file /abs/path/reply.md +``` + +Update: + +```bash +node scripts/update-note-comment.mjs --uri "obsidian://side-note2-comment?..." --comment-file /abs/path/comment.md +``` + +Or with an explicit file and comment id: + +```bash +node scripts/update-note-comment.mjs --file /abs/path/note.md --id "" --comment-file /abs/path/comment.md +``` + +Resolve: + +```bash +node scripts/resolve-note-comment.mjs --uri "obsidian://side-note2-comment?..." +``` + +Or with an explicit file and comment id: + +```bash +node scripts/resolve-note-comment.mjs --file /abs/path/note.md --id "" +``` + +## Matching Rules + +- Match by `commentId` when available. +- Otherwise match by `selectedText` plus nearby note context. +- If multiple stored comments share the same `selectedText`, ask for more context or use the URI/comment id. + +## Safety + +- Do not overwrite a thread when the user asked to reply. +- Do not hand-migrate legacy flat `comment` payloads during normal agent work. +- If the helper script refuses a note because it changed after read, treat it as a retry case instead of editing the JSON manually. diff --git a/src/control/pluginRegistrationController.ts b/src/control/pluginRegistrationController.ts index 435beba..e711117 100644 --- a/src/control/pluginRegistrationController.ts +++ b/src/control/pluginRegistrationController.ts @@ -31,7 +31,8 @@ export interface PluginRegistrationHost { id: string; name: string; icon: string; - editorCallback: ( + callback?: () => Promise | void; + editorCallback?: ( editor: EditorSelectionLike, view: EditorCommandViewLike, ) => Promise | void; @@ -48,6 +49,8 @@ export interface PluginRegistrationHost { startDraftFromEditorSelection(editor: EditorSelectionLike, file: TFile | null): Promise; highlightCommentById(filePath: string, commentId: string): Promise; openIndexNote(): Promise | void; + installVaultAgentsFile(): Promise; + uninstallVaultAgentsFile(): Promise; } export interface CommentProtocolTarget { @@ -85,6 +88,24 @@ export class PluginRegistrationController { }, }); + this.host.addCommand({ + id: "install-vault-agents-file", + name: "Sync AGENTS.md in vault root", + icon: this.host.iconId, + callback: async () => { + await this.host.installVaultAgentsFile(); + }, + }); + + this.host.addCommand({ + id: "uninstall-vault-agents-file", + name: "Remove SideNote2 agent support from vault", + icon: this.host.iconId, + callback: async () => { + await this.host.uninstallVaultAgentsFile(); + }, + }); + this.host.registerEditorMenu((menu, editor, view) => { if (!editor.somethingSelected()) { return; diff --git a/src/control/vaultAgentsFileController.ts b/src/control/vaultAgentsFileController.ts new file mode 100644 index 0000000..3492d60 --- /dev/null +++ b/src/control/vaultAgentsFileController.ts @@ -0,0 +1,339 @@ +export interface VaultAgentsFileContext { + vaultName: string; + vaultRootPath: string | null; + pluginVersion: string; +} + +export interface VaultAgentsFileHost { + getVaultAgentsFileContext(): VaultAgentsFileContext; + vaultRootFileExists(relativePath: string): Promise; + readVaultRootFile(relativePath: string): Promise; + writeVaultRootFile(relativePath: string, content: string): Promise; + deleteVaultRootFile(relativePath: string): Promise; + showNotice(message: string): void; + warn(message: string, error: unknown): void; +} + +const VAULT_AGENTS_FILE_PATH = "AGENTS.md"; +const MANAGED_BLOCK_START_PREFIX = ""; + +export type VaultAgentsSyncMode = "manual" | "startup"; + +export type VaultAgentsSyncPlan = + | { + kind: "write"; + nextContent: string; + reason: "created" | "updated" | "inserted"; + } + | { + kind: "noop"; + reason: "already-current"; + }; + +type VaultAgentsWriteReason = Extract["reason"]; + +type VaultAgentsRemovalPlan = + | { + kind: "write"; + nextContent: string; + } + | { + kind: "delete"; + } + | { + kind: "noop"; + }; + +function normalizeLineEndings(content: string): string { + return content.replace(/\r\n/g, "\n"); +} + +function buildVaultAgentsInstructions(context: VaultAgentsFileContext): string { + const lines = [ + "# SideNote2 Vault Agent Routing", + "", + `This Obsidian vault is \`${context.vaultName}\`.`, + ]; + + if (context.vaultRootPath) { + lines.push(`Vault root path: \`${context.vaultRootPath}\`.`); + } + + lines.push( + "", + "When a user is working with real SideNote2 comments in this vault:", + "", + "- Treat the markdown note as the source of truth.", + "- Treat the trailing `` block as the canonical stored comment data.", + "- Treat `SideNote2 index.md` as discovery output, not canonical storage.", + "", + "If the `sidenote2` skill is available, use it for:", + "", + "- `obsidian://side-note2-comment?...` URIs", + "- `reply to this`", + "- `add another note under this`", + "- `update this side note`", + "- `resolve this side note`", + "- `edit this stored comment`", + "", + "Intent mapping:", + "", + "- `reply`, `continue`, `answer this`, `add another note under this`", + " append to the existing thread", + "- `update`, `rewrite`, `replace this comment`", + " replace the targeted stored comment body", + "- `resolve`, `mark resolved`, `archive this side note`", + " mark the targeted thread resolved", + "", + "Preferred write path when the local `sidenote2` CLI is available:", + "", + "```bash", + "sidenote2 comment:append --uri \"obsidian://side-note2-comment?...\" --comment-file /abs/path/reply.md", + "sidenote2 comment:resolve --uri \"obsidian://side-note2-comment?...\"", + "sidenote2 comment:update --uri \"obsidian://side-note2-comment?...\" --comment-file /abs/path/comment.md", + "```", + "", + "If the CLI is not available, edit the source markdown note carefully and preserve all existing thread entries unless the user explicitly asked to replace one.", + "", + ); + + return `${lines.join("\n")}\n`; +} + +export function buildVaultAgentsFileContent(context: VaultAgentsFileContext): string { + return `${MANAGED_BLOCK_START_PREFIX} version="${context.pluginVersion}" -->\n${buildVaultAgentsInstructions(context)}${MANAGED_BLOCK_END}\n`; +} + +function findManagedBlockRange(content: string): { start: number; end: number } | null { + const normalizedContent = normalizeLineEndings(content); + const start = normalizedContent.indexOf(MANAGED_BLOCK_START_PREFIX); + if (start === -1) { + return null; + } + + const endMarkerStart = normalizedContent.indexOf(MANAGED_BLOCK_END, start); + if (endMarkerStart === -1) { + return null; + } + + let end = endMarkerStart + MANAGED_BLOCK_END.length; + if (normalizedContent.charAt(end) === "\n") { + end += 1; + } + + return { start, end }; +} + +function isLegacyManagedVaultAgentsFileContent(content: string): boolean { + const normalizedContent = normalizeLineEndings(content).trim(); + return normalizedContent.startsWith("# SideNote2 Vault Agent Routing") + && normalizedContent.includes("When a user is working with real SideNote2 comments in this vault:") + && normalizedContent.includes("obsidian://side-note2-comment?..."); +} + +function appendManagedBlockToDocument(existingContent: string, managedBlock: string): string { + const trimmedContent = normalizeLineEndings(existingContent).replace(/\s+$/u, ""); + if (!trimmedContent) { + return managedBlock; + } + + return `${trimmedContent}\n\n${managedBlock}`; +} + +function removeManagedBlockFromDocument(existingContent: string, blockRange: { start: number; end: number }): string { + const normalizedContent = normalizeLineEndings(existingContent); + const before = normalizedContent.slice(0, blockRange.start).replace(/\s+$/u, ""); + const after = normalizedContent.slice(blockRange.end).replace(/^\s+/u, ""); + + if (!before && !after) { + return ""; + } + + if (!before) { + return `${after}\n`; + } + + if (!after) { + return `${before}\n`; + } + + return `${before}\n\n${after}\n`; +} + +export function planVaultAgentsFileSync( + existingContent: string | null, + context: VaultAgentsFileContext, + _mode: VaultAgentsSyncMode, +): VaultAgentsSyncPlan { + const nextManagedContent = buildVaultAgentsFileContent(context); + if (existingContent === null) { + return { + kind: "write", + nextContent: nextManagedContent, + reason: "created", + }; + } + + const normalizedExistingContent = normalizeLineEndings(existingContent); + const managedBlockRange = findManagedBlockRange(normalizedExistingContent); + if (managedBlockRange) { + const currentManagedBlock = normalizedExistingContent.slice(managedBlockRange.start, managedBlockRange.end); + if (currentManagedBlock === nextManagedContent) { + return { + kind: "noop", + reason: "already-current", + }; + } + + return { + kind: "write", + nextContent: `${normalizedExistingContent.slice(0, managedBlockRange.start)}${nextManagedContent}${normalizedExistingContent.slice(managedBlockRange.end)}`, + reason: "updated", + }; + } + + if (isLegacyManagedVaultAgentsFileContent(normalizedExistingContent)) { + return { + kind: "write", + nextContent: nextManagedContent, + reason: "updated", + }; + } + + return { + kind: "write", + nextContent: appendManagedBlockToDocument(normalizedExistingContent, nextManagedContent), + reason: "inserted", + }; +} + +export function planVaultAgentsFileRemoval(existingContent: string | null): VaultAgentsRemovalPlan { + if (existingContent === null) { + return { kind: "noop" }; + } + + const normalizedExistingContent = normalizeLineEndings(existingContent); + const managedBlockRange = findManagedBlockRange(normalizedExistingContent); + if (managedBlockRange) { + const nextContent = removeManagedBlockFromDocument(normalizedExistingContent, managedBlockRange); + return nextContent.trim().length > 0 + ? { kind: "write", nextContent } + : { kind: "delete" }; + } + + if (isLegacyManagedVaultAgentsFileContent(normalizedExistingContent)) { + return { kind: "delete" }; + } + + return { kind: "noop" }; +} + +export class VaultAgentsFileController { + constructor(private readonly host: VaultAgentsFileHost) {} + + public async installVaultAgentsFile(): Promise { + await this.syncVaultAgentsFile("manual"); + } + + public async uninstallVaultAgentsFile(): Promise { + const context = this.host.getVaultAgentsFileContext(); + const location = this.describeLocation(context); + let existingContent: string | null = null; + + try { + if (await this.host.vaultRootFileExists(VAULT_AGENTS_FILE_PATH)) { + existingContent = await this.host.readVaultRootFile(VAULT_AGENTS_FILE_PATH); + } + } catch (error) { + this.host.warn("Failed to read AGENTS.md from the vault root.", error); + this.host.showNotice("Failed to inspect AGENTS.md in the vault root."); + return; + } + + const plan = planVaultAgentsFileRemoval(existingContent); + if (plan.kind === "noop") { + this.host.showNotice(`No SideNote2 AGENTS instructions found in ${location}`); + return; + } + + try { + if (plan.kind === "delete") { + await this.host.deleteVaultRootFile(VAULT_AGENTS_FILE_PATH); + } else { + await this.host.writeVaultRootFile(VAULT_AGENTS_FILE_PATH, plan.nextContent); + } + } catch (error) { + this.host.warn("Failed to remove SideNote2 AGENTS instructions from the vault root.", error); + this.host.showNotice("Failed to remove SideNote2 AGENTS instructions from the vault root."); + return; + } + + this.host.showNotice( + plan.kind === "delete" + ? `Removed SideNote2 AGENTS.md from ${location}` + : `Removed SideNote2 AGENTS instructions from ${location}`, + ); + } + + public async syncVaultAgentsFileOnStartup(): Promise { + await this.syncVaultAgentsFile("startup"); + } + + private async syncVaultAgentsFile(mode: VaultAgentsSyncMode): Promise { + const context = this.host.getVaultAgentsFileContext(); + const location = this.describeLocation(context); + let existingContent: string | null = null; + + try { + if (await this.host.vaultRootFileExists(VAULT_AGENTS_FILE_PATH)) { + existingContent = await this.host.readVaultRootFile(VAULT_AGENTS_FILE_PATH); + } + } catch (error) { + this.host.warn("Failed to read AGENTS.md from the vault root.", error); + if (mode === "manual") { + this.host.showNotice("Failed to inspect AGENTS.md in the vault root."); + } + return; + } + + const plan = planVaultAgentsFileSync(existingContent, context, mode); + if (plan.kind === "noop") { + if (mode === "manual") { + this.host.showNotice(`SideNote2 AGENTS instructions are already up to date in ${location}`); + } + return; + } + + try { + await this.host.writeVaultRootFile(VAULT_AGENTS_FILE_PATH, plan.nextContent); + } catch (error) { + this.host.warn("Failed to write AGENTS.md into the vault root.", error); + if (mode === "manual") { + this.host.showNotice("Failed to install AGENTS.md in the vault root."); + } + return; + } + + if (mode === "manual") { + this.host.showNotice(this.buildManualSuccessNotice(plan.reason, location)); + } + } + + private describeLocation(context: VaultAgentsFileContext): string { + return context.vaultRootPath + ? `${context.vaultRootPath}/AGENTS.md` + : `the vault root for ${context.vaultName}`; + } + + private buildManualSuccessNotice(reason: VaultAgentsWriteReason, location: string): string { + switch (reason) { + case "created": + return `Installed AGENTS.md in ${location}`; + case "updated": + return `Updated SideNote2 AGENTS instructions in ${location}`; + case "inserted": + return `Inserted SideNote2 AGENTS instructions into existing ${location}`; + } + } +} diff --git a/src/core/storage/noteCommentStorage.ts b/src/core/storage/noteCommentStorage.ts index 9568657..480f202 100644 --- a/src/core/storage/noteCommentStorage.ts +++ b/src/core/storage/noteCommentStorage.ts @@ -821,3 +821,41 @@ export function appendNoteCommentEntryById( return serializeNoteCommentThreads(noteContent, updatedThreads); } + +export function setNoteCommentResolvedById( + noteContent: string, + filePath: string, + commentId: string, + nextResolved: boolean, +): string | null { + const parsed = parseNoteComments(noteContent, filePath); + let found = false; + + const updatedThreads = parsed.threads.map((thread) => { + const matchesThread = thread.id === commentId + || thread.entries.some((entry) => entry.id === commentId); + if (!matchesThread) { + return thread; + } + + found = true; + return { + ...thread, + resolved: nextResolved, + }; + }); + + if (!found) { + return null; + } + + return serializeNoteCommentThreads(noteContent, updatedThreads); +} + +export function resolveNoteCommentById( + noteContent: string, + filePath: string, + commentId: string, +): string | null { + return setNoteCommentResolvedById(noteContent, filePath, commentId, true); +} diff --git a/src/main.ts b/src/main.ts index 36e1861..8dd5130 100755 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,4 @@ -import { addIcon, WorkspaceLeaf, TFile, Notice, Plugin, normalizePath, MarkdownView, type Editor } from "obsidian"; +import { addIcon, WorkspaceLeaf, TFile, Notice, Plugin, normalizePath, MarkdownView, FileSystemAdapter, type Editor } from "obsidian"; import type { EditorView } from "@codemirror/view"; import { Comment, CommentManager, CommentThread } from "./commentManager"; import { CommentEntryController } from "./control/commentEntryController"; @@ -14,6 +14,7 @@ import { PluginLifecycleController } from "./control/pluginLifecycleController"; import { PluginRegistrationController } from "./control/pluginRegistrationController"; import { WorkspaceContextController } from "./control/workspaceContextController"; import { WorkspaceViewController } from "./control/workspaceViewController"; +import { VaultAgentsFileController } from "./control/vaultAgentsFileController"; import { DraftComment } from "./domain/drafts"; import { parsePromptDeleteSetting } from "./core/config/appConfig"; import { DerivedCommentMetadataManager } from "./core/derived/derivedCommentMetadata"; @@ -225,6 +226,25 @@ export default class SideNote2 extends Plugin { console.warn(message, error); }, }); + private readonly vaultAgentsFileController = new VaultAgentsFileController({ + getVaultAgentsFileContext: () => ({ + vaultName: this.app.vault.getName(), + vaultRootPath: this.app.vault.adapter instanceof FileSystemAdapter + ? this.app.vault.adapter.getBasePath() + : null, + pluginVersion: this.manifest.version, + }), + vaultRootFileExists: (relativePath) => this.app.vault.adapter.exists(relativePath), + readVaultRootFile: (relativePath) => this.app.vault.adapter.read(relativePath), + writeVaultRootFile: (relativePath, content) => this.app.vault.adapter.write(relativePath, content), + deleteVaultRootFile: (relativePath) => this.app.vault.adapter.remove(relativePath), + showNotice: (message) => { + new Notice(message); + }, + warn: (message, error) => { + console.warn(message, error); + }, + }); private readonly pluginRegistrationController = new PluginRegistrationController({ manifestId: this.manifest.id, iconId: SIDE_NOTE2_ICON_ID, @@ -251,6 +271,8 @@ export default class SideNote2 extends Plugin { this.commentEntryController.startDraftFromEditorSelection(editor as unknown as Editor, file), highlightCommentById: (filePath, commentId) => this.highlightCommentById(filePath, commentId), openIndexNote: () => this.openIndexNote(), + installVaultAgentsFile: () => this.installVaultAgentsFile(), + uninstallVaultAgentsFile: () => this.uninstallVaultAgentsFile(), }); private readonly workspaceContextController = new WorkspaceContextController({ app: this.app, @@ -295,10 +317,12 @@ export default class SideNote2 extends Plugin { if (this.app.workspace.layoutReady) { await this.pluginLifecycleController.handleLayoutReady(); await this.legacyNoteCommentsMigrationController.runStartupMigrationIfNeeded(); + await this.vaultAgentsFileController.syncVaultAgentsFileOnStartup(); } else { this.app.workspace.onLayoutReady(async () => { await this.pluginLifecycleController.handleLayoutReady(); await this.legacyNoteCommentsMigrationController.runStartupMigrationIfNeeded(); + await this.vaultAgentsFileController.syncVaultAgentsFileOnStartup(); }); } @@ -580,6 +604,14 @@ export default class SideNote2 extends Plugin { await this.commentNavigationController.openCommentById(filePath, commentId); } + private async installVaultAgentsFile(): Promise { + await this.vaultAgentsFileController.installVaultAgentsFile(); + } + + private async uninstallVaultAgentsFile(): Promise { + await this.vaultAgentsFileController.uninstallVaultAgentsFile(); + } + public getDraftForFile(filePath: string): DraftComment | null { return this.commentSessionController.getDraftForFile(filePath); } diff --git a/tests/appendNoteCommentEntryScript.test.ts b/tests/appendNoteCommentEntryScript.test.ts index 92132f6..24bb3cb 100644 --- a/tests/appendNoteCommentEntryScript.test.ts +++ b/tests/appendNoteCommentEntryScript.test.ts @@ -1,6 +1,6 @@ import * as assert from "node:assert/strict"; import { execFile as execFileCallback } from "node:child_process"; -import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import * as path from "node:path"; import test from "node:test"; @@ -10,6 +10,22 @@ import type { Comment } from "../src/commentManager"; const execFile = promisify(execFileCallback); +async function writeObsidianVaultConfig(homeDir: string, vaultRoot: string): Promise { + const configPath = path.join(homeDir, ".config", "obsidian", "obsidian.json"); + await mkdir(path.dirname(configPath), { recursive: true }); + await writeFile(configPath, JSON.stringify({ + vaults: { + "vault-1": { + path: vaultRoot, + }, + }, + }, null, 2), "utf8"); +} + +function buildCommentLocationUri(vaultName: string, filePath: string, commentId: string): string { + return `obsidian://side-note2-comment?vault=${encodeURIComponent(vaultName)}&file=${encodeURIComponent(filePath)}&commentId=${encodeURIComponent(commentId)}`; +} + function createComment(overrides: Partial = {}): Comment { return { id: "comment-1", @@ -60,3 +76,42 @@ test("append-note-comment-entry script appends a new entry to the targeted threa assert.equal(parsed.threads[0].entries[1].body, "Reply body\nSecond line"); assert.equal(parsed.mainContent, "# Title\n\nBody text."); }); + +test("append-note-comment-entry script can target a thread by obsidian side-note URI", async () => { + const tempDir = await mkdtemp(path.join(tmpdir(), "sidenote2-comment-uri-append-script-")); + const homeDir = path.join(tempDir, "home"); + const vaultRoot = path.join(tempDir, "Public Vault"); + const notePath = path.join(vaultRoot, "Folder", "Note.md"); + const commentPath = path.join(tempDir, "reply.md"); + const scriptPath = path.resolve(process.cwd(), "scripts/append-note-comment-entry.mjs"); + const noteFilePath = "Folder/Note.md"; + const original = serializeNoteComments("# Title\n\nBody text.\n", [createComment({ + filePath: noteFilePath, + })]); + + await mkdir(path.dirname(notePath), { recursive: true }); + await writeObsidianVaultConfig(homeDir, vaultRoot); + await writeFile(notePath, original, "utf8"); + await writeFile(commentPath, "Reply from URI\nSecond line\n", "utf8"); + + const { stdout } = await execFile("node", [ + scriptPath, + "--uri", + buildCommentLocationUri("Public Vault", noteFilePath, "comment-1"), + "--comment-file", + commentPath, + ], { + cwd: process.cwd(), + env: { + ...process.env, + HOME: homeDir, + }, + }); + + assert.match(stdout, /Appended a new entry to comment comment-1/); + + const updated = await readFile(notePath, "utf8"); + const parsed = parseNoteComments(updated, notePath); + assert.equal(parsed.threads[0].entries.length, 2); + assert.equal(parsed.threads[0].entries[1].body, "Reply from URI\nSecond line"); +}); diff --git a/tests/noteCommentStorage.test.ts b/tests/noteCommentStorage.test.ts index 4d1bb93..0b5a7d1 100644 --- a/tests/noteCommentStorage.test.ts +++ b/tests/noteCommentStorage.test.ts @@ -9,6 +9,7 @@ import { getManagedSectionStartLine, getVisibleNoteContent, parseNoteComments, + resolveNoteCommentById, replaceNoteCommentBodyById, serializeNoteComments, } from "../src/core/storage/noteCommentStorage"; @@ -218,6 +219,49 @@ test("appendNoteCommentEntryById returns null when the target id is missing", () }), null); }); +test("resolveNoteCommentById marks the targeted thread resolved", () => { + const original = serializeNoteComments("# Title\n\nAlpha beta gamma.\n", [ + createComment({ + id: "comment-1", + resolved: false, + }), + ]); + + const updated = resolveNoteCommentById(original, "note.md", "comment-1"); + assert.ok(updated); + + const parsed = parseNoteComments(updated, "note.md"); + assert.equal(parsed.comments[0].resolved, true); + assert.equal(parsed.threads[0].resolved, true); + assert.match(updated, /"resolved": true/); +}); + +test("resolveNoteCommentById can target a child entry id and resolves the whole thread", () => { + const original = appendNoteCommentEntryById(serializeNoteComments("# Title\n\nAlpha beta gamma.\n", [ + createComment({ + id: "comment-1", + resolved: false, + }), + ]), "note.md", "comment-1", { + id: "entry-2", + body: "Follow up", + timestamp: 1710000001000, + }); + assert.ok(original); + + const updated = resolveNoteCommentById(original, "note.md", "entry-2"); + assert.ok(updated); + + const parsed = parseNoteComments(updated, "note.md"); + assert.equal(parsed.comments[0].resolved, true); + assert.equal(parsed.threads[0].resolved, true); +}); + +test("resolveNoteCommentById returns null when the target id is missing", () => { + const original = serializeNoteComments("Body\n", [createComment()]); + assert.equal(resolveNoteCommentById(original, "note.md", "missing-id"), null); +}); + test("buildLegacyNoteCommentMigrationPlan converts legacy flat comments to threaded storage", () => { const legacyNote = [ "# Title", diff --git a/tests/pluginRegistrationController.test.ts b/tests/pluginRegistrationController.test.ts index 084e2bf..00f98ed 100644 --- a/tests/pluginRegistrationController.test.ts +++ b/tests/pluginRegistrationController.test.ts @@ -67,7 +67,8 @@ function createHarness() { id: string; name: string; icon: string; - editorCallback: (editor: { somethingSelected(): boolean }, view: { file: TFile | null }) => Promise | void; + callback?: () => Promise | void; + editorCallback?: (editor: { somethingSelected(): boolean }, view: { file: TFile | null }) => Promise | void; }> = []; let editorMenuHandler: ((menu: EditorMenuLike, editor: { somethingSelected(): boolean }, view: { file: TFile | null }) => void) | null = null; const ribbonActions: Array<{ icon: string; title: string; callback: () => void }> = []; @@ -75,6 +76,8 @@ function createHarness() { const draftCalls: Array<{ selected: boolean; filePath: string | null }> = []; const highlightedCommentTargets: Array<{ filePath: string; commentId: string }> = []; let openIndexNoteCount = 0; + let installVaultAgentsFileCount = 0; + let uninstallVaultAgentsFileCount = 0; const controller = new PluginRegistrationController({ manifestId: "side-note2", @@ -113,6 +116,12 @@ function createHarness() { openIndexNote: async () => { openIndexNoteCount += 1; }, + installVaultAgentsFile: async () => { + installVaultAgentsFileCount += 1; + }, + uninstallVaultAgentsFile: async () => { + uninstallVaultAgentsFileCount += 1; + }, }); return { @@ -127,6 +136,8 @@ function createHarness() { draftCalls, highlightedCommentTargets, getOpenIndexNoteCount: () => openIndexNoteCount, + getInstallVaultAgentsFileCount: () => installVaultAgentsFileCount, + getUninstallVaultAgentsFileCount: () => uninstallVaultAgentsFileCount, }; } @@ -140,10 +151,14 @@ test("plugin registration controller registers the view, protocol handler, comma assert.equal(harness.registerViewCalls[0].creator({ id: "leaf-1" }) instanceof Object, true); assert.deepEqual(harness.createdSidebarLeaves, [{ id: "leaf-1" }]); assert.deepEqual(harness.removedCommandIds, ["side-note2:activate-view"]); - assert.deepEqual(harness.commands.map((command) => command.id), ["add-comment-to-selection"]); + assert.deepEqual(harness.commands.map((command) => command.id), [ + "add-comment-to-selection", + "install-vault-agents-file", + "uninstall-vault-agents-file", + ]); assert.deepEqual(harness.ribbonActions.map((action) => action.title), ["Open SideNote2 index"]); - await harness.commands[0].editorCallback( + await harness.commands[0].editorCallback?.( { somethingSelected: () => true }, { file: editorFile }, ); @@ -166,6 +181,12 @@ test("plugin registration controller registers the view, protocol handler, comma harness.ribbonActions[0].callback(); await Promise.resolve(); assert.equal(harness.getOpenIndexNoteCount(), 1); + + await harness.commands[1].callback?.(); + assert.equal(harness.getInstallVaultAgentsFileCount(), 1); + + await harness.commands[2].callback?.(); + assert.equal(harness.getUninstallVaultAgentsFileCount(), 1); }); test("plugin registration controller only adds the editor menu item for active selections", async () => { diff --git a/tests/resolveNoteCommentScript.test.ts b/tests/resolveNoteCommentScript.test.ts new file mode 100644 index 0000000..f3ea94e --- /dev/null +++ b/tests/resolveNoteCommentScript.test.ts @@ -0,0 +1,106 @@ +import * as assert from "node:assert/strict"; +import { execFile as execFileCallback } from "node:child_process"; +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import * as path from "node:path"; +import test from "node:test"; +import { promisify } from "node:util"; +import { parseNoteComments, serializeNoteComments } from "../src/core/storage/noteCommentStorage"; +import type { Comment } from "../src/commentManager"; + +const execFile = promisify(execFileCallback); + +async function writeObsidianVaultConfig(homeDir: string, vaultRoot: string): Promise { + const configPath = path.join(homeDir, ".config", "obsidian", "obsidian.json"); + await mkdir(path.dirname(configPath), { recursive: true }); + await writeFile(configPath, JSON.stringify({ + vaults: { + "vault-1": { + path: vaultRoot, + }, + }, + }, null, 2), "utf8"); +} + +function buildCommentLocationUri(vaultName: string, filePath: string, commentId: string): string { + return `obsidian://side-note2-comment?vault=${encodeURIComponent(vaultName)}&file=${encodeURIComponent(filePath)}&commentId=${encodeURIComponent(commentId)}`; +} + +function createComment(overrides: Partial = {}): Comment { + return { + id: "comment-1", + filePath: "note.md", + startLine: 1, + startChar: 2, + endLine: 1, + endChar: 7, + selectedText: "hello", + selectedTextHash: "hash-1", + comment: "Original body", + timestamp: 1710000000000, + resolved: false, + ...overrides, + }; +} + +test("comment:resolve marks the targeted thread resolved", async () => { + const tempDir = await mkdtemp(path.join(tmpdir(), "sidenote2-comment-resolve-script-")); + const notePath = path.join(tempDir, "note.md"); + const scriptPath = path.resolve(process.cwd(), "bin/sidenote2.mjs"); + const original = serializeNoteComments("# Title\n\nBody text.\n", [createComment()]); + + await writeFile(notePath, original, "utf8"); + + const { stdout } = await execFile("node", [ + scriptPath, + "comment:resolve", + "--file", + notePath, + "--id", + "comment-1", + ], { + cwd: process.cwd(), + }); + + assert.match(stdout, /Resolved comment comment-1/); + + const updated = await readFile(notePath, "utf8"); + const parsed = parseNoteComments(updated, notePath); + assert.equal(parsed.comments[0].resolved, true); + assert.equal(parsed.threads[0].resolved, true); +}); + +test("comment:resolve can target a stored comment by obsidian side-note URI", async () => { + const tempDir = await mkdtemp(path.join(tmpdir(), "sidenote2-comment-resolve-uri-script-")); + const homeDir = path.join(tempDir, "home"); + const vaultRoot = path.join(tempDir, "Public Vault"); + const notePath = path.join(vaultRoot, "Folder", "Note.md"); + const scriptPath = path.resolve(process.cwd(), "bin/sidenote2.mjs"); + const noteFilePath = "Folder/Note.md"; + const original = serializeNoteComments("# Title\n\nBody text.\n", [createComment({ + filePath: noteFilePath, + })]); + + await mkdir(path.dirname(notePath), { recursive: true }); + await writeObsidianVaultConfig(homeDir, vaultRoot); + await writeFile(notePath, original, "utf8"); + + const { stdout } = await execFile("node", [ + scriptPath, + "comment:resolve", + "--uri", + buildCommentLocationUri("Public Vault", noteFilePath, "comment-1"), + ], { + cwd: process.cwd(), + env: { + ...process.env, + HOME: homeDir, + }, + }); + + assert.match(stdout, /Resolved comment comment-1/); + + const updated = await readFile(notePath, "utf8"); + const parsed = parseNoteComments(updated, notePath); + assert.equal(parsed.comments[0].resolved, true); +}); diff --git a/tests/sidenote2AgentSupportCli.test.ts b/tests/sidenote2AgentSupportCli.test.ts new file mode 100644 index 0000000..d2a97f9 --- /dev/null +++ b/tests/sidenote2AgentSupportCli.test.ts @@ -0,0 +1,91 @@ +import * as assert from "node:assert/strict"; +import { execFile as execFileCallback } from "node:child_process"; +import { access, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import * as path from "node:path"; +import test from "node:test"; +import { promisify } from "node:util"; +import { buildVaultAgentsFileContent } from "../src/control/vaultAgentsFileController"; + +const execFile = promisify(execFileCallback); + +test("uninstall-agent-support removes the SideNote2-managed AGENTS block and bundled skills", async () => { + const tempDir = await mkdtemp(path.join(tmpdir(), "sidenote2-agent-uninstall-")); + const vaultRoot = path.join(tempDir, "vault"); + const skillsRoot = path.join(tempDir, "skills"); + const cliPath = path.resolve(process.cwd(), "bin/sidenote2.mjs"); + const agentsPath = path.join(vaultRoot, "AGENTS.md"); + + await mkdir(vaultRoot, { recursive: true }); + await mkdir(path.join(skillsRoot, "sidenote2"), { recursive: true }); + await mkdir(path.join(skillsRoot, "canvas-design"), { recursive: true }); + await mkdir(path.join(skillsRoot, "other-skill"), { recursive: true }); + + await writeFile(path.join(skillsRoot, "sidenote2", "SKILL.md"), "skill one", "utf8"); + await writeFile(path.join(skillsRoot, "canvas-design", "SKILL.md"), "skill two", "utf8"); + await writeFile(path.join(skillsRoot, "other-skill", "SKILL.md"), "keep me", "utf8"); + + const managedAgentsContent = buildVaultAgentsFileContent({ + vaultName: "public", + vaultRootPath: vaultRoot, + pluginVersion: "2.0.2", + }); + await writeFile(agentsPath, `# User Rules\n\nKeep answers brief.\n\n${managedAgentsContent}`, "utf8"); + + const { stdout } = await execFile("node", [ + cliPath, + "uninstall-agent-support", + "--vault-root", + vaultRoot, + "--skills-root", + skillsRoot, + ], { + cwd: process.cwd(), + }); + + assert.match(stdout, /Removed the SideNote2-managed AGENTS block/); + assert.match(stdout, /Removed skill sidenote2/); + assert.match(stdout, /Removed skill canvas-design/); + assert.match(stdout, /Restart Codex to drop any cached skills\./); + + const nextAgentsContent = await readFile(agentsPath, "utf8"); + assert.match(nextAgentsContent, /# User Rules/); + assert.match(nextAgentsContent, /Keep answers brief\./); + assert.doesNotMatch(nextAgentsContent, /SideNote2 Vault Agent Routing/); + + await assert.rejects(access(path.join(skillsRoot, "sidenote2"))); + await assert.rejects(access(path.join(skillsRoot, "canvas-design"))); + await access(path.join(skillsRoot, "other-skill", "SKILL.md")); +}); + +test("uninstall-agent-support deletes AGENTS.md when only SideNote2-managed content exists", async () => { + const tempDir = await mkdtemp(path.join(tmpdir(), "sidenote2-agent-uninstall-managed-only-")); + const vaultRoot = path.join(tempDir, "vault"); + const skillsRoot = path.join(tempDir, "skills"); + const cliPath = path.resolve(process.cwd(), "bin/sidenote2.mjs"); + const agentsPath = path.join(vaultRoot, "AGENTS.md"); + + await mkdir(vaultRoot, { recursive: true }); + await mkdir(skillsRoot, { recursive: true }); + + await writeFile(agentsPath, buildVaultAgentsFileContent({ + vaultName: "public", + vaultRootPath: vaultRoot, + pluginVersion: "2.0.2", + }), "utf8"); + + const { stdout } = await execFile("node", [ + cliPath, + "uninstall-agent-support", + "--vault-root", + vaultRoot, + "--skills-root", + skillsRoot, + ], { + cwd: process.cwd(), + }); + + assert.match(stdout, new RegExp(`Removed SideNote2 AGENTS\\.md content and deleted ${agentsPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\.`)); + assert.match(stdout, /No bundled SideNote2 skills found/); + await assert.rejects(access(agentsPath)); +}); diff --git a/tests/sidenote2SkillCli.test.ts b/tests/sidenote2SkillCli.test.ts index 97aaeaf..764521d 100644 --- a/tests/sidenote2SkillCli.test.ts +++ b/tests/sidenote2SkillCli.test.ts @@ -12,7 +12,7 @@ test("install-skill copies all bundled repo skills into the target Codex skills const tempDir = await mkdtemp(path.join(tmpdir(), "sidenote2-skill-install-")); const skillsRoot = path.join(tempDir, "skills"); const cliPath = path.resolve(process.cwd(), "bin/sidenote2.mjs"); - const sourceCommentSkillDir = path.resolve(process.cwd(), "skills/side-note2-note-comments"); + const sourceSidenoteSkillDir = path.resolve(process.cwd(), "skills/sidenote2"); const sourceCanvasSkillDir = path.resolve(process.cwd(), "skills/canvas-design"); const { stdout } = await execFile("node", [ @@ -24,14 +24,14 @@ test("install-skill copies all bundled repo skills into the target Codex skills cwd: process.cwd(), }); - assert.match(stdout, /Installed skill side-note2-note-comments/); + assert.match(stdout, /Installed skill sidenote2/); assert.match(stdout, /Installed skill canvas-design/); assert.match(stdout, /Restart Codex to pick up new skills/); - const installedCommentSkillDir = path.join(skillsRoot, "side-note2-note-comments"); - const installedCommentSkill = await readFile(path.join(installedCommentSkillDir, "SKILL.md"), "utf8"); - const sourceCommentSkill = await readFile(path.join(sourceCommentSkillDir, "SKILL.md"), "utf8"); - assert.equal(installedCommentSkill, sourceCommentSkill); + const installedSidenoteSkillDir = path.join(skillsRoot, "sidenote2"); + const installedSidenoteSkill = await readFile(path.join(installedSidenoteSkillDir, "SKILL.md"), "utf8"); + const sourceSidenoteSkill = await readFile(path.join(sourceSidenoteSkillDir, "SKILL.md"), "utf8"); + assert.equal(installedSidenoteSkill, sourceSidenoteSkill); const installedCanvasSkillDir = path.join(skillsRoot, "canvas-design"); const installedCanvasSkill = await readFile(path.join(installedCanvasSkillDir, "SKILL.md"), "utf8"); @@ -39,16 +39,16 @@ test("install-skill copies all bundled repo skills into the target Codex skills assert.equal(installedCanvasSkill, sourceCanvasSkill); await assert.rejects(access(path.join(installedCanvasSkillDir, "canvas-fonts"))); - const installedDirStat = await lstat(installedCommentSkillDir); + const installedDirStat = await lstat(installedSidenoteSkillDir); assert.equal(installedDirStat.isSymbolicLink(), false); }); test("install-skill replaces an existing installed skill directory", async () => { const tempDir = await mkdtemp(path.join(tmpdir(), "sidenote2-skill-install-overwrite-")); const skillsRoot = path.join(tempDir, "skills"); - const installedSkillDir = path.join(skillsRoot, "side-note2-note-comments"); + const installedSkillDir = path.join(skillsRoot, "sidenote2"); const cliPath = path.resolve(process.cwd(), "bin/sidenote2.mjs"); - const sourceSkillPath = path.resolve(process.cwd(), "skills/side-note2-note-comments/SKILL.md"); + const sourceSkillPath = path.resolve(process.cwd(), "skills/sidenote2/SKILL.md"); await mkdir(installedSkillDir, { recursive: true }); await writeFile(path.join(installedSkillDir, "SKILL.md"), "stale", "utf8"); @@ -57,7 +57,7 @@ test("install-skill replaces an existing installed skill directory", async () => cliPath, "install-skill", "--name", - "side-note2-note-comments", + "sidenote2", "--dest", skillsRoot, ], { @@ -78,18 +78,18 @@ test("install-skill can install a named bundled skill", async () => { cliPath, "install-skill", "--name", - "canvas-design", + "sidenote2", "--dest", skillsRoot, ], { cwd: process.cwd(), }); - assert.match(stdout, /Installed skill canvas-design/); + assert.match(stdout, /Installed skill sidenote2/); - const installedCanvasSkill = await readFile(path.join(skillsRoot, "canvas-design", "SKILL.md"), "utf8"); - const sourceCanvasSkill = await readFile(path.resolve(process.cwd(), "skills/canvas-design/SKILL.md"), "utf8"); - assert.equal(installedCanvasSkill, sourceCanvasSkill); + const installedSidenoteSkill = await readFile(path.join(skillsRoot, "sidenote2", "SKILL.md"), "utf8"); + const sourceSidenoteSkill = await readFile(path.resolve(process.cwd(), "skills/sidenote2/SKILL.md"), "utf8"); + assert.equal(installedSidenoteSkill, sourceSidenoteSkill); - await assert.rejects(access(path.join(skillsRoot, "side-note2-note-comments", "SKILL.md"))); + await assert.rejects(access(path.join(skillsRoot, "canvas-design", "SKILL.md"))); }); diff --git a/tests/updateNoteCommentScript.test.ts b/tests/updateNoteCommentScript.test.ts index 11530c7..6aa74b9 100644 --- a/tests/updateNoteCommentScript.test.ts +++ b/tests/updateNoteCommentScript.test.ts @@ -1,6 +1,6 @@ import * as assert from "node:assert/strict"; import { execFile as execFileCallback } from "node:child_process"; -import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import * as path from "node:path"; import test from "node:test"; @@ -10,6 +10,22 @@ import type { Comment } from "../src/commentManager"; const execFile = promisify(execFileCallback); +async function writeObsidianVaultConfig(homeDir: string, vaultRoot: string): Promise { + const configPath = path.join(homeDir, ".config", "obsidian", "obsidian.json"); + await mkdir(path.dirname(configPath), { recursive: true }); + await writeFile(configPath, JSON.stringify({ + vaults: { + "vault-1": { + path: vaultRoot, + }, + }, + }, null, 2), "utf8"); +} + +function buildCommentLocationUri(vaultName: string, filePath: string, commentId: string): string { + return `obsidian://side-note2-comment?vault=${encodeURIComponent(vaultName)}&file=${encodeURIComponent(filePath)}&commentId=${encodeURIComponent(commentId)}`; +} + function createComment(overrides: Partial = {}): Comment { return { id: "comment-1", @@ -58,3 +74,42 @@ test("update-note-comment script replaces the targeted comment body", async () = assert.equal(parsed.comments[0].comment, "Updated body\nSecond line"); assert.equal(parsed.mainContent, "# Title\n\nBody text."); }); + +test("comment:update can target a stored comment by obsidian side-note URI", async () => { + const tempDir = await mkdtemp(path.join(tmpdir(), "sidenote2-comment-uri-script-")); + const homeDir = path.join(tempDir, "home"); + const vaultRoot = path.join(tempDir, "Public Vault"); + const notePath = path.join(vaultRoot, "Folder", "Note.md"); + const commentPath = path.join(tempDir, "comment.md"); + const scriptPath = path.resolve(process.cwd(), "bin/sidenote2.mjs"); + const noteFilePath = "Folder/Note.md"; + const original = serializeNoteComments("# Title\n\nBody text.\n", [createComment({ + filePath: noteFilePath, + })]); + + await mkdir(path.dirname(notePath), { recursive: true }); + await writeObsidianVaultConfig(homeDir, vaultRoot); + await writeFile(notePath, original, "utf8"); + await writeFile(commentPath, "Updated from URI\nSecond line\n", "utf8"); + + const { stdout } = await execFile("node", [ + scriptPath, + "comment:update", + "--uri", + buildCommentLocationUri("Public Vault", noteFilePath, "comment-1"), + "--comment-file", + commentPath, + ], { + cwd: process.cwd(), + env: { + ...process.env, + HOME: homeDir, + }, + }); + + assert.match(stdout, /Updated comment comment-1/); + + const updated = await readFile(notePath, "utf8"); + const parsed = parseNoteComments(updated, notePath); + assert.equal(parsed.comments[0].comment, "Updated from URI\nSecond line"); +}); diff --git a/tests/vaultAgentsFileController.test.ts b/tests/vaultAgentsFileController.test.ts new file mode 100644 index 0000000..03809d9 --- /dev/null +++ b/tests/vaultAgentsFileController.test.ts @@ -0,0 +1,249 @@ +import * as assert from "node:assert/strict"; +import test from "node:test"; +import { + VaultAgentsFileController, + buildVaultAgentsFileContent, + planVaultAgentsFileRemoval, + planVaultAgentsFileSync, +} from "../src/control/vaultAgentsFileController"; + +const baseContext = { + vaultName: "public", + vaultRootPath: "/home/bun/Documents/public", + pluginVersion: "2.0.2", +} as const; + +test("buildVaultAgentsFileContent includes managed markers plus the active vault name and path", () => { + const content = buildVaultAgentsFileContent(baseContext); + + assert.match(content, //); + assert.match(content, /This Obsidian vault is `public`\./); + assert.match(content, /Vault root path: `\/home\/bun\/Documents\/public`\./); + assert.match(content, /obsidian:\/\/side-note2-comment\?\.\.\./); + assert.match(content, /append to the existing thread/); + assert.match(content, /mark the targeted thread resolved/); + assert.match(content, /replace the targeted stored comment body/); + assert.match(content, //); +}); + +test("planVaultAgentsFileSync creates a managed file when AGENTS.md is missing", () => { + const plan = planVaultAgentsFileSync(null, baseContext, "startup"); + + assert.deepEqual(plan, { + kind: "write", + nextContent: buildVaultAgentsFileContent(baseContext), + reason: "created", + }); +}); + +test("planVaultAgentsFileSync updates an existing managed block in place", () => { + const previousContext = { + ...baseContext, + pluginVersion: "2.0.1", + }; + const existingContent = [ + "# User Vault Rules", + "", + "Keep answers short.", + "", + buildVaultAgentsFileContent(previousContext).trimEnd(), + "", + "## Extra", + "", + "- Never touch archives.", + "", + ].join("\n"); + + const plan = planVaultAgentsFileSync(existingContent, baseContext, "startup"); + + if (plan.kind !== "write") { + throw new Error("expected a write plan"); + } + assert.equal(plan.reason, "updated"); + assert.match(plan.nextContent, /# User Vault Rules/); + assert.match(plan.nextContent, /version="2\.0\.2"/); + assert.doesNotMatch(plan.nextContent, /version="2\.0\.1"/); + assert.match(plan.nextContent, /## Extra/); +}); + +test("planVaultAgentsFileSync inserts a managed block into an unmanaged AGENTS.md on startup", () => { + const plan = planVaultAgentsFileSync("# User AGENTS\n\nDo not modify this file.\n", baseContext, "startup"); + + if (plan.kind !== "write") { + throw new Error("expected a write plan"); + } + assert.equal(plan.reason, "inserted"); + assert.match(plan.nextContent, /^# User AGENTS/m); + assert.match(plan.nextContent, /Do not modify this file\./); + assert.match(plan.nextContent, //); +}); + +test("planVaultAgentsFileSync inserts a managed block into an unmanaged AGENTS.md for manual install", () => { + const plan = planVaultAgentsFileSync("# User AGENTS\n\nDo not modify this file.\n", baseContext, "manual"); + + if (plan.kind !== "write") { + throw new Error("expected a write plan"); + } + assert.equal(plan.reason, "inserted"); + assert.match(plan.nextContent, /^# User AGENTS/m); + assert.match(plan.nextContent, /Do not modify this file\./); + assert.match(plan.nextContent, //); +}); + +test("planVaultAgentsFileRemoval removes a managed block but preserves unrelated AGENTS content", () => { + const existingContent = `# User Rules\n\nKeep answers brief.\n\n${buildVaultAgentsFileContent(baseContext)}`; + const plan = planVaultAgentsFileRemoval(existingContent); + + assert.equal(plan.kind, "write"); + if (plan.kind !== "write") { + throw new Error("expected a write plan"); + } + assert.match(plan.nextContent, /# User Rules/); + assert.match(plan.nextContent, /Keep answers brief\./); + assert.doesNotMatch(plan.nextContent, /SideNote2 Vault Agent Routing/); +}); + +test("planVaultAgentsFileRemoval deletes AGENTS.md when only SideNote2-managed content exists", () => { + const plan = planVaultAgentsFileRemoval(buildVaultAgentsFileContent(baseContext)); + + assert.deepEqual(plan, { kind: "delete" }); +}); + +test("vault agents file controller installs AGENTS.md into the vault root and reports the resolved path", async () => { + let writtenPath = ""; + let writtenContent = ""; + let notice = ""; + + const controller = new VaultAgentsFileController({ + getVaultAgentsFileContext: () => baseContext, + vaultRootFileExists: async () => false, + readVaultRootFile: async () => { + throw new Error("should not read missing AGENTS.md"); + }, + writeVaultRootFile: async (relativePath, content) => { + writtenPath = relativePath; + writtenContent = content; + }, + deleteVaultRootFile: async () => { + throw new Error("should not delete on install"); + }, + showNotice: (message) => { + notice = message; + }, + warn: () => {}, + }); + + await controller.installVaultAgentsFile(); + + assert.equal(writtenPath, "AGENTS.md"); + assert.equal(writtenContent, buildVaultAgentsFileContent(baseContext)); + assert.equal(notice, "Installed AGENTS.md in /home/bun/Documents/public/AGENTS.md"); +}); + +test("vault agents file controller appends a managed block into an existing unmanaged AGENTS.md on manual install", async () => { + let writtenContent = ""; + let notice = ""; + + const controller = new VaultAgentsFileController({ + getVaultAgentsFileContext: () => baseContext, + vaultRootFileExists: async () => true, + readVaultRootFile: async () => "# User AGENTS\n\nDo not modify this file.\n", + writeVaultRootFile: async (_relativePath, content) => { + writtenContent = content; + }, + deleteVaultRootFile: async () => { + throw new Error("should not delete on insert"); + }, + showNotice: (message) => { + notice = message; + }, + warn: () => {}, + }); + + await controller.installVaultAgentsFile(); + + assert.match(writtenContent, /# User AGENTS/); + assert.match(writtenContent, //); + assert.equal(notice, "Inserted SideNote2 AGENTS instructions into existing /home/bun/Documents/public/AGENTS.md"); +}); + +test("vault agents file controller inserts a managed block into an unmanaged AGENTS.md during startup sync", async () => { + let writtenContent = ""; + let noticeCount = 0; + + const controller = new VaultAgentsFileController({ + getVaultAgentsFileContext: () => baseContext, + vaultRootFileExists: async () => true, + readVaultRootFile: async () => "# User AGENTS\n\nDo not modify this file.\n", + writeVaultRootFile: async (_relativePath, content) => { + writtenContent = content; + }, + deleteVaultRootFile: async () => { + throw new Error("should not delete when content remains"); + }, + showNotice: () => { + noticeCount += 1; + }, + warn: () => {}, + }); + + await controller.syncVaultAgentsFileOnStartup(); + + assert.match(writtenContent, /# User AGENTS/); + assert.match(writtenContent, //); + assert.equal(noticeCount, 0); +}); + +test("vault agents file controller removes the SideNote2-managed block and keeps unrelated AGENTS content", async () => { + let writtenContent = ""; + let notice = ""; + + const controller = new VaultAgentsFileController({ + getVaultAgentsFileContext: () => baseContext, + vaultRootFileExists: async () => true, + readVaultRootFile: async () => `# User Rules\n\nKeep answers brief.\n\n${buildVaultAgentsFileContent(baseContext)}`, + writeVaultRootFile: async (_relativePath, content) => { + writtenContent = content; + }, + deleteVaultRootFile: async () => { + throw new Error("should not delete when unrelated content remains"); + }, + showNotice: (message) => { + notice = message; + }, + warn: () => {}, + }); + + await controller.uninstallVaultAgentsFile(); + + assert.match(writtenContent, /# User Rules/); + assert.match(writtenContent, /Keep answers brief\./); + assert.doesNotMatch(writtenContent, /SideNote2 Vault Agent Routing/); + assert.equal(notice, "Removed SideNote2 AGENTS instructions from /home/bun/Documents/public/AGENTS.md"); +}); + +test("vault agents file controller deletes AGENTS.md when only SideNote2-managed content exists during uninstall", async () => { + let deletedPath = ""; + let notice = ""; + + const controller = new VaultAgentsFileController({ + getVaultAgentsFileContext: () => baseContext, + vaultRootFileExists: async () => true, + readVaultRootFile: async () => buildVaultAgentsFileContent(baseContext), + writeVaultRootFile: async () => { + throw new Error("should not write when delete is enough"); + }, + deleteVaultRootFile: async (relativePath) => { + deletedPath = relativePath; + }, + showNotice: (message) => { + notice = message; + }, + warn: () => {}, + }); + + await controller.uninstallVaultAgentsFile(); + + assert.equal(deletedPath, "AGENTS.md"); + assert.equal(notice, "Removed SideNote2 AGENTS.md from /home/bun/Documents/public/AGENTS.md"); +});