Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,21 @@ obs_cd() {

Then you can use `obs_cd` to navigate to the default vault directory within your terminal.

### List Vaults

Lists all registered Obsidian vaults. Alias: `lv`

```bash
# Lists all vaults (name and path)
notesmd-cli list-vaults

# Outputs vaults as JSON
notesmd-cli list-vaults --json

# Outputs only vault paths (useful for scripting)
notesmd-cli list-vaults --path-only
```

### Open Note

Open given note name in Obsidian (or your default editor). Note can also be an absolute path from top level of vault.
Expand Down
74 changes: 74 additions & 0 deletions cmd/list_vaults.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package cmd

import (
"encoding/json"
"fmt"
"io"
"log"
"os"
"sort"
"text/tabwriter"

"github.com/Yakitrak/notesmd-cli/pkg/obsidian"
"github.com/spf13/cobra"
)

var listVaultsJSON bool
var listVaultsPathOnly bool

var listVaultsCmd = &cobra.Command{
Use: "list-vaults",
Aliases: []string{"lv"},
Short: "lists all registered Obsidian vaults",
Args: cobra.ExactArgs(0),
Run: func(cmd *cobra.Command, args []string) {
vaults, err := obsidian.ListVaults()
if err != nil {
log.Fatal(err)
}

sort.Slice(vaults, func(i, j int) bool {
return vaults[i].Name < vaults[j].Name
})

if listVaultsJSON {
output, err := json.MarshalIndent(vaults, "", " ")
if err != nil {
log.Fatal(err)
}
fmt.Println(string(output))
return
}

if listVaultsPathOnly {
for _, v := range vaults {
fmt.Println(v.Path)
}
} else {
formatVaultsTable(os.Stdout, vaults)
}
},
}

// formatVaultsTable writes vaults as aligned columns using tabwriter,
// so that the path column lines up regardless of vault name length.
//
// Example output:
//
// Notes /home/user/Notes
// LongVaultName /home/user/LongVaultName
// Work /home/user/Work
func formatVaultsTable(w io.Writer, vaults []obsidian.VaultInfo) {
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
for _, v := range vaults {
fmt.Fprintf(tw, "%s\t%s\n", v.Name, v.Path)
}
tw.Flush()
}

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.MarkFlagsMutuallyExclusive("json", "path-only")
rootCmd.AddCommand(listVaultsCmd)
}
62 changes: 62 additions & 0 deletions cmd/list_vaults_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package cmd

import (
"bytes"
"testing"

"github.com/Yakitrak/notesmd-cli/pkg/obsidian"
"github.com/stretchr/testify/assert"
)

func TestFormatVaultsTable(t *testing.T) {
t.Run("Aligns columns with varying name lengths", func(t *testing.T) {
vaults := []obsidian.VaultInfo{
{Name: "Notes", Path: "/home/user/Notes"},
{Name: "LongVaultName", Path: "/home/user/LongVaultName"},
{Name: "Work", Path: "/home/user/Work"},
}

var buf bytes.Buffer
formatVaultsTable(&buf, vaults)
output := buf.String()

// All path columns should start at the same position
lines := bytes.Split(bytes.TrimSpace([]byte(output)), []byte("\n"))
assert.Len(t, lines, 3)

// Each line should contain both name and path
assert.Contains(t, output, "Notes")
assert.Contains(t, output, "/home/user/Notes")
assert.Contains(t, output, "LongVaultName")
assert.Contains(t, output, "/home/user/LongVaultName")

// Paths should be aligned — find the byte offset of each path
// With tabwriter, the path column should start at the same position
pathOffsets := make([]int, len(lines))
for i, line := range lines {
pathOffsets[i] = bytes.Index(line, []byte("/home"))
}
assert.Equal(t, pathOffsets[0], pathOffsets[1], "path columns should be aligned")
assert.Equal(t, pathOffsets[1], pathOffsets[2], "path columns should be aligned")
})

t.Run("Single vault produces output", func(t *testing.T) {
vaults := []obsidian.VaultInfo{
{Name: "MyVault", Path: "/tmp/MyVault"},
}

var buf bytes.Buffer
formatVaultsTable(&buf, vaults)
output := buf.String()

assert.Contains(t, output, "MyVault")
assert.Contains(t, output, "/tmp/MyVault")
})

t.Run("Empty vault list produces no output", func(t *testing.T) {
var buf bytes.Buffer
formatVaultsTable(&buf, []obsidian.VaultInfo{})

assert.Empty(t, buf.String())
})
}
5 changes: 4 additions & 1 deletion cmd/set_default.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ var setDefaultCmd = &cobra.Command{
}

if len(args) > 0 {
name := 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)
Expand Down
7 changes: 7 additions & 0 deletions pkg/config/obsidian_path.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ func ObsidianFile() (obsidianConfigFile string, err error) {
candidatePaths = append(candidatePaths,
filepath.Join(homeDir, "snap", "obsidian", "current", ".config", "obsidian", ObsidianConfigFile))

// Also check numbered snap subdirectories (e.g. ~/snap/obsidian/x1/.config/obsidian/)
// which exist when the "current" symlink is missing or multiple snap versions are installed.
snapNumberedPattern := filepath.Join(homeDir, "snap", "obsidian", "*", ".config", "obsidian", ObsidianConfigFile)
if matches, globErr := filepath.Glob(snapNumberedPattern); globErr == nil {
candidatePaths = append(candidatePaths, matches...)
}

var firstNonExistErr error
for _, path := range candidatePaths {
if _, statErr := os.Stat(path); statErr == nil {
Expand Down
30 changes: 30 additions & 0 deletions pkg/config/obsidian_path_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,36 @@ func TestConfigObsidianPath(t *testing.T) {
assert.Equal(t, nativeConfigFile, obsConfigFile)
})

t.Run("Finds numbered Snap config on Linux when current symlink does not exist", func(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("Snap test only runs on Linux")
}

originalUserConfigDir := config.UserConfigDirectory
defer func() { config.UserConfigDirectory = originalUserConfigDir }()

tempDir := t.TempDir()
snapDir := filepath.Join(tempDir, "snap", "obsidian", "x1", ".config", "obsidian")
err := os.MkdirAll(snapDir, 0755)
assert.NoError(t, err)

snapConfigFile := filepath.Join(snapDir, "obsidian.json")
err = os.WriteFile(snapConfigFile, []byte(`{"vaults":{}}`), 0644)
assert.NoError(t, err)

origHome := os.Getenv("HOME")
defer os.Setenv("HOME", origHome)
os.Setenv("HOME", tempDir)

config.UserConfigDirectory = func() (string, error) {
return filepath.Join(tempDir, ".config"), nil
}

obsConfigFile, err := config.ObsidianFile()
assert.NoError(t, err)
assert.Equal(t, snapConfigFile, obsConfigFile)
})

t.Run("Finds WSL Install Location", func(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("WSL test only runs on Linux")
Expand Down
96 changes: 96 additions & 0 deletions pkg/obsidian/vault_list.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package obsidian

import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)

type VaultInfo struct {
Name string `json:"name"`
Path string `json:"path"`
}

func ListVaults() ([]VaultInfo, error) {
obsidianConfigFile, err := ObsidianConfigFile()
if err != nil {
return nil, err
}

content, err := os.ReadFile(obsidianConfigFile)
if err != nil {
return nil, errors.New(ObsidianConfigReadError)
}

vaultsContent := ObsidianVaultConfig{}
if json.Unmarshal(content, &vaultsContent) != nil {
return nil, errors.New(ObsidianConfigParseError)
}

vaults := make([]VaultInfo, 0, len(vaultsContent.Vaults))
for _, element := range vaultsContent.Vaults {
path := element.Path
if RunningInWSL() {
path = adjustForWslMount(path)
}
vaults = append(vaults, VaultInfo{
Name: filepath.Base(path),
Path: path,
})
}

return vaults, nil
}

// ResolveVaultName validates user input against registered Obsidian vaults.
// It accepts a vault name or a path and resolves it to the correct vault name.
func ResolveVaultName(input string) (string, error) {
vaults, err := ListVaults()
if err != nil {
return "", err
}

if len(vaults) == 0 {
return "", errors.New("no vaults registered in Obsidian. Please create a vault in Obsidian first")
}

// Collect all name matches
var nameMatches []VaultInfo
for _, v := range vaults {
if v.Name == input {
nameMatches = append(nameMatches, v)
}
}
if len(nameMatches) == 1 {
return nameMatches[0].Name, nil
}
if len(nameMatches) > 1 {
var paths []string
for _, m := range nameMatches {
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"),
)
}

// Exact path match (user passed a full path)
cleanInput := filepath.Clean(input)
for _, v := range vaults {
if filepath.Clean(v.Path) == cleanInput {
return v.Name, nil
}
}

// Build available vault list for the error message
var available []string
for _, v := range vaults {
available = append(available, fmt.Sprintf(" %s\t(%s)", v.Name, v.Path))
}

return "", fmt.Errorf("vault %q not found in Obsidian.\nAvailable vaults:\n%s", input, strings.Join(available, "\n"))
}
Loading