vicky469_aside/scripts/lib/asideRepoScripts.mjs
2026-07-08 12:58:01 +08:00

1220 lines
41 KiB
JavaScript

import { createHash, randomUUID } from "node:crypto";
import { access, copyFile, mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
function getRepoRoot(metaUrl) {
return path.resolve(path.dirname(fileURLToPath(metaUrl)), "../..");
}
const PUBLIC_BUNDLED_SKILL_NAMES = ["aside"];
function parseNonNegativeIntegerOption(rawValue, flagName) {
const parsed = Number(rawValue);
if (!Number.isInteger(parsed) || parsed < 0) {
throw new Error(`Expected ${flagName} to be a non-negative integer.`);
}
return parsed;
}
function printCreateNoteCommentThreadUsage(stream = process.stderr) {
stream.write(
[
"Usage:",
" node scripts/create-note-comment-thread.mjs --file <note.md> --page (--comment <text> | --comment-file <path> | --stdin) [--settle-ms <milliseconds>]",
" node scripts/create-note-comment-thread.mjs --file <note.md> --selected-text <text> --start-line <number> --start-char <number> --end-line <number> --end-char <number> (--comment <text> | --comment-file <path> | --stdin) [--settle-ms <milliseconds>]",
].join("\n") + "\n",
);
}
function printCreateNoteCommentThreadWithChildrenUsage(stream = process.stderr) {
stream.write(
[
"Usage:",
" node scripts/create-note-comment-thread-with-children.mjs --file <note.md> --page --root-comment-file <path> (--children-dir <dir> | --children-file <path>) [--child-separator <text>] [--replace-existing] [--settle-ms <milliseconds>]",
" node scripts/create-note-comment-thread-with-children.mjs --file <note.md> --selected-text <text> --start-line <number> --start-char <number> --end-line <number> --end-char <number> --root-comment-file <path> (--children-dir <dir> | --children-file <path>) [--child-separator <text>] [--replace-existing] [--settle-ms <milliseconds>]",
].join("\n") + "\n",
);
}
function printAppendNoteCommentEntryUsage(stream = process.stderr) {
stream.write(
[
"Usage:",
" node scripts/append-note-comment-entry.mjs (--file <note.md> --id <comment-id> | --uri <obsidian://aside-comment?...>) (--comment <text> | --comment-file <path> | --stdin) [--settle-ms <milliseconds>]",
].join("\n") + "\n",
);
}
function printUpdateNoteCommentUsage(stream = process.stderr) {
stream.write(
[
"Usage:",
" node scripts/update-note-comment.mjs (--file <note.md> --id <comment-id> | --uri <obsidian://aside-comment?...>) (--comment <text> | --comment-file <path> | --stdin) [--settle-ms <milliseconds>]",
].join("\n") + "\n",
);
}
function printInstallBundledSkillUsage(stream = process.stderr) {
stream.write(
[
"Usage:",
" node scripts/install-bundled-skill.mjs [--name <skill-name>]... [--dest <skills-root>]",
"",
"Defaults:",
" installs all public bundled skills when --name is omitted",
" --dest defaults to $CODEX_HOME/skills or ~/.codex/skills",
].join("\n") + "\n",
);
}
function parseCommentTargetArgs(argv, options) {
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:
return arg;
}
}
return undefined;
}
function parseCreateNoteCommentThreadArgs(argv) {
const options = {
file: "",
page: false,
selectedText: "",
startLine: null,
startChar: null,
endLine: null,
endChar: null,
comment: null,
commentFile: "",
stdin: false,
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 "--page":
options.page = true;
break;
case "--selected-text":
options.selectedText = argv[index + 1] ?? "";
index += 1;
break;
case "--start-line":
options.startLine = parseNonNegativeIntegerOption(argv[index + 1] ?? "", "--start-line");
index += 1;
break;
case "--start-char":
options.startChar = parseNonNegativeIntegerOption(argv[index + 1] ?? "", "--start-char");
index += 1;
break;
case "--end-line":
options.endLine = parseNonNegativeIntegerOption(argv[index + 1] ?? "", "--end-line");
index += 1;
break;
case "--end-char":
options.endChar = parseNonNegativeIntegerOption(argv[index + 1] ?? "", "--end-char");
index += 1;
break;
case "--comment":
options.comment = argv[index + 1] ?? "";
index += 1;
break;
case "--comment-file":
options.commentFile = argv[index + 1] ?? "";
index += 1;
break;
case "--stdin":
options.stdin = true;
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 contentSources = [options.comment !== null, Boolean(options.commentFile), options.stdin].filter(Boolean).length;
if (!options.file || contentSources !== 1) {
throw new Error("Expected --file plus exactly one comment source.");
}
const hasSelectionTarget = options.selectedText.length > 0
|| options.startLine !== null
|| options.startChar !== null
|| options.endLine !== null
|| options.endChar !== null;
if (options.page && hasSelectionTarget) {
throw new Error("Expected either --page or a selection target, not both.");
}
if (!options.page) {
if (
!options.selectedText
|| options.startLine === null
|| options.startChar === null
|| options.endLine === null
|| options.endChar === null
) {
throw new Error(
"Expected --page, or --selected-text with --start-line/--start-char/--end-line/--end-char.",
);
}
if (
options.endLine < options.startLine
|| (options.endLine === options.startLine && options.endChar < options.startChar)
) {
throw new Error("Expected the end position to be at or after the start position.");
}
}
return options;
}
function parseCreateNoteCommentThreadWithChildrenArgs(argv) {
const options = {
file: "",
page: false,
selectedText: "",
startLine: null,
startChar: null,
endLine: null,
endChar: null,
rootCommentFile: "",
childrenDir: "",
childrenFile: "",
childSeparator: "\n---\n",
replaceExisting: false,
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 "--page":
options.page = true;
break;
case "--selected-text":
options.selectedText = argv[index + 1] ?? "";
index += 1;
break;
case "--start-line":
options.startLine = parseNonNegativeIntegerOption(argv[index + 1] ?? "", "--start-line");
index += 1;
break;
case "--start-char":
options.startChar = parseNonNegativeIntegerOption(argv[index + 1] ?? "", "--start-char");
index += 1;
break;
case "--end-line":
options.endLine = parseNonNegativeIntegerOption(argv[index + 1] ?? "", "--end-line");
index += 1;
break;
case "--end-char":
options.endChar = parseNonNegativeIntegerOption(argv[index + 1] ?? "", "--end-char");
index += 1;
break;
case "--root-comment-file":
options.rootCommentFile = argv[index + 1] ?? "";
index += 1;
break;
case "--children-dir":
options.childrenDir = argv[index + 1] ?? "";
index += 1;
break;
case "--children-file":
options.childrenFile = argv[index + 1] ?? "";
index += 1;
break;
case "--child-separator":
options.childSeparator = argv[index + 1] ?? "";
index += 1;
break;
case "--replace-existing":
options.replaceExisting = true;
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 childSources = [Boolean(options.childrenDir), Boolean(options.childrenFile)].filter(Boolean).length;
if (!options.file || !options.rootCommentFile || childSources !== 1) {
throw new Error("Expected --file, --root-comment-file, and exactly one of --children-dir or --children-file.");
}
if (options.childrenFile && !options.childSeparator) {
throw new Error("Expected --child-separator to be non-empty when --children-file is used.");
}
const hasSelectionTarget = options.selectedText.length > 0
|| options.startLine !== null
|| options.startChar !== null
|| options.endLine !== null
|| options.endChar !== null;
if (options.page && hasSelectionTarget) {
throw new Error("Expected either --page or a selection target, not both.");
}
if (!options.page) {
if (
!options.selectedText
|| options.startLine === null
|| options.startChar === null
|| options.endLine === null
|| options.endChar === null
) {
throw new Error(
"Expected --page, or --selected-text with --start-line/--start-char/--end-line/--end-char.",
);
}
if (
options.endLine < options.startLine
|| (options.endLine === options.startLine && options.endChar < options.startChar)
) {
throw new Error("Expected the end position to be at or after the start position.");
}
}
return options;
}
function parseAppendOrUpdateArgs(argv) {
const options = {
file: "",
id: "",
uri: "",
comment: null,
commentFile: "",
stdin: false,
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 "--comment":
options.comment = argv[index + 1] ?? "";
index += 1;
break;
case "--comment-file":
options.commentFile = argv[index + 1] ?? "";
index += 1;
break;
case "--stdin":
options.stdin = true;
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 contentSources = [options.comment !== null, Boolean(options.commentFile), options.stdin].filter(Boolean).length;
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;
}
function getDefaultSkillsRoot() {
const codexHome = process.env.CODEX_HOME?.trim();
return codexHome
? path.join(codexHome, "skills")
: path.join(homedir(), ".codex", "skills");
}
function parseInstallBundledSkillArgs(argv) {
const options = {
destRoot: getDefaultSkillsRoot(),
skillNames: [],
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
switch (arg) {
case "--name": {
const skillName = argv[index + 1] ?? "";
if (!skillName) {
throw new Error("Expected a skill name after --name.");
}
options.skillNames.push(skillName);
index += 1;
break;
}
case "--dest":
options.destRoot = path.resolve(process.cwd(), argv[index + 1] ?? "");
index += 1;
break;
case "--help":
case "-h":
return null;
default:
throw new Error(`Unknown argument: ${arg}`);
}
}
return options;
}
async function pathExists(targetPath) {
try {
await access(targetPath);
return true;
} catch {
return false;
}
}
async function copyDirectoryRecursive(sourceDir, destinationDir) {
await mkdir(destinationDir, { recursive: true });
const entries = await readdir(sourceDir, { withFileTypes: true });
for (const entry of entries) {
const sourcePath = path.join(sourceDir, entry.name);
const destinationPath = path.join(destinationDir, entry.name);
if (entry.isDirectory()) {
await copyDirectoryRecursive(sourcePath, destinationPath);
continue;
}
if (entry.isFile()) {
await copyFile(sourcePath, destinationPath);
continue;
}
throw new Error(`Unsupported skill entry type: ${sourcePath}`);
}
}
async function readStdin() {
const chunks = [];
for await (const chunk of process.stdin) {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
}
return Buffer.concat(chunks).toString("utf8");
}
function sleep(milliseconds) {
return new Promise((resolve) => {
setTimeout(resolve, milliseconds);
});
}
export function createContentFingerprint(content) {
return `${Buffer.byteLength(content, "utf8")}:${createHash("sha256").update(content).digest("hex")}`;
}
async function writeFileAtomically(targetPath, content) {
const tempPath = path.join(
path.dirname(targetPath),
`.${path.basename(targetPath)}.aside-${process.pid}-${randomUUID()}.tmp`,
);
await writeFile(tempPath, content, "utf8");
try {
await rename(tempPath, targetPath);
} catch (error) {
await rm(tempPath, { force: true });
throw error;
}
}
export async function writeObservedNoteSafely(notePath, expectedFingerprint, nextContent, options = {}) {
const settleMs = options.settleMs ?? 0;
if (settleMs > 0) {
await sleep(settleMs);
}
let currentContent;
try {
currentContent = await readFile(notePath, "utf8");
} catch (error) {
return {
kind: "changed",
reason: error instanceof Error ? error.message : String(error),
};
}
if (createContentFingerprint(currentContent) !== expectedFingerprint) {
return {
kind: "changed",
reason: "content changed after the script read it",
};
}
await writeFileAtomically(notePath, nextContent);
return { kind: "written" };
}
async function loadCommentBody(options) {
if (options.comment !== null) {
return options.comment;
}
if (options.commentFile) {
return readFile(path.resolve(process.cwd(), options.commentFile), "utf8");
}
return readStdin();
}
function getPageCommentLabelForPath(filePath) {
return path.basename(filePath).replace(/\.[^.]+$/i, "") || filePath;
}
function hashCommentSelection(text) {
return createHash("sha256").update(text, "utf8").digest("hex");
}
function normalizeCommentBody(body) {
return body.replace(/\r\n/g, "\n").replace(/\n+$/, "");
}
export function hashText(text) {
return createHash("sha256").update(text, "utf8").digest("hex");
}
function normalizeStoragePath(targetPath) {
return targetPath.replace(/\\/g, "/").replace(/\/+/g, "/").replace(/\/$/, "");
}
export function getSidecarPath(vaultRoot, noteRelativePath) {
const normalized = normalizeStoragePath(noteRelativePath);
const noteHash = hashText(normalized);
const shard = noteHash.slice(0, 2) || "00";
return path.join(vaultRoot, ".obsidian", "plugins", "aside", "sidenotes", "by-note", shard, `${noteHash}.json`);
}
export function getSourceSidecarPath(vaultRoot, sourceId) {
const sourceHash = hashText(sourceId);
const shard = sourceHash.slice(0, 2) || "00";
return path.join(vaultRoot, ".obsidian", "plugins", "aside", "sidenotes", "by-source", shard, `${sourceHash}.json`);
}
async function readSourceIdForPath(vaultRoot, noteRelativePath) {
const dataPath = path.join(vaultRoot, ".obsidian", "plugins", "aside", "data.json");
let parsed;
try {
parsed = JSON.parse(await readFile(dataPath, "utf8"));
} catch (error) {
if (error && error.code === "ENOENT") {
return null;
}
throw error;
}
const state = parsed?.sourceIdentityState;
const pathToSourceId = state?.pathToSourceId;
if (pathToSourceId && typeof pathToSourceId[noteRelativePath] === "string") {
return pathToSourceId[noteRelativePath];
}
const sources = state?.sources && typeof state.sources === "object" ? state.sources : {};
for (const record of Object.values(sources)) {
if (!record || typeof record !== "object") {
continue;
}
if (record.currentPath === noteRelativePath) {
return record.sourceId ?? null;
}
if (Array.isArray(record.aliases) && record.aliases.includes(noteRelativePath)) {
return record.sourceId ?? null;
}
}
return null;
}
async function writeSidecarPayload(sidecarPath, payload) {
const serialized = `${JSON.stringify(payload)}\n`;
const tempPath = `${sidecarPath}.tmp-${process.pid}-${randomUUID()}`;
await mkdir(path.dirname(sidecarPath), { recursive: true });
await writeFile(tempPath, serialized, "utf8");
try {
await rename(tempPath, sidecarPath);
} catch (error) {
await rm(tempPath, { force: true });
throw error;
}
}
export async function resolveVaultRootByPath(notePath) {
let current = path.resolve(notePath);
while (true) {
const parent = path.dirname(current);
if (parent === current) {
throw new Error(`Could not find an Obsidian vault (.obsidian directory) for ${notePath}`);
}
const obsidianDir = path.join(parent, ".obsidian");
if (await pathExists(obsidianDir)) {
return parent;
}
current = parent;
}
}
export function getVaultRelativePath(vaultRoot, absolutePath) {
const relative = path.relative(vaultRoot, path.resolve(absolutePath));
return relative.split(path.sep).join("/");
}
export async function readSidecar(vaultRoot, noteRelativePath) {
try {
const raw = await readFile(getSidecarPath(vaultRoot, noteRelativePath), "utf8");
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || parsed.version !== 1 || !Array.isArray(parsed.threads)) {
return null;
}
return parsed.threads.map((thread) => ({
...thread,
filePath: noteRelativePath,
entries: Array.isArray(thread.entries) ? thread.entries.map((entry) => ({ ...entry })) : [],
}));
} catch (error) {
if (error && error.code === "ENOENT") {
return null;
}
throw error;
}
}
export async function writeSidecar(vaultRoot, noteRelativePath, threads) {
const sidecarPath = getSidecarPath(vaultRoot, noteRelativePath);
const sourceId = await readSourceIdForPath(vaultRoot, noteRelativePath);
const sourceSidecarPath = sourceId ? getSourceSidecarPath(vaultRoot, sourceId) : null;
if (threads.length === 0) {
for (const targetPath of [sidecarPath, sourceSidecarPath].filter(Boolean)) {
if (await pathExists(targetPath)) {
await rm(targetPath, { force: true });
}
}
return;
}
const payload = {
version: 1,
notePath: noteRelativePath,
threads: threads.map((thread) => ({
...thread,
filePath: noteRelativePath,
})),
};
await writeSidecarPayload(sidecarPath, payload);
if (sourceId && sourceSidecarPath) {
await writeSidecarPayload(sourceSidecarPath, {
...payload,
sourceId,
});
}
}
async function loadChildCommentBodies(options) {
if (options.childrenDir) {
const dirPath = path.resolve(process.cwd(), options.childrenDir);
const entries = await readdir(dirPath, { withFileTypes: true });
const fileNames = entries
.filter((entry) => entry.isFile() && !entry.name.startsWith("."))
.map((entry) => entry.name)
.sort((left, right) => left.localeCompare(right));
if (fileNames.length === 0) {
throw new Error(`No child comment files found in ${dirPath}`);
}
return Promise.all(fileNames.map(async (fileName) =>
normalizeCommentBody(await readFile(path.join(dirPath, fileName), "utf8"))
));
}
const raw = await readFile(path.resolve(process.cwd(), options.childrenFile), "utf8");
const bodies = raw
.split(options.childSeparator)
.map((body) => normalizeCommentBody(body.trim()))
.filter((body) => body.length > 0);
if (bodies.length === 0) {
throw new Error(`No child comments found in ${options.childrenFile}`);
}
return bodies;
}
function removeExistingThreadsForTarget(threads, target) {
return threads.filter((thread) => {
if (thread.filePath !== target.filePath) {
return true;
}
if (target.anchorKind === "page") {
return thread.anchorKind !== "page";
}
return !(
thread.anchorKind !== "page"
&& thread.startLine === target.startLine
&& thread.startChar === target.startChar
&& thread.endLine === target.endLine
&& thread.endChar === target.endChar
&& thread.selectedTextHash === target.selectedTextHash
);
});
}
export async function loadThreadsWithFallback(vaultRoot, notePath, noteRelativePath) {
const sidecarThreads = await readSidecar(vaultRoot, noteRelativePath);
await readFile(notePath, "utf8");
return { threads: sidecarThreads ?? [] };
}
const COMMENT_LOCATION_PROTOCOL = "aside-comment";
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];
}
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://aside-comment link with vault, file, and commentId.");
}
const vaultRoot = await resolveVaultRootByName(uriTarget.vaultName);
const notePath = resolveVaultRelativeNotePath(vaultRoot, uriTarget.filePath);
return {
notePath,
commentId: uriTarget.commentId,
vaultRoot,
noteRelativePath: uriTarget.filePath,
};
}
const notePath = path.resolve(process.cwd(), options.file);
const vaultRoot = await resolveVaultRootByPath(notePath);
return {
notePath,
commentId: options.id,
vaultRoot,
noteRelativePath: getVaultRelativePath(vaultRoot, notePath),
};
}
async function getBundledSkills() {
const repoRoot = getRepoRoot(import.meta.url);
const skillsRoot = path.join(repoRoot, "skills");
const skillDirectories = new Map();
for (const skillName of PUBLIC_BUNDLED_SKILL_NAMES) {
const sourceDir = path.join(skillsRoot, skillName);
const skillFile = path.join(sourceDir, "SKILL.md");
if (await pathExists(skillFile)) {
skillDirectories.set(skillName, sourceDir);
} else {
throw new Error(`Missing public bundled skill: ${skillFile}`);
}
}
return { skillDirectories };
}
export async function runCreateNoteCommentThread(argv, io = { stdout: process.stdout, stderr: process.stderr }) {
let options;
try {
options = parseCreateNoteCommentThreadArgs(argv);
} catch (error) {
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
printCreateNoteCommentThreadUsage(io.stderr);
return 1;
}
if (options === null) {
printCreateNoteCommentThreadUsage(io.stdout);
return 0;
}
const notePath = path.resolve(process.cwd(), options.file);
let vaultRoot;
let noteRelativePath;
try {
vaultRoot = await resolveVaultRootByPath(notePath);
noteRelativePath = getVaultRelativePath(vaultRoot, notePath);
} catch (error) {
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
return 1;
}
const nextCommentBody = await loadCommentBody(options);
let threads;
try {
({ threads } = await loadThreadsWithFallback(vaultRoot, notePath, noteRelativePath));
} catch (error) {
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
return 1;
}
const timestamp = Date.now();
const threadId = randomUUID();
const selectedText = options.page
? getPageCommentLabelForPath(noteRelativePath)
: options.selectedText;
const nextThread = {
id: threadId,
filePath: noteRelativePath,
startLine: options.page ? 0 : options.startLine,
startChar: options.page ? 0 : options.startChar,
endLine: options.page ? 0 : options.endLine,
endChar: options.page ? 0 : options.endChar,
selectedText,
selectedTextHash: hashCommentSelection(selectedText),
anchorKind: options.page ? "page" : "selection",
orphaned: false,
resolved: false,
entries: [{
id: threadId,
body: normalizeCommentBody(nextCommentBody),
timestamp,
}],
createdAt: timestamp,
updatedAt: timestamp,
};
threads.push(nextThread);
await writeSidecar(vaultRoot, noteRelativePath, threads);
const anchorLabel = options.page ? "page note" : "anchored note";
io.stdout.write(`Created ${anchorLabel} thread ${threadId} in ${notePath}\n`);
return 0;
}
export async function runCreateNoteCommentThreadWithChildren(argv, io = { stdout: process.stdout, stderr: process.stderr }) {
let options;
try {
options = parseCreateNoteCommentThreadWithChildrenArgs(argv);
} catch (error) {
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
printCreateNoteCommentThreadWithChildrenUsage(io.stderr);
return 1;
}
if (options === null) {
printCreateNoteCommentThreadWithChildrenUsage(io.stdout);
return 0;
}
const notePath = path.resolve(process.cwd(), options.file);
let vaultRoot;
let noteRelativePath;
try {
vaultRoot = await resolveVaultRootByPath(notePath);
noteRelativePath = getVaultRelativePath(vaultRoot, notePath);
} catch (error) {
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
return 1;
}
let rootBody;
let childBodies;
try {
rootBody = normalizeCommentBody(await readFile(path.resolve(process.cwd(), options.rootCommentFile), "utf8"));
childBodies = await loadChildCommentBodies(options);
} catch (error) {
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
return 1;
}
if (!rootBody) {
io.stderr.write("Root comment body is empty.\n");
return 1;
}
let threads;
try {
({ threads } = await loadThreadsWithFallback(vaultRoot, notePath, noteRelativePath));
} catch (error) {
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
return 1;
}
const timestamp = Date.now();
const threadId = randomUUID();
const selectedText = options.page
? getPageCommentLabelForPath(noteRelativePath)
: options.selectedText;
const selectedTextHash = hashCommentSelection(selectedText);
const target = {
filePath: noteRelativePath,
anchorKind: options.page ? "page" : "selection",
startLine: options.page ? 0 : options.startLine,
startChar: options.page ? 0 : options.startChar,
endLine: options.page ? 0 : options.endLine,
endChar: options.page ? 0 : options.endChar,
selectedTextHash,
};
const entries = [
{
id: threadId,
body: rootBody,
timestamp,
},
...childBodies.map((body, index) => ({
id: randomUUID(),
body,
timestamp: timestamp + index + 1,
})),
];
const nextThread = {
id: threadId,
filePath: noteRelativePath,
startLine: target.startLine,
startChar: target.startChar,
endLine: target.endLine,
endChar: target.endChar,
selectedText,
selectedTextHash,
anchorKind: options.page ? "page" : "selection",
orphaned: false,
resolved: false,
entries,
createdAt: timestamp,
updatedAt: entries[entries.length - 1]?.timestamp ?? timestamp,
};
const nextThreads = [
...(options.replaceExisting ? removeExistingThreadsForTarget(threads, target) : threads),
nextThread,
];
await writeSidecar(vaultRoot, noteRelativePath, nextThreads);
const anchorLabel = options.page ? "page note" : "anchored note";
io.stdout.write(
`Created ${anchorLabel} thread ${threadId} with ${childBodies.length} child `
+ `comment${childBodies.length === 1 ? "" : "s"} in ${notePath}\n`,
);
return 0;
}
export async function runAppendNoteCommentEntry(argv, io = { stdout: process.stdout, stderr: process.stderr }) {
let options;
try {
options = parseAppendOrUpdateArgs(argv);
} catch (error) {
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
printAppendNoteCommentEntryUsage(io.stderr);
return 1;
}
if (options === null) {
printAppendNoteCommentEntryUsage(io.stdout);
return 0;
}
let writeTarget;
try {
writeTarget = await resolveCommentWriteTarget(options);
} catch (error) {
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
return 1;
}
const { notePath, commentId, vaultRoot, noteRelativePath } = writeTarget;
const nextCommentBody = await loadCommentBody(options);
let threads;
try {
({ threads } = await loadThreadsWithFallback(vaultRoot, notePath, noteRelativePath));
} catch (error) {
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
return 1;
}
let found = false;
const timestamp = Date.now();
const updatedThreads = threads.map((thread) => {
const matchesThread = thread.id === commentId || thread.entries.some((entry) => entry.id === commentId);
if (!matchesThread) {
return thread;
}
found = true;
return {
...thread,
entries: [...thread.entries, { id: randomUUID(), body: normalizeCommentBody(nextCommentBody), timestamp }],
updatedAt: Math.max(thread.updatedAt || 0, timestamp),
};
});
if (!found) {
io.stderr.write(`Comment id not found: ${commentId}\n`);
return 1;
}
await writeSidecar(vaultRoot, noteRelativePath, updatedThreads);
io.stdout.write(`Appended a new entry to comment ${commentId} in ${notePath}\n`);
return 0;
}
export async function runUpdateNoteComment(argv, io = { stdout: process.stdout, stderr: process.stderr }) {
let options;
try {
options = parseAppendOrUpdateArgs(argv);
} catch (error) {
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
printUpdateNoteCommentUsage(io.stderr);
return 1;
}
if (options === null) {
printUpdateNoteCommentUsage(io.stdout);
return 0;
}
let writeTarget;
try {
writeTarget = await resolveCommentWriteTarget(options);
} catch (error) {
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
return 1;
}
const { notePath, commentId, vaultRoot, noteRelativePath } = writeTarget;
const nextCommentBody = await loadCommentBody(options);
const normalizedBody = normalizeCommentBody(nextCommentBody);
let threads;
try {
({ threads } = await loadThreadsWithFallback(vaultRoot, notePath, noteRelativePath));
} catch (error) {
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
return 1;
}
let found = false;
const updatedThreads = threads.map((thread) => {
if (thread.id === commentId) {
found = true;
const entries = thread.entries.slice();
const latestEntry = entries[entries.length - 1];
if (latestEntry) {
latestEntry.body = normalizedBody;
}
return { ...thread, entries };
}
const matchingEntryIndex = thread.entries.findIndex((entry) => entry.id === commentId);
if (matchingEntryIndex === -1) {
return thread;
}
found = true;
const entries = thread.entries.slice();
entries[matchingEntryIndex] = { ...entries[matchingEntryIndex], body: normalizedBody };
return { ...thread, entries };
});
if (!found) {
io.stderr.write(`Comment id not found: ${commentId}\n`);
return 1;
}
await writeSidecar(vaultRoot, noteRelativePath, updatedThreads);
io.stdout.write(`Updated comment ${commentId} in ${notePath}\n`);
return 0;
}
export async function runInstallBundledSkill(argv, io = { stdout: process.stdout, stderr: process.stderr }) {
let options;
try {
options = parseInstallBundledSkillArgs(argv);
} catch (error) {
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
printInstallBundledSkillUsage(io.stderr);
return 1;
}
if (options === null) {
printInstallBundledSkillUsage(io.stdout);
return 0;
}
const { skillDirectories } = await getBundledSkills();
const requestedSkillNames = options.skillNames.length > 0
? [...new Set(options.skillNames)]
: [...skillDirectories.keys()];
const destinationRoot = options.destRoot;
await mkdir(destinationRoot, { recursive: true });
for (const skillName of requestedSkillNames) {
const sourceDir = skillDirectories.get(skillName);
if (!sourceDir) {
throw new Error(`Bundled skill not found: ${skillName}`);
}
const destinationDir = path.join(destinationRoot, skillName);
await rm(destinationDir, { recursive: true, force: true });
await copyDirectoryRecursive(sourceDir, destinationDir);
io.stdout.write(`Installed skill ${skillName} to ${destinationDir}\n`);
}
io.stdout.write("Restart Codex to pick up new skills.\n");
return 0;
}
export async function runScriptMain(run, argv = process.argv.slice(2), io = { stdout: process.stdout, stderr: process.stderr }) {
try {
const exitCode = await run(argv, io);
if (exitCode !== 0) {
process.exitCode = exitCode;
}
} catch (error) {
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
}
}