@@ -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 - f 0 - 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+
97158function 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+
181250async 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
0 commit comments