Skip to content

Commit 61ad673

Browse files
Integrate Obsidian Headless sync so cataloging can sync without desktop focus disruptions and fail early when Sync prerequisites are missing. (#20)
1 parent 6a3ce05 commit 61ad673

File tree

6 files changed

+440
-23
lines changed

6 files changed

+440
-23
lines changed

README.md

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,10 @@ This project automates Daytona sandbox setup and OpenCode execution.
4646
- `DAYTONA_API_KEY`
4747
- `DAYTONA_API_URL` for self-hosted Daytona (example: `https://daytona.example.com/api`)
4848
- Optional but recommended: `OPENCODE_SERVER_PASSWORD`
49-
- Optional: `obsidian` command in `PATH` (for Obsidian note cataloging/open)
49+
- Obsidian Headless CLI in `PATH` (`ob`, installed via `npm install -g obsidian-headless`) for non-disruptive sync
50+
- Obsidian Catalyst access (Headless Sync is currently open beta)
51+
- Active Obsidian Sync subscription (required for `ob sync-*`)
52+
- Optional: `obsidian` desktop CLI in `PATH` if you explicitly use desktop integration/open-after-catalog
5053

5154
---
5255

@@ -94,6 +97,9 @@ It sets up:
9497

9598
- `~/.config/opencode/shpit.toml` for shared preferences
9699
- `~/.config/opencode/.env` for optional credential storage
100+
- headless preflight checks when `obsidian.integration_mode = "headless"`:
101+
- verifies `ob` command is installed
102+
- runs `ob sync-list-remote` to validate account/login/sync access
97103

98104
No provider API key is required if you only use free `opencode/*` models (for example `opencode/minimax-m2.5-free`).
99105

@@ -105,14 +111,31 @@ Example config:
105111
[obsidian]
106112
enabled = true
107113
command = "obsidian"
114+
integration_mode = "headless" # headless | desktop
115+
headless_command = "ob"
108116
vault_path = "/absolute/path/to/vault"
109117
notes_root = "Research/OpenCode"
110118
catalog_mode = "date" # date | repo
111-
open_after_catalog = false
119+
sync_after_catalog = true
120+
sync_timeout_sec = 120
121+
open_after_catalog = false # desktop mode only
112122
```
113123

114124
Project-level `shpit.toml` or `.shpit.toml` overrides global config.
115-
The configured command must be `obsidian` (not `obs`).
125+
The configured desktop command must be `obsidian` (not `obs`).
126+
127+
Headless setup is one-time per local vault path:
128+
129+
```bash
130+
npm install -g obsidian-headless
131+
ob login
132+
ob sync-list-remote
133+
mkdir -p ~/vaults/my-headless-vault
134+
ob sync-setup --vault "My Vault" --path ~/vaults/my-headless-vault
135+
ob sync --path ~/vaults/my-headless-vault
136+
```
137+
138+
Do not run desktop Sync and Headless Sync on the same device for the same vault path; use a dedicated local path for headless workflows.
116139

117140
---
118141

@@ -121,7 +144,7 @@ The configured command must be `obsidian` (not `obs`).
121144
| Command | Purpose |
122145
|---|---|
123146
| `scripts/install-gh-package.sh` | Bootstrap install from GitHub Packages on a new machine |
124-
| `bun run setup` | Guided setup for shared config/env and Obsidian cataloging |
147+
| `bun run setup` | Guided setup for shared config/env, Obsidian mode selection, and headless preflight checks |
125148
| `bun run start` | Launch OpenCode web in a Daytona sandbox |
126149
| `bun run analyze -- --input example.md` | Analyze repos listed in a file |
127150
| `bun run analyze -- <url1> <url2>` | Analyze direct repo URLs |
@@ -162,7 +185,7 @@ bun run start -- --no-open
162185
- Auto-installs missing `git` and `node/npm` inside sandbox
163186
- Forwards provider env vars (`OPENAI_*`, `ANTHROPIC_*`, `XAI_*`, `OPENROUTER_*`, `ZHIPU_*`, `MINIMAX_*`, etc.)
164187
- Syncs local OpenCode config files from `~/.config/opencode` when present
165-
- Auto-catalogs findings into Obsidian when enabled via `shpit.toml`
188+
- Auto-catalogs findings into Obsidian when enabled via `shpit.toml`, with optional automatic `ob sync` in headless mode
166189

167190
### Examples
168191

src/install.ts

Lines changed: 170 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ type CliOptions = {
1212
notesRoot?: string;
1313
catalogMode?: "date" | "repo";
1414
openAfterCatalog?: boolean;
15+
integrationMode?: "desktop" | "headless";
16+
syncAfterCatalog?: boolean;
17+
syncTimeoutSec?: number;
1518
daytonaApiKey?: string;
1619
openaiApiKey?: string;
1720
zhipuApiKey?: string;
@@ -27,7 +30,10 @@ function parseCliOptions(): CliOptions {
2730
"vault-path": { type: "string" },
2831
"notes-root": { type: "string" },
2932
"catalog-mode": { type: "string" },
30-
"open-after-catalog": { type: "boolean", default: false },
33+
"open-after-catalog": { type: "boolean" },
34+
"obsidian-integration": { type: "string" },
35+
"sync-after-catalog": { type: "boolean" },
36+
"sync-timeout-sec": { type: "string" },
3137
"daytona-api-key": { type: "string" },
3238
"openai-api-key": { type: "string" },
3339
"zhipu-api-key": { type: "string" },
@@ -44,7 +50,10 @@ Options:
4450
--vault-path <path> Obsidian vault path (absolute or ~/...)
4551
--notes-root <path> Folder inside vault for audit notes (default: Research/OpenCode)
4652
--catalog-mode <mode> date | repo (default: date)
47-
--open-after-catalog Open each new note via obsidian CLI after writing
53+
--obsidian-integration headless | desktop (default: auto-detect)
54+
--sync-after-catalog Run 'ob sync' after writing each note (headless mode)
55+
--sync-timeout-sec <sec> Timeout for 'ob sync' (default: 120)
56+
--open-after-catalog Open each new note via obsidian CLI (desktop mode)
4857
--daytona-api-key <key> Seed DAYTONA_API_KEY into ~/.config/opencode/.env
4958
--openai-api-key <key> Seed OPENAI_API_KEY into ~/.config/opencode/.env
5059
--zhipu-api-key <key> Seed ZHIPU_API_KEY into ~/.config/opencode/.env
@@ -58,12 +67,34 @@ Options:
5867
throw new Error(`--catalog-mode must be "date" or "repo". Received "${rawCatalogMode}".`);
5968
}
6069

70+
const rawIntegrationMode = values["obsidian-integration"];
71+
if (rawIntegrationMode && rawIntegrationMode !== "desktop" && rawIntegrationMode !== "headless") {
72+
throw new Error(
73+
`--obsidian-integration must be "desktop" or "headless". Received "${rawIntegrationMode}".`,
74+
);
75+
}
76+
77+
const rawSyncTimeoutSec = values["sync-timeout-sec"];
78+
let syncTimeoutSec: number | undefined;
79+
if (rawSyncTimeoutSec !== undefined) {
80+
const parsed = Number.parseInt(rawSyncTimeoutSec, 10);
81+
if (!Number.isInteger(parsed) || parsed <= 0) {
82+
throw new Error(
83+
`--sync-timeout-sec must be a positive integer. Received "${rawSyncTimeoutSec}".`,
84+
);
85+
}
86+
syncTimeoutSec = parsed;
87+
}
88+
6189
return {
6290
yes: values.yes,
6391
vaultPath: values["vault-path"],
6492
notesRoot: values["notes-root"],
6593
catalogMode: rawCatalogMode as "date" | "repo" | undefined,
6694
openAfterCatalog: values["open-after-catalog"],
95+
integrationMode: rawIntegrationMode as "desktop" | "headless" | undefined,
96+
syncAfterCatalog: values["sync-after-catalog"],
97+
syncTimeoutSec,
6798
daytonaApiKey: values["daytona-api-key"],
6899
openaiApiKey: values["openai-api-key"],
69100
zhipuApiKey: values["zhipu-api-key"],
@@ -84,16 +115,46 @@ function expandHomeDir(value: string | undefined): string | undefined {
84115
return path.join(home, value.slice(1));
85116
}
86117

87-
async function detectObsidianBinary(): Promise<string | undefined> {
118+
async function detectCommandBinary(command: string): Promise<string | undefined> {
88119
try {
89-
const { stdout } = await execFileAsync("sh", ["-lc", "command -v obsidian"]);
120+
const { stdout } = await execFileAsync("which", [command]);
90121
const resolved = stdout.trim();
91122
return resolved || undefined;
92123
} catch {
93124
return undefined;
94125
}
95126
}
96127

128+
function countRemoteVaults(output: string): number {
129+
return output.split(/\r?\n/).filter((line) => /^\s*[a-f0-9]{32}\s+"/.test(line)).length;
130+
}
131+
132+
function describeExecError(error: unknown): string {
133+
if (!error || typeof error !== "object") {
134+
return String(error);
135+
}
136+
137+
const message = "message" in error ? String(error.message) : "unknown error";
138+
const stdout = "stdout" in error ? String(error.stdout ?? "") : "";
139+
const stderr = "stderr" in error ? String(error.stderr ?? "") : "";
140+
const details = [stdout.trim(), stderr.trim()].filter(Boolean).join("\n");
141+
return details ? `${message}\n${details}` : message;
142+
}
143+
144+
async function validateHeadlessSyncAccess(command: string): Promise<number> {
145+
try {
146+
const { stdout, stderr } = await execFileAsync(command, ["sync-list-remote"], {
147+
timeout: 30_000,
148+
maxBuffer: 4 * 1024 * 1024,
149+
});
150+
return countRemoteVaults(`${stdout}\n${stderr}`);
151+
} catch (error) {
152+
throw new Error(
153+
`Headless Sync preflight failed. Ensure Obsidian Catalyst access, an active Obsidian Sync subscription, and successful \`ob login\`.\n${describeExecError(error)}`,
154+
);
155+
}
156+
}
157+
97158
function parseEnvFile(content: string): Map<string, string> {
98159
const result = new Map<string, string>();
99160
const lines = content.split(/\r?\n/);
@@ -178,6 +239,14 @@ async function askText(params: {
178239
return answer;
179240
}
180241

242+
function parsePositiveInteger(value: string, label: string): number {
243+
const parsed = Number.parseInt(value, 10);
244+
if (!Number.isInteger(parsed) || parsed <= 0) {
245+
throw new Error(`${label} must be a positive integer. Received "${value}".`);
246+
}
247+
return parsed;
248+
}
249+
181250
async function main(): Promise<void> {
182251
const options = parseCliOptions();
183252
await loadConfiguredEnv();
@@ -192,19 +261,34 @@ async function main(): Promise<void> {
192261
const configPath = path.join(configDir, "shpit.toml");
193262
const envPath = path.join(configDir, ".env");
194263

195-
const obsidianBinary = await detectObsidianBinary();
264+
const obsidianBinary = await detectCommandBinary("obsidian");
265+
const headlessBinary = await detectCommandBinary("ob");
196266
console.log(
197267
obsidianBinary
198-
? `[install] Detected Obsidian CLI command at: ${obsidianBinary}`
199-
: "[install] Obsidian CLI command not found in PATH. Expected command name: obsidian",
268+
? `[install] Detected Obsidian desktop CLI at: ${obsidianBinary}`
269+
: "[install] Obsidian desktop CLI not found in PATH (command: obsidian)",
200270
);
201271
console.log(
202-
"[install] The installer will not execute Obsidian commands; it only configures them.",
272+
headlessBinary
273+
? `[install] Detected Obsidian Headless CLI at: ${headlessBinary}`
274+
: "[install] Obsidian Headless CLI not found in PATH (command: ob)",
275+
);
276+
console.log(
277+
"[install] Headless mode runs a real preflight against Obsidian Sync using `ob sync-list-remote`.",
203278
);
204279

205280
const rl = createInterface({ input: process.stdin, output: process.stdout });
206281
try {
207282
const nonInteractive = options.yes;
283+
const hasExistingConfig = Boolean(
284+
existingConfig.paths.globalConfigPath ?? existingConfig.paths.projectConfigPath,
285+
);
286+
const recommendedIntegrationMode = hasExistingConfig
287+
? existingConfig.obsidian.integrationMode
288+
: headlessBinary
289+
? "headless"
290+
: "desktop";
291+
const headlessCommand = existingConfig.obsidian.headlessCommand;
208292

209293
const enableObsidian = nonInteractive
210294
? Boolean(options.vaultPath ?? existingConfig.obsidian.enabled)
@@ -214,6 +298,21 @@ async function main(): Promise<void> {
214298
defaultValue: existingConfig.obsidian.enabled,
215299
});
216300

301+
const requestedIntegrationMode =
302+
options.integrationMode ??
303+
(nonInteractive
304+
? recommendedIntegrationMode
305+
: await askText({
306+
rl,
307+
prompt: "Obsidian integration mode (headless|desktop)",
308+
defaultValue: recommendedIntegrationMode,
309+
}));
310+
const integrationMode =
311+
(requestedIntegrationMode ?? recommendedIntegrationMode).trim() || recommendedIntegrationMode;
312+
if (integrationMode !== "headless" && integrationMode !== "desktop") {
313+
throw new Error(`Invalid integration mode "${integrationMode}".`);
314+
}
315+
217316
const vaultPath = expandHomeDir(
218317
options.vaultPath ??
219318
(nonInteractive
@@ -249,18 +348,70 @@ async function main(): Promise<void> {
249348
throw new Error(`Invalid catalog mode "${catalogMode}".`);
250349
}
251350

252-
const openAfterCatalog = nonInteractive
253-
? Boolean(options.openAfterCatalog)
254-
: await askYesNo({
255-
rl,
256-
prompt: "Open each created note via obsidian command",
257-
defaultValue: existingConfig.obsidian.openAfterCatalog,
258-
});
351+
const openAfterCatalog =
352+
integrationMode === "desktop"
353+
? nonInteractive
354+
? (options.openAfterCatalog ?? existingConfig.obsidian.openAfterCatalog)
355+
: await askYesNo({
356+
rl,
357+
prompt: "Open each created note via obsidian command",
358+
defaultValue: existingConfig.obsidian.openAfterCatalog,
359+
})
360+
: false;
361+
362+
const syncAfterCatalog =
363+
integrationMode === "headless"
364+
? nonInteractive
365+
? (options.syncAfterCatalog ?? existingConfig.obsidian.syncAfterCatalog)
366+
: await askYesNo({
367+
rl,
368+
prompt: "Run `ob sync` after each note write",
369+
defaultValue: existingConfig.obsidian.syncAfterCatalog,
370+
})
371+
: false;
372+
373+
let syncTimeoutSec = options.syncTimeoutSec ?? existingConfig.obsidian.syncTimeoutSec;
374+
if (integrationMode === "headless" && !nonInteractive && options.syncTimeoutSec === undefined) {
375+
const entered = await askText({
376+
rl,
377+
prompt: "Headless sync timeout in seconds",
378+
defaultValue: String(existingConfig.obsidian.syncTimeoutSec),
379+
});
380+
if (!entered) {
381+
throw new Error("Headless sync timeout is required in headless mode.");
382+
}
383+
syncTimeoutSec = parsePositiveInteger(entered, "Headless sync timeout");
384+
}
259385

260386
if (enableObsidian && !vaultPath) {
261387
throw new Error("Obsidian cataloging is enabled, but no vault path was provided.");
262388
}
263389

390+
if (enableObsidian && integrationMode === "desktop" && openAfterCatalog && !obsidianBinary) {
391+
throw new Error(
392+
"Desktop integration with open_after_catalog requires the `obsidian` command in PATH.",
393+
);
394+
}
395+
396+
if (enableObsidian && integrationMode === "headless") {
397+
const resolvedHeadlessBinary = await detectCommandBinary(headlessCommand);
398+
if (!resolvedHeadlessBinary) {
399+
throw new Error(
400+
"Headless integration requires `ob` in PATH. Install with: npm install -g obsidian-headless",
401+
);
402+
}
403+
const remoteVaultCount = await validateHeadlessSyncAccess(headlessCommand);
404+
if (remoteVaultCount > 0) {
405+
console.log(
406+
`[install] Headless preflight passed. Remote vaults visible to this account: ${remoteVaultCount}`,
407+
);
408+
} else {
409+
console.warn(
410+
'[install] Headless preflight succeeded but no remote vaults were found. Create one with `ob sync-create-remote --name "..."`.',
411+
);
412+
}
413+
}
414+
264415
await mkdir(configDir, { recursive: true });
265416

266417
const shpitTomlLines: string[] = [];
@@ -269,6 +420,8 @@ async function main(): Promise<void> {
269420
shpitTomlLines.push("[obsidian]");
270421
shpitTomlLines.push(`enabled = ${enableObsidian ? "true" : "false"}`);
271422
shpitTomlLines.push('command = "obsidian"');
423+
shpitTomlLines.push(`integration_mode = ${JSON.stringify(integrationMode)}`);
424+
shpitTomlLines.push(`headless_command = ${JSON.stringify(headlessCommand)}`);
272425
if (vaultPath) {
273426
shpitTomlLines.push(`vault_path = ${JSON.stringify(vaultPath)}`);
274427
}
@@ -278,6 +431,8 @@ async function main(): Promise<void> {
278431
shpitTomlLines.push(
279432
`catalog_mode = ${JSON.stringify(catalogMode ?? existingConfig.obsidian.catalogMode)}`,
280433
);
434+
shpitTomlLines.push(`sync_after_catalog = ${syncAfterCatalog ? "true" : "false"}`);
435+
shpitTomlLines.push(`sync_timeout_sec = ${syncTimeoutSec}`);
281436
shpitTomlLines.push(`open_after_catalog = ${openAfterCatalog ? "true" : "false"}`);
282437
shpitTomlLines.push("");
283438

src/obsidian-catalog.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, expect, test } from "bun:test";
2+
import path from "node:path";
23
import { __testables } from "./obsidian-catalog.js";
34

45
describe("obsidian catalog pathing", () => {
@@ -27,4 +28,15 @@ describe("obsidian catalog pathing", () => {
2728
/^Research[\\/]OpenCode[\\/]owner-repo[\\/]\d{4}-\d{2}-\d{2}-01-owner-repo\.md$/,
2829
);
2930
});
31+
32+
test("keeps resolved note path inside vault", () => {
33+
const notePath = __testables.resolveNotePathWithinVault("/vault", "Research/OpenCode/note.md");
34+
expect(notePath).toBe(path.resolve("/vault", "Research/OpenCode/note.md"));
35+
});
36+
37+
test("rejects note path traversal outside vault", () => {
38+
expect(() => __testables.resolveNotePathWithinVault("/vault", "../../etc/passwd")).toThrow(
39+
/outside Obsidian vault/,
40+
);
41+
});
3042
});

0 commit comments

Comments
 (0)