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
-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");
+});