Compare commits
5 Commits
v0.2026030
...
v0.2026030
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
548e5694ac | ||
|
|
0cf8f55a8d | ||
|
|
72b88cef43 | ||
|
|
8db9f61d5c | ||
|
|
fb2067ec79 |
@@ -56,7 +56,8 @@ Entity type is inferred from the folder structure. The vault is organized by typ
|
||||
├── journal/ → "Journal"
|
||||
├── essay/ → "Essay"
|
||||
├── evergreen/ → "Evergreen"
|
||||
└── theme/ → "Theme" ← vault-based themes
|
||||
├── theme/ → "Theme" ← vault-based themes
|
||||
└── config/ → "Config" ← meta-configuration files (agents.md, etc.)
|
||||
```
|
||||
|
||||
Mapping logic lives in `vault/mod.rs:parse_md_file()`. If a folder doesn't match any known type, the folder name is capitalized and used as-is.
|
||||
|
||||
@@ -183,7 +183,7 @@ The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistan
|
||||
| `delete_note` | `path` | Delete a note file from the vault |
|
||||
| `link_notes` | `source_path, property, target_title` | Add a target to an array property in frontmatter |
|
||||
| `list_notes` | `[type_filter], [sort]` | List all notes, optionally filtered by type |
|
||||
| `vault_context` | — | Get vault summary: entity types + 20 recent notes |
|
||||
| `vault_context` | — | Get vault summary: entity types + 20 recent notes + configFiles |
|
||||
| `ui_open_note` | `path` | Open a note in the Laputa UI editor |
|
||||
| `ui_open_tab` | `path` | Open a note in a new UI tab |
|
||||
| `ui_highlight` | `element, [path]` | Highlight a UI element (editor, tab, properties, notelist) |
|
||||
@@ -391,7 +391,7 @@ Backend: `get_vault_pulse` Tauri command parses `git log` with `--name-status`.
|
||||
|
||||
```
|
||||
1. Tauri setup:
|
||||
a. run_startup_tasks() → purge trash, migrate frontmatter, seed themes, register MCP
|
||||
a. run_startup_tasks() → purge trash, migrate frontmatter, seed themes, migrate AGENTS.md, seed config files, register MCP
|
||||
b. spawn_ws_bridge() → start MCP WebSocket bridge (ports 9710, 9711)
|
||||
2. App mounts
|
||||
3. useOnboarding checks vault exists → WelcomeScreen if not
|
||||
@@ -454,6 +454,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `rename.rs` | `rename_note` — renames files and updates wikilinks across the vault |
|
||||
| `image.rs` | `save_image` — saves base64-encoded attachments with sanitized filenames |
|
||||
| `migration.rs` | Frontmatter migration utilities |
|
||||
| `config_seed.rs` | Seeds `config/` folder, migrates `AGENTS.md`, repairs missing config files |
|
||||
| `getting_started.rs` | Creates the Getting Started demo vault |
|
||||
|
||||
## Rust Backend Modules
|
||||
@@ -551,6 +552,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `create_vault_theme` | Create markdown theme note |
|
||||
| `ensure_vault_themes` | Seed default themes if missing |
|
||||
| `restore_default_themes` | Restore all default themes |
|
||||
| `repair_vault` | Restore default themes + missing config files |
|
||||
|
||||
### AI & MCP
|
||||
|
||||
|
||||
@@ -107,11 +107,22 @@ export async function vaultContext(vaultPath) {
|
||||
notesWithMtime.sort((a, b) => b.mtime - a.mtime)
|
||||
const recentNotes = notesWithMtime.slice(0, 20).map(({ mtime: _mtime, ...rest }) => rest)
|
||||
|
||||
// Read config files for AI agent context
|
||||
const configFiles = {}
|
||||
try {
|
||||
const agentsPath = path.join(vaultPath, 'config', 'agents.md')
|
||||
const agentsContent = await fs.readFile(agentsPath, 'utf-8')
|
||||
configFiles.agents = agentsContent
|
||||
} catch {
|
||||
// config/agents.md may not exist yet
|
||||
}
|
||||
|
||||
return {
|
||||
types: [...typesSet].sort(),
|
||||
noteCount: files.length,
|
||||
folders: [...foldersSet].sort(),
|
||||
recentNotes,
|
||||
configFiles,
|
||||
vaultPath,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -511,6 +511,16 @@ pub fn restore_default_themes(vault_path: String) -> Result<String, String> {
|
||||
theme::restore_default_themes(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn repair_vault(vault_path: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
// Repair themes
|
||||
theme::restore_default_themes(&vault_path)?;
|
||||
// Repair config files (config/agents.md, type/config.md, AGENTS.md stub)
|
||||
vault::repair_config_files(&vault_path)?;
|
||||
Ok("Vault repaired".to_string())
|
||||
}
|
||||
|
||||
// ── Settings & config commands ──────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -56,6 +56,11 @@ fn run_startup_tasks() {
|
||||
// Seed type/theme.md so the Theme type has an icon in the sidebar
|
||||
let _ = theme::ensure_theme_type_definition(vp_str);
|
||||
|
||||
// Migrate root AGENTS.md → config/agents.md (one-time, idempotent)
|
||||
vault::migrate_agents_md(vp_str);
|
||||
// Seed config/ with default config files if missing
|
||||
vault::seed_config_files(vp_str);
|
||||
|
||||
// Register Laputa MCP server in Claude Code and Cursor configs
|
||||
match mcp::register_mcp(vp_str) {
|
||||
Ok(status) => log::info!("MCP registration: {status}"),
|
||||
@@ -167,6 +172,7 @@ pub fn run() {
|
||||
commands::create_vault_theme,
|
||||
commands::ensure_vault_themes,
|
||||
commands::restore_default_themes,
|
||||
commands::repair_vault,
|
||||
commands::get_vault_config,
|
||||
commands::save_vault_config
|
||||
])
|
||||
|
||||
@@ -49,6 +49,7 @@ const VAULT_RESOLVE_CONFLICTS: &str = "vault-resolve-conflicts";
|
||||
const VAULT_VIEW_CHANGES: &str = "vault-view-changes";
|
||||
const VAULT_INSTALL_MCP: &str = "vault-install-mcp";
|
||||
const VAULT_REINDEX: &str = "vault-reindex";
|
||||
const VAULT_REPAIR: &str = "vault-repair";
|
||||
|
||||
const CUSTOM_IDS: &[&str] = &[
|
||||
APP_SETTINGS,
|
||||
@@ -90,6 +91,7 @@ const CUSTOM_IDS: &[&str] = &[
|
||||
VAULT_VIEW_CHANGES,
|
||||
VAULT_INSTALL_MCP,
|
||||
VAULT_REINDEX,
|
||||
VAULT_REPAIR,
|
||||
];
|
||||
|
||||
/// IDs of menu items that should be disabled when no note tab is active.
|
||||
@@ -337,6 +339,9 @@ fn build_vault_menu(app: &App) -> MenuResult {
|
||||
let reindex = MenuItemBuilder::new("Reindex Vault")
|
||||
.id(VAULT_REINDEX)
|
||||
.build(app)?;
|
||||
let repair = MenuItemBuilder::new("Repair Vault")
|
||||
.id(VAULT_REPAIR)
|
||||
.build(app)?;
|
||||
|
||||
Ok(SubmenuBuilder::new(app, "Vault")
|
||||
.item(&open_vault)
|
||||
@@ -351,6 +356,7 @@ fn build_vault_menu(app: &App) -> MenuResult {
|
||||
.item(&view_changes)
|
||||
.separator()
|
||||
.item(&reindex)
|
||||
.item(&repair)
|
||||
.item(&install_mcp)
|
||||
.build()?)
|
||||
}
|
||||
|
||||
355
src-tauri/src/vault/config_seed.rs
Normal file
355
src-tauri/src/vault/config_seed.rs
Normal file
@@ -0,0 +1,355 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use super::getting_started::AGENTS_MD;
|
||||
|
||||
/// Content for `type/config.md` — gives the Config type a sidebar icon and label.
|
||||
const CONFIG_TYPE_DEFINITION: &str = "\
|
||||
---
|
||||
Is A: Type
|
||||
icon: gear-six
|
||||
color: gray
|
||||
order: 90
|
||||
sidebar label: Config
|
||||
---
|
||||
|
||||
# Config
|
||||
|
||||
Vault configuration files. These control how AI agents, tools, and other integrations interact with this vault.
|
||||
";
|
||||
|
||||
/// Minimal root `AGENTS.md` stub that redirects to `config/agents.md`.
|
||||
const AGENTS_MD_STUB: &str = "\
|
||||
# Agent Instructions
|
||||
|
||||
See config/agents.md for vault instructions.
|
||||
";
|
||||
|
||||
/// Seed `config/agents.md` if missing or empty (idempotent, per-file).
|
||||
/// Also seeds `type/config.md` for sidebar visibility.
|
||||
pub fn seed_config_files(vault_path: &str) {
|
||||
let vault = Path::new(vault_path);
|
||||
let config_dir = vault.join("config");
|
||||
if fs::create_dir_all(&config_dir).is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
let agents_path = config_dir.join("agents.md");
|
||||
let needs_write =
|
||||
!agents_path.exists() || fs::metadata(&agents_path).map_or(true, |m| m.len() == 0);
|
||||
if needs_write {
|
||||
let _ = fs::write(&agents_path, AGENTS_MD);
|
||||
log::info!("Seeded config/agents.md");
|
||||
}
|
||||
|
||||
ensure_config_type_definition(vault_path);
|
||||
}
|
||||
|
||||
/// Ensure `type/config.md` exists (gives Config type a sidebar icon/color).
|
||||
fn ensure_config_type_definition(vault_path: &str) {
|
||||
let type_dir = Path::new(vault_path).join("type");
|
||||
if fs::create_dir_all(&type_dir).is_err() {
|
||||
return;
|
||||
}
|
||||
let path = type_dir.join("config.md");
|
||||
let needs_write = !path.exists() || fs::metadata(&path).map_or(true, |m| m.len() == 0);
|
||||
if needs_write {
|
||||
let _ = fs::write(&path, CONFIG_TYPE_DEFINITION);
|
||||
}
|
||||
}
|
||||
|
||||
/// Migrate root `AGENTS.md` → `config/agents.md` for existing vaults.
|
||||
///
|
||||
/// - If root `AGENTS.md` exists and `config/agents.md` does not: move content, write stub.
|
||||
/// - If root `AGENTS.md` exists and `config/agents.md` also exists: just replace root with stub.
|
||||
/// - If root `AGENTS.md` doesn't exist: write the stub anyway (for Codex discoverability).
|
||||
///
|
||||
/// Always idempotent and silent.
|
||||
pub fn migrate_agents_md(vault_path: &str) {
|
||||
let vault = Path::new(vault_path);
|
||||
let root_agents = vault.join("AGENTS.md");
|
||||
let config_dir = vault.join("config");
|
||||
let config_agents = config_dir.join("agents.md");
|
||||
|
||||
// Ensure config/ directory exists
|
||||
if fs::create_dir_all(&config_dir).is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
// If root AGENTS.md has real content (not already a stub), migrate it
|
||||
if root_agents.exists() {
|
||||
let content = fs::read_to_string(&root_agents).unwrap_or_default();
|
||||
let is_stub = content.contains("See config/agents.md");
|
||||
|
||||
if !is_stub {
|
||||
// Only move content if config/agents.md doesn't exist yet
|
||||
let config_needs_write = !config_agents.exists()
|
||||
|| fs::metadata(&config_agents).map_or(true, |m| m.len() == 0);
|
||||
if config_needs_write {
|
||||
let _ = fs::write(&config_agents, &content);
|
||||
log::info!("Migrated AGENTS.md content to config/agents.md");
|
||||
}
|
||||
// Replace root with stub
|
||||
let _ = fs::write(&root_agents, AGENTS_MD_STUB);
|
||||
log::info!("Replaced root AGENTS.md with stub pointing to config/agents.md");
|
||||
}
|
||||
} else {
|
||||
// No root AGENTS.md — write stub for Codex discoverability
|
||||
let _ = fs::write(&root_agents, AGENTS_MD_STUB);
|
||||
}
|
||||
}
|
||||
|
||||
/// Repair config files: re-create missing `config/agents.md` and `type/config.md`.
|
||||
/// Called by the "Repair Vault" command. Returns a status message.
|
||||
pub fn repair_config_files(vault_path: &str) -> Result<String, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
// Ensure config/ directory
|
||||
let config_dir = vault.join("config");
|
||||
fs::create_dir_all(&config_dir)
|
||||
.map_err(|e| format!("Failed to create config directory: {e}"))?;
|
||||
|
||||
let agents_path = config_dir.join("agents.md");
|
||||
let root_agents = vault.join("AGENTS.md");
|
||||
|
||||
// Step 1: Migrate root AGENTS.md content → config/agents.md if needed
|
||||
if root_agents.exists() {
|
||||
let root_content = fs::read_to_string(&root_agents).unwrap_or_default();
|
||||
let is_stub = root_content.contains("See config/agents.md");
|
||||
if !is_stub && !root_content.is_empty() {
|
||||
let config_needs_write =
|
||||
!agents_path.exists() || fs::metadata(&agents_path).map_or(true, |m| m.len() == 0);
|
||||
if config_needs_write {
|
||||
fs::write(&agents_path, &root_content)
|
||||
.map_err(|e| format!("Failed to migrate AGENTS.md: {e}"))?;
|
||||
}
|
||||
fs::write(&root_agents, AGENTS_MD_STUB)
|
||||
.map_err(|e| format!("Failed to write AGENTS.md stub: {e}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Seed config/agents.md with defaults if still missing or empty
|
||||
let needs_write =
|
||||
!agents_path.exists() || fs::metadata(&agents_path).map_or(true, |m| m.len() == 0);
|
||||
if needs_write {
|
||||
fs::write(&agents_path, AGENTS_MD)
|
||||
.map_err(|e| format!("Failed to write config/agents.md: {e}"))?;
|
||||
}
|
||||
|
||||
// Step 3: Ensure type/config.md
|
||||
let type_dir = vault.join("type");
|
||||
fs::create_dir_all(&type_dir).map_err(|e| format!("Failed to create type directory: {e}"))?;
|
||||
let config_type_path = type_dir.join("config.md");
|
||||
let type_needs_write = !config_type_path.exists()
|
||||
|| fs::metadata(&config_type_path).map_or(true, |m| m.len() == 0);
|
||||
if type_needs_write {
|
||||
fs::write(&config_type_path, CONFIG_TYPE_DEFINITION)
|
||||
.map_err(|e| format!("Failed to write type/config.md: {e}"))?;
|
||||
}
|
||||
|
||||
// Step 4: Ensure root AGENTS.md stub exists
|
||||
let stub_needs_write = !root_agents.exists()
|
||||
|| fs::read_to_string(&root_agents).map_or(true, |c| !c.contains("See config/agents.md"));
|
||||
if stub_needs_write {
|
||||
fs::write(&root_agents, AGENTS_MD_STUB)
|
||||
.map_err(|e| format!("Failed to write AGENTS.md stub: {e}"))?;
|
||||
}
|
||||
|
||||
Ok("Config files repaired".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_seed_config_files_creates_dir_and_agents() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
|
||||
seed_config_files(vault.to_str().unwrap());
|
||||
|
||||
assert!(vault.join("config").is_dir());
|
||||
assert!(vault.join("config/agents.md").exists());
|
||||
let content = fs::read_to_string(vault.join("config/agents.md")).unwrap();
|
||||
assert!(content.contains("Vault Instructions for AI Agents"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_config_files_creates_type_definition() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
|
||||
seed_config_files(vault.to_str().unwrap());
|
||||
|
||||
assert!(vault.join("type/config.md").exists());
|
||||
let content = fs::read_to_string(vault.join("type/config.md")).unwrap();
|
||||
assert!(content.contains("Is A: Type"));
|
||||
assert!(content.contains("icon: gear-six"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_config_files_is_idempotent() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
|
||||
seed_config_files(vault.to_str().unwrap());
|
||||
// Customize the file
|
||||
let custom = "---\nIs A: Config\n---\n# Custom Agents\nMy custom instructions\n";
|
||||
fs::write(vault.join("config/agents.md"), custom).unwrap();
|
||||
|
||||
seed_config_files(vault.to_str().unwrap());
|
||||
let content = fs::read_to_string(vault.join("config/agents.md")).unwrap();
|
||||
assert!(
|
||||
content.contains("Custom Agents"),
|
||||
"must preserve existing content"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_config_files_reseeds_empty() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
let config_dir = vault.join("config");
|
||||
fs::create_dir_all(&config_dir).unwrap();
|
||||
fs::write(config_dir.join("agents.md"), "").unwrap();
|
||||
|
||||
seed_config_files(vault.to_str().unwrap());
|
||||
let content = fs::read_to_string(config_dir.join("agents.md")).unwrap();
|
||||
assert!(content.contains("Vault Instructions for AI Agents"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_agents_md_moves_content() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
fs::write(vault.join("AGENTS.md"), AGENTS_MD).unwrap();
|
||||
|
||||
migrate_agents_md(vault.to_str().unwrap());
|
||||
|
||||
// config/agents.md should have the original content
|
||||
let config_content = fs::read_to_string(vault.join("config/agents.md")).unwrap();
|
||||
assert!(config_content.contains("Vault Instructions for AI Agents"));
|
||||
|
||||
// Root AGENTS.md should be a stub
|
||||
let root_content = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
assert!(root_content.contains("See config/agents.md"));
|
||||
assert!(!root_content.contains("## Structure"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_agents_md_preserves_existing_config() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
let config_dir = vault.join("config");
|
||||
fs::create_dir_all(&config_dir).unwrap();
|
||||
let custom = "# Custom agent instructions\n";
|
||||
fs::write(config_dir.join("agents.md"), custom).unwrap();
|
||||
fs::write(vault.join("AGENTS.md"), AGENTS_MD).unwrap();
|
||||
|
||||
migrate_agents_md(vault.to_str().unwrap());
|
||||
|
||||
// config/agents.md should preserve custom content
|
||||
let content = fs::read_to_string(config_dir.join("agents.md")).unwrap();
|
||||
assert!(content.contains("Custom agent instructions"));
|
||||
|
||||
// Root should be a stub
|
||||
let root = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
assert!(root.contains("See config/agents.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_agents_md_idempotent_on_stub() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
fs::write(vault.join("AGENTS.md"), AGENTS_MD_STUB).unwrap();
|
||||
|
||||
migrate_agents_md(vault.to_str().unwrap());
|
||||
|
||||
// Stub should remain unchanged
|
||||
let root = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
assert!(root.contains("See config/agents.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_agents_md_writes_stub_when_no_root() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
|
||||
migrate_agents_md(vault.to_str().unwrap());
|
||||
|
||||
assert!(vault.join("AGENTS.md").exists());
|
||||
let root = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
assert!(root.contains("See config/agents.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_repair_config_files_creates_all() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
|
||||
let msg = repair_config_files(vault.to_str().unwrap()).unwrap();
|
||||
assert_eq!(msg, "Config files repaired");
|
||||
|
||||
assert!(vault.join("config/agents.md").exists());
|
||||
assert!(vault.join("type/config.md").exists());
|
||||
assert!(vault.join("AGENTS.md").exists());
|
||||
|
||||
let agents = fs::read_to_string(vault.join("config/agents.md")).unwrap();
|
||||
assert!(agents.contains("Vault Instructions for AI Agents"));
|
||||
|
||||
let stub = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
assert!(stub.contains("See config/agents.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_repair_config_files_preserves_custom_content() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
let config_dir = vault.join("config");
|
||||
fs::create_dir_all(&config_dir).unwrap();
|
||||
let custom = "# My custom agent config\nDo not overwrite me\n";
|
||||
fs::write(config_dir.join("agents.md"), custom).unwrap();
|
||||
fs::write(
|
||||
vault.join("AGENTS.md"),
|
||||
"# Agent Instructions\nSee config/agents.md for vault instructions.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
repair_config_files(vault.to_str().unwrap()).unwrap();
|
||||
|
||||
let content = fs::read_to_string(config_dir.join("agents.md")).unwrap();
|
||||
assert!(
|
||||
content.contains("My custom agent config"),
|
||||
"must preserve existing content"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_repair_config_files_migrates_root_agents() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
let original = "# My vault agents instructions\nCustom content here\n";
|
||||
fs::write(vault.join("AGENTS.md"), original).unwrap();
|
||||
|
||||
repair_config_files(vault.to_str().unwrap()).unwrap();
|
||||
|
||||
// Root should be a stub
|
||||
let root = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
assert!(root.contains("See config/agents.md"));
|
||||
|
||||
// config/agents.md should have the original content
|
||||
let config = fs::read_to_string(vault.join("config/agents.md")).unwrap();
|
||||
assert!(config.contains("My vault agents instructions"));
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,10 @@ struct SampleFile {
|
||||
content: &'static str,
|
||||
}
|
||||
|
||||
/// Content for the AGENTS.md file written to the vault root.
|
||||
/// Content for config/agents.md — vault instructions for AI agents.
|
||||
/// This file has no YAML frontmatter — it is a convention file for AI agents,
|
||||
/// not a vault note. The vault scanner will still pick it up as a regular entry.
|
||||
const AGENTS_MD: &str = r#"# AGENTS.md — Vault Instructions for AI Agents
|
||||
pub(super) const AGENTS_MD: &str = r#"# AGENTS.md — Vault Instructions for AI Agents
|
||||
|
||||
This is a [Laputa](https://github.com/refactoring-ai/laputa) vault — a folder of markdown files with YAML frontmatter that form a personal knowledge graph.
|
||||
|
||||
@@ -137,6 +137,10 @@ const SAMPLE_FILES: &[SampleFile] = &[
|
||||
rel_path: "type/theme.md",
|
||||
content: "---\nIs A: Type\nicon: palette\ncolor: purple\norder: 50\n---\n\n# Theme\n\nA visual theme for Laputa. Each theme defines CSS custom properties that control colors, typography, and spacing.\n",
|
||||
},
|
||||
SampleFile {
|
||||
rel_path: "type/config.md",
|
||||
content: "---\nIs A: Type\nicon: gear-six\ncolor: gray\norder: 90\nsidebar label: Config\n---\n\n# Config\n\nVault configuration files. These control how AI agents, tools, and other integrations interact with this vault.\n",
|
||||
},
|
||||
SampleFile {
|
||||
rel_path: "note/welcome-to-laputa.md",
|
||||
content: r#"---
|
||||
@@ -384,9 +388,19 @@ pub fn create_getting_started_vault(target_path: &str) -> Result<String, String>
|
||||
fs::create_dir_all(vault_dir)
|
||||
.map_err(|e| format!("Failed to create vault directory: {}", e))?;
|
||||
|
||||
// Write AGENTS.md at the vault root
|
||||
fs::write(vault_dir.join("AGENTS.md"), AGENTS_MD)
|
||||
.map_err(|e| format!("Failed to write AGENTS.md: {}", e))?;
|
||||
// Write config/agents.md with vault instructions for AI agents
|
||||
let config_dir = vault_dir.join("config");
|
||||
fs::create_dir_all(&config_dir)
|
||||
.map_err(|e| format!("Failed to create config directory: {}", e))?;
|
||||
fs::write(config_dir.join("agents.md"), AGENTS_MD)
|
||||
.map_err(|e| format!("Failed to write config/agents.md: {}", e))?;
|
||||
|
||||
// Write root AGENTS.md stub for Codex discoverability
|
||||
fs::write(
|
||||
vault_dir.join("AGENTS.md"),
|
||||
"# Agent Instructions\n\nSee config/agents.md for vault instructions.\n",
|
||||
)
|
||||
.map_err(|e| format!("Failed to write AGENTS.md stub: {}", e))?;
|
||||
|
||||
for sample in SAMPLE_FILES {
|
||||
let file_path = vault_dir.join(sample.rel_path);
|
||||
@@ -462,6 +476,7 @@ mod tests {
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Verify key files exist
|
||||
assert!(vault_path.join("config/agents.md").exists());
|
||||
assert!(vault_path.join("AGENTS.md").exists());
|
||||
assert!(vault_path.join("note/welcome-to-laputa.md").exists());
|
||||
assert!(vault_path.join("note/editor-basics.md").exists());
|
||||
@@ -476,6 +491,7 @@ mod tests {
|
||||
assert!(vault_path.join("type/note.md").exists());
|
||||
assert!(vault_path.join("type/person.md").exists());
|
||||
assert!(vault_path.join("type/topic.md").exists());
|
||||
assert!(vault_path.join("type/config.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -530,57 +546,63 @@ mod tests {
|
||||
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
|
||||
|
||||
let entries = crate::vault::scan_vault(&vault_path).unwrap();
|
||||
// SAMPLE_FILES + AGENTS.md + 3 vault theme notes (theme/default.md, dark.md, minimal.md)
|
||||
assert_eq!(entries.len(), SAMPLE_FILES.len() + 1 + 3);
|
||||
// SAMPLE_FILES + config/agents.md + AGENTS.md stub + 3 vault theme notes
|
||||
assert_eq!(entries.len(), SAMPLE_FILES.len() + 2 + 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agents_md_present_after_vault_creation() {
|
||||
fn test_config_agents_md_present_after_vault_creation() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().join("agents-vault");
|
||||
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
|
||||
|
||||
let agents_path = vault_path.join("AGENTS.md");
|
||||
assert!(agents_path.exists(), "AGENTS.md should exist at vault root");
|
||||
let agents_path = vault_path.join("config/agents.md");
|
||||
assert!(
|
||||
agents_path.exists(),
|
||||
"config/agents.md should exist in vault"
|
||||
);
|
||||
|
||||
let content = fs::read_to_string(&agents_path).unwrap();
|
||||
assert!(content.contains("Vault Instructions for AI Agents"));
|
||||
assert!(content.contains("## Structure"));
|
||||
assert!(content.contains("## Frontmatter"));
|
||||
assert!(content.contains("## Wikilinks"));
|
||||
assert!(content.contains("## Type definitions"));
|
||||
assert!(content.contains("## Conventions"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_root_agents_md_is_stub_after_vault_creation() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().join("stub-vault");
|
||||
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
|
||||
|
||||
let root_path = vault_path.join("AGENTS.md");
|
||||
assert!(root_path.exists(), "Root AGENTS.md stub should exist");
|
||||
|
||||
let content = fs::read_to_string(&root_path).unwrap();
|
||||
assert!(
|
||||
content.contains("Vault Instructions for AI Agents"),
|
||||
"AGENTS.md should contain instructions header"
|
||||
content.contains("See config/agents.md"),
|
||||
"Root AGENTS.md should redirect to config/agents.md"
|
||||
);
|
||||
assert!(
|
||||
content.contains("## Structure"),
|
||||
"AGENTS.md should describe vault structure"
|
||||
);
|
||||
assert!(
|
||||
content.contains("## Frontmatter"),
|
||||
"AGENTS.md should describe frontmatter"
|
||||
);
|
||||
assert!(
|
||||
content.contains("## Wikilinks"),
|
||||
"AGENTS.md should describe wikilinks"
|
||||
);
|
||||
assert!(
|
||||
content.contains("## Type definitions"),
|
||||
"AGENTS.md should describe type definitions"
|
||||
);
|
||||
assert!(
|
||||
content.contains("## Conventions"),
|
||||
"AGENTS.md should describe conventions"
|
||||
!content.contains("## Structure"),
|
||||
"Root AGENTS.md should not contain full instructions"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agents_md_parseable_as_vault_entry() {
|
||||
fn test_config_agents_md_parseable_as_vault_entry() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().join("agents-parse-vault");
|
||||
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
|
||||
|
||||
let entry = crate::vault::parse_md_file(&vault_path.join("AGENTS.md")).unwrap();
|
||||
let entry = crate::vault::parse_md_file(&vault_path.join("config/agents.md")).unwrap();
|
||||
assert_eq!(
|
||||
entry.title,
|
||||
"AGENTS.md \u{2014} Vault Instructions for AI Agents"
|
||||
);
|
||||
assert_eq!(entry.is_a.as_deref(), Some("Config"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod cache;
|
||||
mod config_seed;
|
||||
mod getting_started;
|
||||
mod image;
|
||||
mod migration;
|
||||
@@ -7,6 +8,7 @@ mod rename;
|
||||
mod trash;
|
||||
|
||||
pub use cache::scan_vault_cached;
|
||||
pub use config_seed::{migrate_agents_md, repair_config_files, seed_config_files};
|
||||
pub use getting_started::{create_getting_started_vault, default_vault_path, vault_exists};
|
||||
pub use image::{copy_image_to_vault, save_image};
|
||||
pub use migration::migrate_is_a_to_type;
|
||||
@@ -277,6 +279,7 @@ fn infer_type_from_folder(folder: &str) -> String {
|
||||
"target" => "Target",
|
||||
"journal" => "Journal",
|
||||
"month" => "Month",
|
||||
"config" => "Config",
|
||||
"essay" => "Essay",
|
||||
"evergreen" => "Evergreen",
|
||||
_ => return title_case_folder(folder),
|
||||
|
||||
14
src/App.tsx
14
src/App.tsx
@@ -412,6 +412,19 @@ function App() {
|
||||
}
|
||||
}, [resolvedPath, vault, themeManager, setToastMessage])
|
||||
|
||||
const handleRepairVault = useCallback(async () => {
|
||||
if (!resolvedPath) return
|
||||
try {
|
||||
const tauriInvoke = isTauri() ? invoke : mockInvoke
|
||||
const msg = await tauriInvoke<string>('repair_vault', { vaultPath: resolvedPath })
|
||||
await vault.reloadVault()
|
||||
await themeManager.reloadThemes()
|
||||
setToastMessage(msg)
|
||||
} catch (err) {
|
||||
setToastMessage(`Failed to repair vault: ${err}`)
|
||||
}
|
||||
}, [resolvedPath, vault, themeManager, setToastMessage])
|
||||
|
||||
const commands = useAppCommands({
|
||||
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
|
||||
handleCloseTabRef: notes.handleCloseTabRef, tabs: notes.tabs,
|
||||
@@ -468,6 +481,7 @@ function App() {
|
||||
mcpStatus,
|
||||
onInstallMcp: installMcp,
|
||||
onReindexVault: indexing.triggerFullReindex,
|
||||
onRepairVault: handleRepairVault,
|
||||
})
|
||||
|
||||
const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null
|
||||
|
||||
@@ -67,6 +67,7 @@ interface AppCommandsConfig {
|
||||
mcpStatus?: string
|
||||
onInstallMcp?: () => void
|
||||
onReindexVault?: () => void
|
||||
onRepairVault?: () => void
|
||||
}
|
||||
|
||||
/** Sets up keyboard shortcuts, command registry, menu events, and keyboard navigation. */
|
||||
@@ -150,6 +151,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onViewChanges: viewChanges,
|
||||
onInstallMcp: config.onInstallMcp,
|
||||
onReindexVault: config.onReindexVault,
|
||||
onRepairVault: config.onRepairVault,
|
||||
activeTabPathRef: config.activeTabPathRef,
|
||||
handleCloseTabRef: config.handleCloseTabRef,
|
||||
activeTabPath: config.activeTabPath,
|
||||
@@ -204,6 +206,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
mcpStatus: config.mcpStatus,
|
||||
onInstallMcp: config.onInstallMcp,
|
||||
onReindexVault: config.onReindexVault,
|
||||
onRepairVault: config.onRepairVault,
|
||||
})
|
||||
|
||||
useKeyboardNavigation({
|
||||
|
||||
@@ -21,6 +21,7 @@ interface CommandRegistryConfig {
|
||||
mcpStatus?: string
|
||||
onInstallMcp?: () => void
|
||||
onReindexVault?: () => void
|
||||
onRepairVault?: () => void
|
||||
|
||||
onQuickOpen: () => void
|
||||
onCreateNote: () => void
|
||||
@@ -198,6 +199,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
onRemoveActiveVault, onRestoreGettingStarted, onRestoreDefaultThemes, isGettingStartedHidden, vaultCount,
|
||||
mcpStatus, onInstallMcp,
|
||||
onReindexVault,
|
||||
onRepairVault,
|
||||
} = config
|
||||
|
||||
const hasActiveNote = activeTabPath !== null
|
||||
@@ -260,6 +262,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
{ id: 'check-updates', label: 'Check for Updates', group: 'Settings', keywords: ['update', 'version', 'upgrade', 'release'], enabled: true, execute: () => onCheckForUpdates?.() },
|
||||
{ id: 'install-mcp', label: mcpStatus === 'installed' ? 'Restore MCP Server' : 'Install MCP Server', group: 'Settings', keywords: ['mcp', 'claude', 'ai', 'tools', 'install', 'restore', 'fix', 'repair'], enabled: true, execute: () => onInstallMcp?.() },
|
||||
{ id: 'reindex-vault', label: 'Reindex Vault', group: 'Settings', keywords: ['reindex', 'index', 'search', 'rebuild', 'refresh'], enabled: !!onReindexVault, execute: () => onReindexVault?.() },
|
||||
{ id: 'repair-vault', label: 'Repair Vault', group: 'Settings', keywords: ['repair', 'fix', 'restore', 'config', 'agents', 'themes', 'missing', 'reset'], enabled: !!onRepairVault, execute: () => onRepairVault?.() },
|
||||
|
||||
// Type-aware: "New [Type]" and "List [Type]"
|
||||
...buildTypeCommands(vaultTypes, onCreateNoteOfType, onSelect),
|
||||
@@ -278,6 +281,6 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
vaultTypes, themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme, onRestoreDefaultThemes,
|
||||
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
|
||||
mcpStatus, onInstallMcp,
|
||||
onReindexVault,
|
||||
onReindexVault, onRepairVault,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface MenuEventHandlers {
|
||||
onViewChanges?: () => void
|
||||
onInstallMcp?: () => void
|
||||
onReindexVault?: () => void
|
||||
onRepairVault?: () => void
|
||||
activeTabPathRef: React.MutableRefObject<string | null>
|
||||
handleCloseTabRef: React.MutableRefObject<(path: string) => void>
|
||||
activeTabPath: string | null
|
||||
@@ -78,7 +79,7 @@ type OptionalHandler =
|
||||
| 'onCreateType' | 'onToggleRawEditor' | 'onToggleDiff' | 'onToggleAIChat'
|
||||
| 'onOpenVault' | 'onRemoveActiveVault' | 'onRestoreGettingStarted'
|
||||
| 'onCreateTheme' | 'onRestoreDefaultThemes'
|
||||
| 'onCommitPush' | 'onResolveConflicts' | 'onViewChanges' | 'onInstallMcp' | 'onReindexVault'
|
||||
| 'onCommitPush' | 'onResolveConflicts' | 'onViewChanges' | 'onInstallMcp' | 'onReindexVault' | 'onRepairVault'
|
||||
|
||||
const OPTIONAL_EVENT_MAP: Record<string, OptionalHandler> = {
|
||||
'view-go-back': 'onGoBack',
|
||||
@@ -98,6 +99,7 @@ const OPTIONAL_EVENT_MAP: Record<string, OptionalHandler> = {
|
||||
'vault-view-changes': 'onViewChanges',
|
||||
'vault-install-mcp': 'onInstallMcp',
|
||||
'vault-reindex': 'onReindexVault',
|
||||
'vault-repair': 'onRepairVault',
|
||||
}
|
||||
|
||||
function dispatchActiveTabEvent(id: string, h: MenuEventHandlers): boolean {
|
||||
|
||||
@@ -334,6 +334,7 @@ line-height-base: 1.6
|
||||
},
|
||||
ensure_vault_themes: (): null => null,
|
||||
restore_default_themes: (): string => 'Default themes restored',
|
||||
repair_vault: (): string => 'Vault repaired',
|
||||
get_vault_config: (): VaultConfig => ({ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null }),
|
||||
save_vault_config: (): null => null,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user