diff --git a/README.md b/README.md
index d1f4088..8135168 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,40 @@
# NotesMD CLI
-> **Note**: With the release of the official Obsidian CLI., this project has been renamed from "Obsidian CLI" to "NotesMD CLI" to avoid confusion. NotesMD CLI works **without requiring Obsidian to be running**, making it perfect for scripting, automation, and terminal-only environments.
+> **Note**: With the release of the official Obsidian CLI, this project has been renamed from "Obsidian CLI" to "NotesMD CLI" to avoid confusion. NotesMD CLI works **without requiring Obsidian to be running**, making it perfect for scripting, automation, and terminal-only environments.
+
+---
+
+## Table of Contents
+
+- [Description](#description)
+- [Install](#install)
+ - [Windows](#windows)
+ - [Mac and Linux](#mac-and-linux)
+ - [Arch Linux (AUR)](#arch-linux-aur)
+ - [Build from Source](#build-from-source)
+ - [Headless / No Obsidian Installed](#headless--no-obsidian-installed)
+- [Migrating from Obsidian CLI](#migrating-from-obsidian-cli)
+- [Usage](#usage)
+ - [Help](#help)
+ - [Editor Flag](#editor-flag)
+ - [Add Vault](#add-vault)
+ - [Remove Vault](#remove-vault)
+ - [List Vaults](#list-vaults)
+ - [Set Default Vault and Open Type](#set-default-vault-and-open-type)
+ - [Open Note](#open-note)
+ - [Daily Note](#daily-note)
+ - [Search Note](#search-note)
+ - [Search Note Content](#search-note-content)
+ - [List Vault Contents](#list-vault-contents)
+ - [Print Note](#print-note)
+ - [Create / Update Note](#create--update-note)
+ - [Move / Rename Note](#move--rename-note)
+ - [Delete Note](#delete-note)
+ - [Frontmatter](#frontmatter)
+- [Deprecated Commands](#deprecated-commands)
+- [Excluded Files](#excluded-files)
+- [Contribution](#contribution)
+- [License](#license)
---
@@ -62,12 +96,35 @@ go build -o notesmd-cli .
sudo install -m 755 notesmd-cli /usr/local/bin/
```
-### Headless / No Obsidian Installed
+### Headless / No Obsidian Installed
-If you're running on a headless server or don't have Obsidian installed (e.g., server environments, containers, or systems without a GUI), you can still use this CLI. Obsidian requires a GUI, so this section explains how to set up the required configuration manually.
+If you're running on a headless server or don't have Obsidian installed (e.g., server environments, containers, or systems without a GUI), you can still use this CLI. When Obsidian is installed, it registers vaults automatically. For headless environments, you register them via the CLI instead.
**Setup Instructions:**
+```bash
+# Register your vault directory
+notesmd-cli add-vault /home/user/vaults/my-brain
+
+# Set it as default
+notesmd-cli set-default-vault "my-brain"
+
+# Or do both in one step
+notesmd-cli add-vault /home/user/vaults/my-brain --set-default
+```
+
+For multiple vaults:
+```bash
+notesmd-cli add-vault /home/user/vaults/personal
+notesmd-cli add-vault /home/user/vaults/work
+notesmd-cli set-default-vault "personal"
+```
+
+You can then pass `--vault "work"` to target a specific vault.
+
+
+Manual setup (without CLI commands)
+
1. Create the Obsidian config directory:
```bash
mkdir -p ~/.config/obsidian
@@ -83,22 +140,9 @@ If you're running on a headless server or don't have Obsidian installed (e.g., s
}
}
```
- The key (`any-unique-id`) can be anything — the CLI uses the **directory name** as the vault name (e.g., `my-brain` above). Use the **absolute path** — do not use `~` as the CLI does not expand it to your home directory.
+ The key (`any-unique-id`) can be anything. The CLI uses the **directory name** as the vault name (e.g., `my-brain` above). Use the **absolute path**. Do not use `~` as the CLI does not expand it to your home directory.
- **Multiple vaults:**
- ```json
- {
- "vaults": {
- "vault-1": {
- "path": "/home/user/vaults/personal"
- },
- "vault-2": {
- "path": "/home/user/vaults/work"
- }
- }
- }
- ```
- You can then use `notesmd-cli set-default "personal"` or pass `--vault "work"` to target a specific vault.
+
---
@@ -145,66 +189,86 @@ notesmd-cli move "old.md" "new.md" --open --editor
To avoid passing `--editor` every time, configure it as the default open type once:
```bash
-notesmd-cli set-default --open-type editor
+notesmd-cli set-default-vault --open-type editor
```
-### Set Default Vault and Open Type
+### Add Vault
-Defines the default vault and/or open type for future usage. If no default vault is set, pass `--vault` with other commands to specify which vault to use.
+Registers a directory as an Obsidian vault. Creates the Obsidian config file (`~/.config/obsidian/obsidian.json`) if it does not exist. Alias: `av`
-```bash
-# Set default vault (vault name only, not the path)
-notesmd-cli set-default "{vault-name}"
+If you have Obsidian installed, vaults are registered automatically when you open them. You only need this command for headless setups or environments where Obsidian is not installed (servers, containers, CI).
-# Set default open type: 'obsidian' (default) or 'editor'
-notesmd-cli set-default --open-type editor
+```bash
+# Register a vault
+notesmd-cli add-vault /path/to/vault
-# Set both at once
-notesmd-cli set-default "{vault-name}" --open-type editor
+# Register and set as default
+notesmd-cli add-vault /path/to/vault --set-default
```
-When `default_open_type` is set to `editor`, commands that support `--open` will open notes in `$EDITOR` automatically, without needing to pass `--editor` each time.
+### Remove Vault
-Note: `open` and other commands in `notesmd-cli` use this vault's base directory as the working directory, not the current working directory of your terminal.
+Removes a vault from the Obsidian config. Does not delete any files on disk. If the removed vault was the default, the default is cleared. Alias: `rv`
-### Print Default Vault
+```bash
+# Remove by vault name
+notesmd-cli remove-vault "{vault-name}"
-Prints default vault and path. Please set this with `set-default` command if not set.
+# Remove by vault path
+notesmd-cli remove-vault /path/to/vault
+```
+
+### List Vaults
+
+Lists all registered Obsidian vaults. The default vault is marked with `(default)`. Alias: `lv`
```bash
-# print the default vault name and path
-notesmd-cli print-default
+# Lists all vaults (name and path, default marked)
+notesmd-cli list-vaults
-# print only the vault path
-notesmd-cli print-default --path-only
+# Outputs vaults as JSON
+notesmd-cli list-vaults --json
+
+# Outputs only vault paths (useful for scripting)
+notesmd-cli list-vaults --path-only
+
+# Show only the default vault (name, path, open type)
+notesmd-cli list-vaults --default
+
+# Get just the default vault path (useful for scripting)
+notesmd-cli list-vaults --default --path-only
```
You can add this to your shell configuration file (like `~/.zshrc`) to quickly navigate to the default vault:
```bash
obs_cd() {
- local result=$(notesmd-cli print-default --path-only)
+ local result=$(notesmd-cli list-vaults --default --path-only)
[ -n "$result" ] && cd -- "$result"
}
```
Then you can use `obs_cd` to navigate to the default vault directory within your terminal.
-### List Vaults
+### Set Default Vault and Open Type
-Lists all registered Obsidian vaults. Alias: `lv`
+Defines the default vault and/or open type for future usage. If no default vault is set, pass `--vault` with other commands to specify which vault to use.
```bash
-# Lists all vaults (name and path)
-notesmd-cli list-vaults
+# Set default vault (by name or path)
+notesmd-cli set-default-vault "{vault-name}"
-# Outputs vaults as JSON
-notesmd-cli list-vaults --json
+# Set default open type: 'obsidian' (default) or 'editor'
+notesmd-cli set-default-vault --open-type editor
-# Outputs only vault paths (useful for scripting)
-notesmd-cli list-vaults --path-only
+# Set both at once
+notesmd-cli set-default-vault "{vault-name}" --open-type editor
```
+When `default_open_type` is set to `editor`, commands that support `--open` will open notes in `$EDITOR` automatically, without needing to pass `--editor` each time.
+
+Note: `open` and other commands in `notesmd-cli` use this vault's base directory as the working directory, not the current working directory of your terminal.
+
### Open Note
Open given note name in Obsidian (or your default editor). Note can also be an absolute path from top level of vault.
@@ -227,7 +291,7 @@ notesmd-cli open "{note-name}" --editor
### Daily Note
-Creates or opens today's daily note directly on disk — **Obsidian does not need to be running**. If `.obsidian/daily-notes.json` exists in the vault, the CLI reads `folder`, `format` (Moment.js date format, default `YYYY-MM-DD`), and `template` from it. A template file's content is used when creating a new daily note. If the config is missing or unreadable, defaults are used (vault root, `YYYY-MM-DD`, no template).
+Creates or opens today's daily note directly on disk. **Obsidian does not need to be running**. If `.obsidian/daily-notes.json` exists in the vault, the CLI reads `folder`, `format` (Moment.js date format, default `YYYY-MM-DD`), and `template` from it. A template file's content is used when creating a new daily note. If the config is missing or unreadable, defaults are used (vault root, `YYYY-MM-DD`, no template).
```bash
# Creates / opens daily note in obsidian vault
@@ -318,7 +382,7 @@ notesmd-cli print "{note-name}" --vault "{vault-name}"
### Create / Update Note
-Creates a note (can also be a path with name) directly on disk — **Obsidian does not need to be running**. If the note already exists and neither `--overwrite` nor `--append` is passed, the file is left unchanged. Intermediate directories are created automatically.
+Creates a note (can also be a path with name) directly on disk. **Obsidian does not need to be running**. If the note already exists and neither `--overwrite` nor `--append` is passed, the file is left unchanged. Intermediate directories are created automatically.
When the note name has no explicit path (no `/`), the CLI reads `.obsidian/app.json` from the vault to check for a configured default folder (`newFileLocation: "folder"` and `newFileFolderPath`). If configured, the note is placed in that folder. If the config is missing or unreadable, the note is created at the vault root.
@@ -369,10 +433,10 @@ notesmd-cli move "{current-note-path}" "{new-note-path}" --open --editor
Deletes a given note (path from top level of vault).
```bash
-# Renames a note in default obsidian
+# Deletes a note in default vault
notesmd-cli delete "{note-path}"
-# Renames a note in given obsidian
+# Deletes a note in specified vault
notesmd-cli delete "{note-path}" --vault "{vault-name}"
```
@@ -394,12 +458,22 @@ notesmd-cli frontmatter "{note-name}" --delete --key "draft"
notesmd-cli frontmatter "{note-name}" --print --vault "{vault-name}"
```
+## Deprecated Commands
+
+The following commands still work but print a deprecation warning to stderr (so pipes and scripts are unaffected). They will be removed in the next major version.
+
+| Old command | Replacement |
+|---|---|
+| `set-default` | `set-default-vault` |
+| `print-default` | `list-vaults --default` |
+| `print-default --path-only` | `list-vaults --default --path-only` |
+
## Excluded Files
-The CLI respects Obsidian's **Excluded Files** setting (`Settings → Files & Links → Excluded Files`).
+The CLI respects Obsidian's **Excluded Files** setting (`Settings > Files & Links > Excluded Files`).
-- `search` — excluded notes won't appear in the fuzzy finder
-- `search-content` — excluded folders won't be searched
+- `search` - excluded notes won't appear in the fuzzy finder
+- `search-content` - excluded folders won't be searched
All other commands (`open`, `move`, `print`, `frontmatter`, etc.) still access excluded files as they refer to notes by name.
diff --git a/cmd/add_vault.go b/cmd/add_vault.go
new file mode 100644
index 0000000..1bd59be
--- /dev/null
+++ b/cmd/add_vault.go
@@ -0,0 +1,41 @@
+package cmd
+
+import (
+ "fmt"
+ "log"
+ "path/filepath"
+
+ "github.com/Yakitrak/notesmd-cli/pkg/obsidian"
+ "github.com/spf13/cobra"
+)
+
+var addVaultCmd = &cobra.Command{
+ Use: "add-vault ",
+ Aliases: []string{"av"},
+ Short: "Register a vault directory",
+ Long: "Registers a directory as an Obsidian vault. Creates the Obsidian config file if it does not exist.",
+ Args: cobra.ExactArgs(1),
+ Run: func(cmd *cobra.Command, args []string) {
+ absPath, err := obsidian.AddVault(args[0])
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ name := filepath.Base(absPath)
+ fmt.Printf("Vault %q registered at: %s\n", name, absPath)
+
+ setDefault, _ := cmd.Flags().GetBool("set-default")
+ if setDefault {
+ v := obsidian.Vault{Name: name}
+ if err := v.SetDefaultName(name); err != nil {
+ log.Fatal(err)
+ }
+ fmt.Println("Default vault set to:", name)
+ }
+ },
+}
+
+func init() {
+ addVaultCmd.Flags().Bool("set-default", false, "set the added vault as the default")
+ rootCmd.AddCommand(addVaultCmd)
+}
diff --git a/cmd/list_vaults.go b/cmd/list_vaults.go
index 785e561..fb957b8 100644
--- a/cmd/list_vaults.go
+++ b/cmd/list_vaults.go
@@ -15,6 +15,7 @@ import (
var listVaultsJSON bool
var listVaultsPathOnly bool
+var listVaultsDefault bool
var listVaultsCmd = &cobra.Command{
Use: "list-vaults",
@@ -27,6 +28,18 @@ var listVaultsCmd = &cobra.Command{
log.Fatal(err)
}
+ defaultName := resolveDefaultVaultName()
+
+ if listVaultsDefault {
+ runListVaultsDefault(vaults, defaultName)
+ return
+ }
+
+ if len(vaults) == 0 {
+ fmt.Println("No vaults registered. Use add-vault to register one.")
+ return
+ }
+
sort.Slice(vaults, func(i, j int) bool {
return vaults[i].Name < vaults[j].Name
})
@@ -45,30 +58,86 @@ var listVaultsCmd = &cobra.Command{
fmt.Println(v.Path)
}
} else {
- formatVaultsTable(os.Stdout, vaults)
+ formatVaultsTable(os.Stdout, vaults, defaultName)
}
},
}
+func runListVaultsDefault(vaults []obsidian.VaultInfo, defaultName string) {
+ if defaultName == "" {
+ fmt.Println("No default vault set. Use set-default-vault to set one.")
+ return
+ }
+
+ var defaultVault *obsidian.VaultInfo
+ for _, v := range vaults {
+ if v.Name == defaultName {
+ defaultVault = &v
+ break
+ }
+ }
+
+ if defaultVault == nil {
+ fmt.Printf("Default vault %q is set but not found in registered vaults.\n", defaultName)
+ return
+ }
+
+ if listVaultsJSON {
+ output, err := json.MarshalIndent(defaultVault, "", " ")
+ if err != nil {
+ log.Fatal(err)
+ }
+ fmt.Println(string(output))
+ return
+ }
+
+ if listVaultsPathOnly {
+ fmt.Println(defaultVault.Path)
+ return
+ }
+
+ vault := obsidian.Vault{Name: defaultName}
+ openType, _ := vault.DefaultOpenType()
+
+ fmt.Println("Default vault name:", defaultVault.Name)
+ fmt.Println("Default vault path:", defaultVault.Path)
+ fmt.Println("Default open type:", openType)
+}
+
// formatVaultsTable writes vaults as aligned columns using tabwriter,
// so that the path column lines up regardless of vault name length.
+// The default vault is marked with (default).
//
// Example output:
//
-// Notes /home/user/Notes
+// Notes /home/user/Notes (default)
// LongVaultName /home/user/LongVaultName
// Work /home/user/Work
-func formatVaultsTable(w io.Writer, vaults []obsidian.VaultInfo) {
+func formatVaultsTable(w io.Writer, vaults []obsidian.VaultInfo, defaultName string) {
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
for _, v := range vaults {
- _, _ = fmt.Fprintf(tw, "%s\t%s\n", v.Name, v.Path)
+ if v.Name == defaultName {
+ _, _ = fmt.Fprintf(tw, "%s\t%s\t(default)\n", v.Name, v.Path)
+ } else {
+ _, _ = fmt.Fprintf(tw, "%s\t%s\n", v.Name, v.Path)
+ }
}
_ = tw.Flush()
}
+func resolveDefaultVaultName() string {
+ vault := obsidian.Vault{}
+ name, err := vault.DefaultName()
+ if err != nil {
+ return ""
+ }
+ return name
+}
+
func init() {
listVaultsCmd.Flags().BoolVar(&listVaultsJSON, "json", false, "output as JSON array")
listVaultsCmd.Flags().BoolVar(&listVaultsPathOnly, "path-only", false, "output one path per line")
+ listVaultsCmd.Flags().BoolVar(&listVaultsDefault, "default", false, "show only the default vault")
listVaultsCmd.MarkFlagsMutuallyExclusive("json", "path-only")
rootCmd.AddCommand(listVaultsCmd)
}
diff --git a/cmd/list_vaults_test.go b/cmd/list_vaults_test.go
index 4ee581a..8527881 100644
--- a/cmd/list_vaults_test.go
+++ b/cmd/list_vaults_test.go
@@ -17,7 +17,7 @@ func TestFormatVaultsTable(t *testing.T) {
}
var buf bytes.Buffer
- formatVaultsTable(&buf, vaults)
+ formatVaultsTable(&buf, vaults, "")
output := buf.String()
// All path columns should start at the same position
@@ -46,16 +46,35 @@ func TestFormatVaultsTable(t *testing.T) {
}
var buf bytes.Buffer
- formatVaultsTable(&buf, vaults)
+ formatVaultsTable(&buf, vaults, "")
output := buf.String()
assert.Contains(t, output, "MyVault")
assert.Contains(t, output, "/tmp/MyVault")
})
+ t.Run("Marks default vault", func(t *testing.T) {
+ vaults := []obsidian.VaultInfo{
+ {Name: "Notes", Path: "/home/user/Notes"},
+ {Name: "Work", Path: "/home/user/Work"},
+ }
+
+ var buf bytes.Buffer
+ formatVaultsTable(&buf, vaults, "Work")
+ output := buf.String()
+
+ assert.Contains(t, output, "Work")
+ assert.Contains(t, output, "(default)")
+ // Notes line should not have (default)
+ lines := bytes.Split(bytes.TrimSpace([]byte(output)), []byte("\n"))
+ assert.Len(t, lines, 2)
+ assert.NotContains(t, string(lines[0]), "(default)")
+ assert.Contains(t, string(lines[1]), "(default)")
+ })
+
t.Run("Empty vault list produces no output", func(t *testing.T) {
var buf bytes.Buffer
- formatVaultsTable(&buf, []obsidian.VaultInfo{})
+ formatVaultsTable(&buf, []obsidian.VaultInfo{}, "")
assert.Empty(t, buf.String())
})
diff --git a/cmd/print_default.go b/cmd/print_default.go
index 72b003d..6b856dd 100644
--- a/cmd/print_default.go
+++ b/cmd/print_default.go
@@ -8,13 +8,15 @@ import (
"github.com/spf13/cobra"
)
-var printPathOnly bool
-var printDefaultCmd = &cobra.Command{
- Use: "print-default",
- Aliases: []string{"pd"},
- Short: "prints default vault name and path",
- Args: cobra.ExactArgs(0),
+var printDefaultDeprecatedCmd = &cobra.Command{
+ Use: "print-default",
+ Aliases: []string{"pd"},
+ Short: "prints default vault name and path (deprecated: use list-vaults --default)",
+ Args: cobra.ExactArgs(0),
+ Deprecated: "use list-vaults --default instead",
Run: func(cmd *cobra.Command, args []string) {
+ pathOnly, _ := cmd.Flags().GetBool("path-only")
+
vault := obsidian.Vault{}
name, err := vault.DefaultName()
if err != nil {
@@ -25,7 +27,7 @@ var printDefaultCmd = &cobra.Command{
log.Fatal(err)
}
- if printPathOnly {
+ if pathOnly {
fmt.Print(path)
return
}
@@ -39,6 +41,6 @@ var printDefaultCmd = &cobra.Command{
}
func init() {
- printDefaultCmd.Flags().BoolVar(&printPathOnly, "path-only", false, "print only the vault path")
- rootCmd.AddCommand(printDefaultCmd)
+ printDefaultDeprecatedCmd.Flags().Bool("path-only", false, "print only the vault path")
+ rootCmd.AddCommand(printDefaultDeprecatedCmd)
}
diff --git a/cmd/remove_vault.go b/cmd/remove_vault.go
new file mode 100644
index 0000000..1c2225c
--- /dev/null
+++ b/cmd/remove_vault.go
@@ -0,0 +1,36 @@
+package cmd
+
+import (
+ "fmt"
+ "log"
+ "os"
+
+ "github.com/Yakitrak/notesmd-cli/pkg/obsidian"
+ "github.com/spf13/cobra"
+)
+
+var removeVaultCmd = &cobra.Command{
+ Use: "remove-vault ",
+ Aliases: []string{"rv"},
+ Short: "Unregister a vault",
+ Long: "Removes a vault from the Obsidian config. Does not delete any files on disk.",
+ Args: cobra.ExactArgs(1),
+ Run: func(cmd *cobra.Command, args []string) {
+ input := args[0]
+
+ name, err := obsidian.RemoveVault(input)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ fmt.Printf("Vault %q removed\n", name)
+
+ if err := obsidian.ClearDefaultIfMatch(name); err != nil {
+ fmt.Fprintln(os.Stderr, "Warning: could not clear default vault:", err)
+ }
+ },
+}
+
+func init() {
+ rootCmd.AddCommand(removeVaultCmd)
+}
diff --git a/cmd/set_default.go b/cmd/set_default.go
index cb41b64..a9ffd21 100644
--- a/cmd/set_default.go
+++ b/cmd/set_default.go
@@ -9,55 +9,67 @@ import (
"github.com/spf13/cobra"
)
-var setDefaultCmd = &cobra.Command{
- Use: "set-default",
- Aliases: []string{"sd"},
- Short: "Sets default vault and/or open type",
- Args: cobra.RangeArgs(0, 1),
- Run: func(cmd *cobra.Command, args []string) {
- openType, err := cmd.Flags().GetString("open-type")
+func runSetDefaultVault(cmd *cobra.Command, args []string) {
+ openType, err := cmd.Flags().GetString("open-type")
+ if err != nil {
+ log.Fatalf("Failed to parse --open-type flag: %v", err)
+ }
+
+ if len(args) == 0 && openType == "" {
+ log.Fatal("Please provide a vault name or use --open-type to set the default open type")
+ }
+
+ if len(args) > 0 {
+ name, err := obsidian.ResolveVaultName(args[0])
if err != nil {
- log.Fatalf("Failed to parse --open-type flag: %v", err)
+ log.Fatal(err)
}
-
- if len(args) == 0 && openType == "" {
- log.Fatal("Please provide a vault name or use --open-type to set the default open type")
+ v := obsidian.Vault{Name: name}
+ if err := v.SetDefaultName(name); err != nil {
+ log.Fatal(err)
}
-
- if len(args) > 0 {
- name, err := obsidian.ResolveVaultName(args[0])
- if err != nil {
- log.Fatal(err)
- }
- v := obsidian.Vault{Name: name}
- if err := v.SetDefaultName(name); err != nil {
- log.Fatal(err)
- }
- fmt.Println("Default vault set to:", name)
- path, err := v.Path()
- if err != nil {
- // Path resolution is best-effort: the name is saved; Obsidian's
- // config file may not be present or may not contain this vault yet.
- fmt.Fprintln(os.Stderr, "Note: could not resolve vault path:", err)
- } else {
- fmt.Println("Default vault path set to:", path)
- }
+ fmt.Println("Default vault set to:", name)
+ path, err := v.Path()
+ if err != nil {
+ // Path resolution is best-effort: the name is saved; Obsidian's
+ // config file may not be present or may not contain this vault yet.
+ fmt.Fprintln(os.Stderr, "Note: could not resolve vault path:", err)
+ } else {
+ fmt.Println("Default vault path set to:", path)
}
+ }
- if openType != "" {
- if openType != "obsidian" && openType != "editor" {
- log.Fatalf("Invalid open type %q: must be 'obsidian' or 'editor'", openType)
- }
- v := obsidian.Vault{}
- if err := v.SetDefaultOpenType(openType); err != nil {
- log.Fatal(err)
- }
- fmt.Println("Default open type set to:", openType)
+ if openType != "" {
+ if openType != "obsidian" && openType != "editor" {
+ log.Fatalf("Invalid open type %q: must be 'obsidian' or 'editor'", openType)
}
- },
+ v := obsidian.Vault{}
+ if err := v.SetDefaultOpenType(openType); err != nil {
+ log.Fatal(err)
+ }
+ fmt.Println("Default open type set to:", openType)
+ }
+}
+
+var setDefaultVaultCmd = &cobra.Command{
+ Use: "set-default-vault",
+ Aliases: []string{"sd"},
+ Short: "Sets default vault and/or open type",
+ Args: cobra.RangeArgs(0, 1),
+ Run: runSetDefaultVault,
+}
+
+var setDefaultDeprecatedCmd = &cobra.Command{
+ Use: "set-default",
+ Short: "Sets default vault and/or open type (deprecated: use set-default-vault)",
+ Args: cobra.RangeArgs(0, 1),
+ Deprecated: "use set-default-vault instead",
+ Run: runSetDefaultVault,
}
func init() {
- setDefaultCmd.Flags().String("open-type", "", "default open type: 'obsidian' (default) or 'editor'")
- rootCmd.AddCommand(setDefaultCmd)
+ setDefaultVaultCmd.Flags().String("open-type", "", "default open type: 'obsidian' (default) or 'editor'")
+ setDefaultDeprecatedCmd.Flags().String("open-type", "", "default open type: 'obsidian' (default) or 'editor'")
+ rootCmd.AddCommand(setDefaultVaultCmd)
+ rootCmd.AddCommand(setDefaultDeprecatedCmd)
}
diff --git a/pkg/obsidian/constants.go b/pkg/obsidian/constants.go
index 65c9c24..56e5804 100644
--- a/pkg/obsidian/constants.go
+++ b/pkg/obsidian/constants.go
@@ -6,8 +6,8 @@ const (
VaultAccessError = "Failed to access vault directory"
VaultReadError = "Failed to read notes in vault"
VaultWriteError = "Failed to write to update notes in vault"
- ObsidianCLIConfigReadError = "Cannot find vault config, please use set-default command to set default vault or use --vault flag"
- ObsidianCLIConfigParseError = "Could not parse vault config file, please use set-default command to set default vault or use --vault flag"
+ ObsidianCLIConfigReadError = "Cannot find vault config, please use set-default-vault command to set default vault or use --vault flag"
+ ObsidianCLIConfigParseError = "Could not parse vault config file, please use set-default-vault command to set default vault or use --vault flag"
ObsidianCLIConfigDirWriteEror = "Failed to create vault config directory. Please ensure you have the correct permissions."
ObsidianCLIConfigGenerateJSONError = "Failed to generate vault config file. Please ensure vault name does not contain any special characters."
ObsidianCLIConfigWriteError = "Failed to write vault config file. Please ensure you have correct permissions."
diff --git a/pkg/obsidian/vault_registry.go b/pkg/obsidian/vault_registry.go
new file mode 100644
index 0000000..457a993
--- /dev/null
+++ b/pkg/obsidian/vault_registry.go
@@ -0,0 +1,202 @@
+package obsidian
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/Yakitrak/notesmd-cli/pkg/config"
+)
+
+// AddVault registers a vault path in the Obsidian config file.
+// It creates the config file and directory if they don't exist.
+// Returns the resolved absolute path on success.
+func AddVault(vaultPath string) (string, error) {
+ absPath, err := filepath.Abs(vaultPath)
+ if err != nil {
+ return "", fmt.Errorf("failed to resolve path: %w", err)
+ }
+
+ info, err := os.Stat(absPath)
+ if err != nil {
+ return "", fmt.Errorf("path does not exist: %s", absPath)
+ }
+ if !info.IsDir() {
+ return "", fmt.Errorf("path is not a directory: %s", absPath)
+ }
+
+ obsidianConfigFile, vaultsConfig, err := readOrCreateObsidianConfig()
+ if err != nil {
+ return "", err
+ }
+
+ // Check if vault already registered
+ for _, v := range vaultsConfig.Vaults {
+ if filepath.Clean(v.Path) == filepath.Clean(absPath) {
+ return "", fmt.Errorf("vault already registered: %s", absPath)
+ }
+ }
+
+ id, err := generateVaultID()
+ if err != nil {
+ return "", fmt.Errorf("failed to generate vault ID: %w", err)
+ }
+
+ vaultsConfig.Vaults[id] = struct {
+ Path string `json:"path"`
+ }{Path: absPath}
+
+ return absPath, writeObsidianConfig(obsidianConfigFile, vaultsConfig)
+}
+
+// RemoveVault removes a vault from the Obsidian config file by name or path.
+// Returns the resolved vault name (directory basename) so callers can use it
+// for follow-up operations like clearing the default.
+func RemoveVault(input string) (string, error) {
+ obsidianConfigFile, err := ObsidianConfigFile()
+ if err != nil {
+ return "", errors.New(ObsidianConfigReadError)
+ }
+
+ content, err := os.ReadFile(obsidianConfigFile)
+ if err != nil {
+ return "", errors.New(ObsidianConfigReadError)
+ }
+
+ vaultsConfig := ObsidianVaultConfig{}
+ if json.Unmarshal(content, &vaultsConfig) != nil {
+ return "", errors.New(ObsidianConfigParseError)
+ }
+
+ // Exact path match (only when input looks like a path, not a bare name)
+ if filepath.IsAbs(input) || strings.Contains(input, string(filepath.Separator)) || strings.HasPrefix(input, ".") {
+ absInput, _ := filepath.Abs(input)
+ for id, v := range vaultsConfig.Vaults {
+ if filepath.Clean(v.Path) == filepath.Clean(absInput) {
+ name := filepath.Base(v.Path)
+ delete(vaultsConfig.Vaults, id)
+ return name, writeObsidianConfig(obsidianConfigFile, vaultsConfig)
+ }
+ }
+ }
+
+ // Name match -- collect all matches to detect ambiguity
+ type match struct {
+ id string
+ path string
+ }
+ var matches []match
+ for id, v := range vaultsConfig.Vaults {
+ if filepath.Base(v.Path) == input {
+ matches = append(matches, match{id: id, path: v.Path})
+ }
+ }
+
+ if len(matches) == 0 {
+ return "", fmt.Errorf("vault %q not found", input)
+ }
+ if len(matches) > 1 {
+ var paths []string
+ for _, m := range matches {
+ paths = append(paths, fmt.Sprintf(" %s", m.path))
+ }
+ return "", fmt.Errorf(
+ "multiple vaults named %q found. Use the full path to disambiguate:\n%s",
+ input, strings.Join(paths, "\n"),
+ )
+ }
+
+ delete(vaultsConfig.Vaults, matches[0].id)
+ return input, writeObsidianConfig(obsidianConfigFile, vaultsConfig)
+}
+
+// ClearDefaultIfMatch clears the default vault in CLI config if it matches the given name.
+func ClearDefaultIfMatch(name string) error {
+ _, cliConfigFile, err := CliConfigPath()
+ if err != nil {
+ return nil //nolint:nilerr // no config dir means nothing to clear
+ }
+
+ content, err := os.ReadFile(cliConfigFile)
+ if err != nil {
+ return nil //nolint:nilerr // no config file means nothing to clear
+ }
+
+ cliConfig := CliConfig{}
+ if err := json.Unmarshal(content, &cliConfig); err != nil {
+ return nil //nolint:nilerr // unparseable config means nothing to clear
+ }
+
+ if cliConfig.DefaultVaultName != name {
+ return nil
+ }
+
+ v := &Vault{}
+ return v.SetDefaultName("")
+}
+
+func readOrCreateObsidianConfig() (string, ObsidianVaultConfig, error) {
+ empty := ObsidianVaultConfig{
+ Vaults: make(map[string]struct {
+ Path string `json:"path"`
+ }),
+ }
+
+ // Try to find existing config
+ obsidianConfigFile, err := ObsidianConfigFile()
+ if err == nil {
+ content, readErr := os.ReadFile(obsidianConfigFile)
+ if readErr == nil {
+ vaultsConfig := ObsidianVaultConfig{}
+ if err := json.Unmarshal(content, &vaultsConfig); err != nil {
+ return "", empty, fmt.Errorf("corrupt obsidian config at %s: %w", obsidianConfigFile, err)
+ }
+ if vaultsConfig.Vaults == nil {
+ vaultsConfig.Vaults = make(map[string]struct {
+ Path string `json:"path"`
+ })
+ }
+ return obsidianConfigFile, vaultsConfig, nil
+ }
+ }
+
+ // Config doesn't exist, create it
+ userConfigDir, err := config.UserConfigDirectory()
+ if err != nil {
+ return "", empty, fmt.Errorf("failed to determine config directory: %w", err)
+ }
+
+ configDir := filepath.Join(userConfigDir, config.ObsidianConfigDirectory)
+ if err := os.MkdirAll(configDir, os.ModePerm); err != nil {
+ return "", empty, fmt.Errorf("failed to create config directory: %w", err)
+ }
+
+ configFile := filepath.Join(configDir, config.ObsidianConfigFile)
+ return configFile, empty, nil
+}
+
+func writeObsidianConfig(path string, cfg ObsidianVaultConfig) error {
+ data, err := json.MarshalIndent(cfg, "", " ")
+ if err != nil {
+ return fmt.Errorf("failed to marshal config: %w", err)
+ }
+
+ if err := os.WriteFile(path, data, 0644); err != nil {
+ return fmt.Errorf("failed to write config file: %w", err)
+ }
+
+ return nil
+}
+
+func generateVaultID() (string, error) {
+ b := make([]byte, 16)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(b), nil
+}
diff --git a/pkg/obsidian/vault_registry_test.go b/pkg/obsidian/vault_registry_test.go
new file mode 100644
index 0000000..ccbc97d
--- /dev/null
+++ b/pkg/obsidian/vault_registry_test.go
@@ -0,0 +1,222 @@
+package obsidian_test
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/Yakitrak/notesmd-cli/mocks"
+ "github.com/Yakitrak/notesmd-cli/pkg/obsidian"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAddVault(t *testing.T) {
+ originalObsidianConfigFile := obsidian.ObsidianConfigFile
+ originalRunningInWSL := obsidian.RunningInWSL
+ defer func() {
+ obsidian.ObsidianConfigFile = originalObsidianConfigFile
+ obsidian.RunningInWSL = originalRunningInWSL
+ }()
+
+ obsidian.RunningInWSL = func() bool { return false }
+
+ t.Run("Adds vault to existing config", func(t *testing.T) {
+ mockObsidianConfigFile := mocks.CreateMockObsidianConfigFile(t)
+ obsidian.ObsidianConfigFile = func() (string, error) {
+ return mockObsidianConfigFile, nil
+ }
+
+ err := os.WriteFile(mockObsidianConfigFile, []byte(`{"vaults":{}}`), 0644)
+ assert.NoError(t, err)
+
+ vaultDir := t.TempDir()
+ absPath, err := obsidian.AddVault(vaultDir)
+ assert.NoError(t, err)
+ assert.Equal(t, vaultDir, absPath)
+
+ // Verify vault was added
+ content, err := os.ReadFile(mockObsidianConfigFile)
+ assert.NoError(t, err)
+
+ var cfg obsidian.ObsidianVaultConfig
+ err = json.Unmarshal(content, &cfg)
+ assert.NoError(t, err)
+ assert.Len(t, cfg.Vaults, 1)
+
+ for _, v := range cfg.Vaults {
+ assert.Equal(t, vaultDir, v.Path)
+ }
+ })
+
+ t.Run("Rejects non-existent path", func(t *testing.T) {
+ mockObsidianConfigFile := mocks.CreateMockObsidianConfigFile(t)
+ obsidian.ObsidianConfigFile = func() (string, error) {
+ return mockObsidianConfigFile, nil
+ }
+
+ err := os.WriteFile(mockObsidianConfigFile, []byte(`{"vaults":{}}`), 0644)
+ assert.NoError(t, err)
+
+ _, err = obsidian.AddVault("/nonexistent/path/to/vault")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "path does not exist")
+ })
+
+ t.Run("Rejects file path", func(t *testing.T) {
+ mockObsidianConfigFile := mocks.CreateMockObsidianConfigFile(t)
+ obsidian.ObsidianConfigFile = func() (string, error) {
+ return mockObsidianConfigFile, nil
+ }
+
+ err := os.WriteFile(mockObsidianConfigFile, []byte(`{"vaults":{}}`), 0644)
+ assert.NoError(t, err)
+
+ tmpFile := filepath.Join(t.TempDir(), "file.txt")
+ err = os.WriteFile(tmpFile, []byte("test"), 0644)
+ assert.NoError(t, err)
+
+ _, err = obsidian.AddVault(tmpFile)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not a directory")
+ })
+
+ t.Run("Rejects duplicate vault", func(t *testing.T) {
+ mockObsidianConfigFile := mocks.CreateMockObsidianConfigFile(t)
+ obsidian.ObsidianConfigFile = func() (string, error) {
+ return mockObsidianConfigFile, nil
+ }
+
+ vaultDir := t.TempDir()
+ configContent := `{
+ "vaults": {
+ "abc123": {
+ "path": "` + vaultDir + `"
+ }
+ }
+ }`
+ err := os.WriteFile(mockObsidianConfigFile, []byte(configContent), 0644)
+ assert.NoError(t, err)
+
+ _, err = obsidian.AddVault(vaultDir)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "already registered")
+ })
+
+ t.Run("Adds multiple vaults", func(t *testing.T) {
+ mockObsidianConfigFile := mocks.CreateMockObsidianConfigFile(t)
+ obsidian.ObsidianConfigFile = func() (string, error) {
+ return mockObsidianConfigFile, nil
+ }
+
+ err := os.WriteFile(mockObsidianConfigFile, []byte(`{"vaults":{}}`), 0644)
+ assert.NoError(t, err)
+
+ vault1 := t.TempDir()
+ vault2 := t.TempDir()
+
+ _, err = obsidian.AddVault(vault1)
+ assert.NoError(t, err)
+
+ _, err = obsidian.AddVault(vault2)
+ assert.NoError(t, err)
+
+ content, err := os.ReadFile(mockObsidianConfigFile)
+ assert.NoError(t, err)
+
+ var cfg obsidian.ObsidianVaultConfig
+ err = json.Unmarshal(content, &cfg)
+ assert.NoError(t, err)
+ assert.Len(t, cfg.Vaults, 2)
+ })
+}
+
+func TestRemoveVault(t *testing.T) {
+ originalObsidianConfigFile := obsidian.ObsidianConfigFile
+ originalRunningInWSL := obsidian.RunningInWSL
+ defer func() {
+ obsidian.ObsidianConfigFile = originalObsidianConfigFile
+ obsidian.RunningInWSL = originalRunningInWSL
+ }()
+
+ obsidian.RunningInWSL = func() bool { return false }
+
+ t.Run("Removes vault by name", func(t *testing.T) {
+ mockObsidianConfigFile := mocks.CreateMockObsidianConfigFile(t)
+ obsidian.ObsidianConfigFile = func() (string, error) {
+ return mockObsidianConfigFile, nil
+ }
+
+ configContent := `{
+ "vaults": {
+ "abc123": {
+ "path": "/Users/user/Documents/Personal"
+ },
+ "def456": {
+ "path": "/Users/user/Documents/Work"
+ }
+ }
+ }`
+ err := os.WriteFile(mockObsidianConfigFile, []byte(configContent), 0644)
+ assert.NoError(t, err)
+
+ name, err := obsidian.RemoveVault("Personal")
+ assert.NoError(t, err)
+ assert.Equal(t, "Personal", name)
+
+ content, err := os.ReadFile(mockObsidianConfigFile)
+ assert.NoError(t, err)
+
+ var cfg obsidian.ObsidianVaultConfig
+ err = json.Unmarshal(content, &cfg)
+ assert.NoError(t, err)
+ assert.Len(t, cfg.Vaults, 1)
+
+ for _, v := range cfg.Vaults {
+ assert.Equal(t, "/Users/user/Documents/Work", v.Path)
+ }
+ })
+
+ t.Run("Removes vault by path and returns resolved name", func(t *testing.T) {
+ mockObsidianConfigFile := mocks.CreateMockObsidianConfigFile(t)
+ obsidian.ObsidianConfigFile = func() (string, error) {
+ return mockObsidianConfigFile, nil
+ }
+
+ configContent := `{
+ "vaults": {
+ "abc123": {
+ "path": "/Users/user/Documents/Personal"
+ }
+ }
+ }`
+ err := os.WriteFile(mockObsidianConfigFile, []byte(configContent), 0644)
+ assert.NoError(t, err)
+
+ name, err := obsidian.RemoveVault("/Users/user/Documents/Personal")
+ assert.NoError(t, err)
+ assert.Equal(t, "Personal", name)
+
+ content, err := os.ReadFile(mockObsidianConfigFile)
+ assert.NoError(t, err)
+
+ var cfg obsidian.ObsidianVaultConfig
+ err = json.Unmarshal(content, &cfg)
+ assert.NoError(t, err)
+ assert.Len(t, cfg.Vaults, 0)
+ })
+
+ t.Run("Returns error for non-existent vault", func(t *testing.T) {
+ mockObsidianConfigFile := mocks.CreateMockObsidianConfigFile(t)
+ obsidian.ObsidianConfigFile = func() (string, error) {
+ return mockObsidianConfigFile, nil
+ }
+
+ err := os.WriteFile(mockObsidianConfigFile, []byte(`{"vaults":{}}`), 0644)
+ assert.NoError(t, err)
+
+ _, err = obsidian.RemoveVault("NonExistent")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not found")
+ })
+}