Skip to content
Merged
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
41 changes: 40 additions & 1 deletion src/init.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use std::fs;
use std::path::Path;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use serde_json::Value;
Expand Down Expand Up @@ -177,3 +177,42 @@ pub fn run_init(project_dir: &Path) -> Result<InitResult> {

Ok(result)
}

/// Inject an MCP server entry into a Claude config file. Returns a status string.
pub fn inject_mcp_server(config_path: &PathBuf, name: &str, entry: &Value) -> Result<String> {
let mut config: Value = if config_path.exists() {
let content = fs::read_to_string(config_path)
.with_context(|| format!("cannot read {}", config_path.display()))?;
serde_json::from_str(&content)
.with_context(|| format!("invalid JSON in {}", config_path.display()))?
} else {
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent).ok();
}
serde_json::json!({})
};

let mcp_servers = config
.as_object_mut()
.context("config is not a JSON object")?
.entry("mcpServers")
.or_insert_with(|| serde_json::json!({}));

if let Some(existing) = mcp_servers.get(name)
&& existing.get("command").and_then(|v| v.as_str())
== entry.get("command").and_then(|v| v.as_str())
{
return Ok("already configured".into());
}

mcp_servers
.as_object_mut()
.unwrap()
.insert(name.to_string(), entry.clone());

let output = serde_json::to_string_pretty(&config)?;
fs::write(config_path, output)
.with_context(|| format!("cannot write {}", config_path.display()))?;

Ok("configured".into())
}
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ pub mod config;
pub mod db;
pub mod init;
pub mod input;
pub mod mcp;
#[cfg(target_os = "macos")]
pub mod stt;
103 changes: 90 additions & 13 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
use std::time::Instant;

use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use clap::{Parser, Subcommand, ValueEnum};

use vox::backend::{self, SpeakOptions};
use vox::config::DEFAULT_BACKEND;
use vox::{clone, db, init, input};
use vox::{clone, db, init, input, mcp};

#[derive(Parser)]
#[command(name = "vox", version, about = "Voice Command — read text aloud")]
Expand Down Expand Up @@ -63,8 +63,14 @@ enum Commands {
},
/// Show usage statistics
Stats,
/// Set up AI assistant integration (Claude Code)
Init,
/// Set up AI assistant integration (Claude Code + Claude Desktop)
Init {
/// Integration mode: mcp, cli, skill, or all (default: all)
#[arg(short, long, default_value = "all")]
mode: InitMode,
},
/// Launch MCP server (stdio transport for Claude Code / Claude Desktop)
Serve,
/// Start a voice conversation with Claude (macOS only)
#[cfg(target_os = "macos")]
Chat {
Expand Down Expand Up @@ -110,6 +116,18 @@ enum CloneAction {
},
}

#[derive(Clone, ValueEnum)]
enum InitMode {
/// MCP server plugin (Claude calls vox tools natively)
Mcp,
/// CLAUDE.md instructions + Stop hook (Claude calls vox via Bash)
Cli,
/// Claude Code slash command /speak
Skill,
/// All integration modes
All,
}

#[derive(Subcommand)]
enum ConfigAction {
/// Show current preferences
Expand All @@ -132,7 +150,8 @@ fn main() -> Result<()> {
Some(Commands::Clone { action }) => handle_clone(action),
Some(Commands::Config { action }) => handle_config(action),
Some(Commands::Stats) => handle_stats(),
Some(Commands::Init) => handle_init(),
Some(Commands::Init { mode }) => handle_init(mode),
Some(Commands::Serve) => mcp::run_server(),
#[cfg(target_os = "macos")]
Some(Commands::Chat { voice, lang }) => handle_chat(voice, lang),
None => handle_speak(cli),
Expand Down Expand Up @@ -341,21 +360,79 @@ fn handle_chat(voice: Option<String>, lang: Option<String>) -> Result<()> {
chat::run_chat_loop(config)
}

fn handle_init() -> Result<()> {
let cwd = std::env::current_dir().context("Failed to get current directory")?;
let result = init::run_init(&cwd)?;
fn handle_init(mode: InitMode) -> Result<()> {
let do_cli = matches!(mode, InitMode::Cli | InitMode::All);
let do_mcp = matches!(mode, InitMode::Mcp | InitMode::All);
let do_skill = matches!(mode, InitMode::Skill | InitMode::All);

// --- CLI mode: CLAUDE.md + Stop hook ---
if do_cli {
let cwd = std::env::current_dir().context("Failed to get current directory")?;
let result = init::run_init(&cwd)?;

if !result.claude_md_written && !result.settings_written {
println!("Already configured — nothing to do.");
} else {
if result.claude_md_written {
println!("CLAUDE.md configured with vox instructions.");
println!("[cli] CLAUDE.md configured with vox instructions.");
}
if result.settings_written {
println!(".claude/settings.json configured with Stop hook.");
println!("[cli] .claude/settings.json configured with Stop hook.");
}
if !result.claude_md_written && !result.settings_written {
println!("[cli] already configured.");
}
}

// --- MCP mode: configure MCP server ---
if do_mcp {
let vox_bin = std::env::current_exe().context("cannot determine vox binary path")?;
let vox_bin_str = vox_bin.to_string_lossy().to_string();
let home = std::env::var("HOME").context("HOME not set")?;

let mcp_entry = serde_json::json!({
"command": vox_bin_str,
"args": ["serve"],
"env": {}
});

let code_path = std::path::PathBuf::from(&home).join(".claude.json");
let code_status = init::inject_mcp_server(&code_path, "vox", &mcp_entry)
.unwrap_or_else(|e| format!("error: {e}"));

let desktop_path = std::path::PathBuf::from(&home)
.join("Library/Application Support/Claude/claude_desktop_config.json");
let desktop_status = init::inject_mcp_server(&desktop_path, "vox", &mcp_entry)
.unwrap_or_else(|e| format!("error: {e}"));

println!("[mcp] Claude Code: {code_status}");
println!("[mcp] Claude Desktop: {desktop_status}");
}

// --- Skill mode: create /speak slash command ---
if do_skill {
let home = std::env::var("HOME").context("HOME not set")?;
let skills_dir = std::path::PathBuf::from(&home).join(".claude/commands");
std::fs::create_dir_all(&skills_dir).ok();

let skill_path = skills_dir.join("speak.md");
if skill_path.exists() {
println!("[skill] /speak already configured.");
} else {
std::fs::write(
&skill_path,
"Use vox to speak the following text aloud: $ARGUMENTS\n\
\n\
Call the vox_speak MCP tool if available, otherwise run:\n\
```bash\n\
vox -b say \"$ARGUMENTS\"\n\
```\n",
)
.context("cannot write skill file")?;
println!("[skill] /speak command created.");
}
}

println!();
println!("Restart Claude Code / Claude Desktop to activate.");

Ok(())
}

Expand Down
Loading
Loading