octomind 0.25.0

Session-based AI development assistant with conversational codebase interaction, multimodal vision support, built-in MCP tools, and multi-provider AI integration
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
// Copyright 2026 Muvon Un Limited
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

//! Tap management — Homebrew-style registry source list.
//!
//! Taps are Git repositories containing agent manifests.
//!
//! ## Usage
//! - `octomind tap user/repo` — clones https://github.com/user/octomind-repo
//! - `octomind tap user/repo /path/to/local` — symlinks local directory into taps dir
//! - `octomind tap` — lists all taps
//! - `octomind untap user/repo` — removes a tap
//!
//! ## Directory structure
//! - GitHub taps cloned to: `~/.local/share/octomind/taps/user/octomind-repo/`
//! - Local taps symlinked to: `~/.local/share/octomind/taps/user/octomind-repo/ -> /your/path`
//! - Manifests expected at: `<tap>/agents/<category>/<variant>.toml`
//!
//! ## Priority
//! User taps (in order added) → built-in default (muvon/tap)

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use std::process::Stdio;

/// The built-in default tap — always present as the last fallback.
pub const DEFAULT_TAP: &str = "muvon/tap";

/// A tap entry: either a GitHub repo or a local path.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Tap {
	/// Tap name in `user/repo` format.
	pub name: String,
	/// Original local path for local taps (None for GitHub taps). Stored for display only —
	/// the actual tap directory is always the standard symlink path under the taps dir.
	pub local_path: Option<String>,
}

impl Tap {
	/// Returns the GitHub URL for this tap.
	pub fn github_url(&self) -> String {
		// user/repo → https://github.com/user/octomind-repo
		let parts: Vec<&str> = self.name.split('/').collect();
		if parts.len() == 2 {
			format!("https://github.com/{}/octomind-{}", parts[0], parts[1])
		} else {
			// Fallback: treat as full URL
			self.name.clone()
		}
	}

	/// Returns the standard directory path for this tap.
	/// GitHub taps: `~/.local/share/octomind/taps/user/octomind-repo/` (git clone)
	/// Local taps:  `~/.local/share/octomind/taps/user/octomind-repo/` (symlink → local path)
	pub fn local_dir(&self) -> Result<PathBuf> {
		let parts: Vec<&str> = self.name.split('/').collect();
		if parts.len() == 2 {
			let tap_dir = crate::directories::get_octomind_data_dir()?
				.join("taps")
				.join(parts[0])
				.join(format!("octomind-{}", parts[1]));
			Ok(tap_dir)
		} else {
			anyhow::bail!("Invalid tap name format: {}", self.name);
		}
	}

	/// Returns the agents directory path for this tap.
	pub fn agents_dir(&self) -> Result<PathBuf> {
		Ok(self.local_dir()?.join("agents"))
	}

	/// Returns the deps directory path for this tap.
	pub fn deps_dir(&self) -> Result<PathBuf> {
		Ok(self.local_dir()?.join("deps"))
	}

	/// Returns the skills directory path for this tap.
	pub fn skills_dir(&self) -> Result<PathBuf> {
		Ok(self.local_dir()?.join("skills"))
	}
}

#[derive(Debug, Default, Serialize, Deserialize)]
struct TapsFile {
	#[serde(default)]
	taps: Vec<Tap>,
}

fn taps_file_path() -> Result<PathBuf> {
	Ok(crate::directories::get_octomind_data_dir()?.join("taps.toml"))
}

fn read_taps_file() -> Result<TapsFile> {
	let path = taps_file_path()?;
	if !path.exists() {
		return Ok(TapsFile::default());
	}
	let content = fs::read_to_string(&path)
		.context(format!("Failed to read taps file: {}", path.display()))?;
	toml::from_str(&content).context("Failed to parse taps.toml")
}

fn write_taps_file(taps: &TapsFile) -> Result<()> {
	let path = taps_file_path()?;
	let content = toml::to_string_pretty(taps).context("Failed to serialize taps")?;
	fs::write(&path, content).context(format!("Failed to write taps file: {}", path.display()))
}

/// Expand a path that may contain `~` or `./`.
fn expand_path(path: &str) -> Result<PathBuf> {
	if let Some(stripped) = path.strip_prefix("~/") {
		let home = dirs::home_dir().context("Cannot determine home directory")?;
		Ok(home.join(stripped))
	} else if let Some(stripped) = path.strip_prefix("./") {
		let cwd = std::env::current_dir().context("Cannot determine current directory")?;
		Ok(cwd.join(stripped))
	} else {
		Ok(PathBuf::from(path))
	}
}

/// Parse a tap argument in one of these formats:
/// - `user/repo` — GitHub tap
/// - `user/repo /path/to/local` — local tap
fn parse_tap_arg(arg: &str) -> Result<Tap> {
	let parts: Vec<&str> = arg.splitn(2, ' ').collect();
	let name = parts[0].trim().to_string();

	// Validate name format
	if !name.contains('/') || name.split('/').count() != 2 {
		anyhow::bail!("Tap name must be in 'user/repo' format, got: {}", name);
	}

	let local_path = if parts.len() == 2 {
		Some(parts[1].trim().to_string())
	} else {
		None
	};

	Ok(Tap { name, local_path })
}

/// Returns all active taps (local paths only, no network).
/// Use this for hot-path lookups (skill discovery, etc.) where taps are
/// already cloned/symlinked and git pull would add unnecessary latency.
pub fn get_taps() -> Result<Vec<Tap>> {
	let mut file = read_taps_file()?;
	file.taps.push(Tap {
		name: DEFAULT_TAP.to_string(),
		local_path: None,
	});
	Ok(file.taps)
}

/// Returns all active taps: user taps first, built-in default last.
/// Also auto-updates GitHub taps by running git pull (Homebrew-style).
pub fn load_taps() -> Result<Vec<Tap>> {
	let mut file = read_taps_file()?;

	// Ensure default tap is cloned (seamless first-time setup)
	ensure_default_tap()?;

	// Auto-update GitHub taps only (local taps are symlinks — always live)
	for tap in &file.taps {
		if tap.local_path.is_none() {
			if let Ok(tap_dir) = tap.local_dir() {
				if tap_dir.exists() {
					// Silently pull updates — don't block on failure
					let _ = git_pull(&tap_dir);
				}
			}
		}
	}

	// Built-in default is always last
	file.taps.push(Tap {
		name: DEFAULT_TAP.to_string(),
		local_path: None,
	});
	Ok(file.taps)
}

/// Ensure the default tap is cloned and updated (seamless first-time setup).
fn ensure_default_tap() -> Result<()> {
	let default_tap = Tap {
		name: DEFAULT_TAP.to_string(),
		local_path: None,
	};
	let tap_dir = default_tap.local_dir()?;
	if !tap_dir.exists() {
		let url = default_tap.github_url();
		crate::log_info!("Cloning default tap {}...", DEFAULT_TAP);
		git_clone(&url, &tap_dir)?;
	} else {
		// Silently pull updates — don't block on failure
		let _ = git_pull(&tap_dir);
	}
	Ok(())
}

/// Returns only user-added taps (excludes the built-in default).
pub fn list_taps() -> Result<Vec<Tap>> {
	Ok(read_taps_file()?.taps)
}

/// Returns all available agent tags (`category:variant`) from all active taps.
///
/// Uses only locally cached tap data — no network calls. First tap wins on duplicates
/// (same priority as `fetch_manifest`). Result is sorted alphabetically.
pub fn list_agent_tags() -> Result<Vec<String>> {
	let taps = get_taps()?;
	let mut tags: Vec<String> = Vec::new();
	let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();

	for tap in &taps {
		let agents_dir = match tap.agents_dir() {
			Ok(d) if d.exists() => d,
			_ => continue,
		};
		let category_entries = match fs::read_dir(&agents_dir) {
			Ok(e) => e,
			Err(_) => continue,
		};
		for category_entry in category_entries.flatten() {
			let category_path = category_entry.path();
			if !category_path.is_dir() {
				continue;
			}
			let category = match category_path.file_name().and_then(|n| n.to_str()) {
				Some(c) => c.to_string(),
				None => continue,
			};
			let variant_entries = match fs::read_dir(&category_path) {
				Ok(e) => e,
				Err(_) => continue,
			};
			for variant_entry in variant_entries.flatten() {
				let variant_path = variant_entry.path();
				if variant_path.extension().and_then(|e| e.to_str()) != Some("toml") {
					continue;
				}
				let variant = match variant_path.file_stem().and_then(|n| n.to_str()) {
					Some(v) => v.to_string(),
					None => continue,
				};
				let tag = format!("{category}:{variant}");
				if seen.insert(tag.clone()) {
					tags.push(tag);
				}
			}
		}
	}

	tags.sort();
	Ok(tags)
}

/// Add a tap. Clones from GitHub or creates a symlink for local taps.
///
/// Format: `user/repo` or `user/repo /path/to/local`
pub fn add_tap(arg: &str) -> Result<()> {
	let tap = parse_tap_arg(arg)?;

	if tap.name == DEFAULT_TAP {
		anyhow::bail!(
			"'{}' is the built-in default tap — it's always active and cannot be re-added",
			tap.name
		);
	}

	let mut file = read_taps_file()?;
	if file.taps.iter().any(|t| t.name == tap.name) {
		anyhow::bail!("Tap '{}' is already added", tap.name);
	}

	let tap_dir = tap.local_dir()?;

	if let Some(ref local_path) = tap.local_path {
		// Local tap: create a symlink so the tap dir always reflects the live local directory.
		let target = expand_path(local_path)?;
		if !target.exists() {
			anyhow::bail!("Local tap directory does not exist: {}", target.display());
		}
		// Create parent dirs (e.g. ~/.local/share/octomind/taps/user/)
		if let Some(parent) = tap_dir.parent() {
			fs::create_dir_all(parent).context(format!(
				"Failed to create tap parent dir: {}",
				parent.display()
			))?;
		}
		// Remove stale symlink/dir if it already exists at the target path
		if tap_dir.exists() || tap_dir.symlink_metadata().is_ok() {
			fs::remove_file(&tap_dir).context(format!(
				"Failed to remove existing tap path: {}",
				tap_dir.display()
			))?;
		}
		#[cfg(unix)]
		std::os::unix::fs::symlink(&target, &tap_dir).context(format!(
			"Failed to create symlink {} -> {}",
			tap_dir.display(),
			target.display()
		))?;
		#[cfg(windows)]
		std::os::windows::fs::symlink_dir(&target, &tap_dir).context(format!(
			"Failed to create symlink {} -> {}",
			tap_dir.display(),
			target.display()
		))?;
		crate::log_info!("Symlinked tap {} -> {}", tap.name, target.display());
	} else {
		// GitHub tap: clone or update
		if !tap_dir.exists() {
			let url = tap.github_url();
			crate::log_info!("Cloning tap {}...", tap.name);
			git_clone(&url, &tap_dir)?;
		} else {
			crate::log_info!("Tap {} already cloned, updating...", tap.name);
			git_pull(&tap_dir)?;
		}
	}

	file.taps.push(tap);
	write_taps_file(&file)?;
	Ok(())
}

/// Remove a tap by name. Also removes the symlink for local taps.
pub fn remove_tap(name: &str) -> Result<()> {
	let name = name.trim().to_string();

	if name == DEFAULT_TAP {
		anyhow::bail!(
			"'{}' is the built-in default tap and cannot be removed",
			name
		);
	}

	let mut file = read_taps_file()?;
	let before = file.taps.len();
	let removed: Vec<Tap> = file
		.taps
		.iter()
		.filter(|t| t.name == name)
		.cloned()
		.collect();
	file.taps.retain(|t| t.name != name);
	if file.taps.len() == before {
		anyhow::bail!("Tap '{}' is not in your tap list", name);
	}

	// Remove the symlink for local taps (GitHub clones are left on disk intentionally)
	for tap in &removed {
		if tap.local_path.is_some() {
			if let Ok(tap_dir) = tap.local_dir() {
				if tap_dir.symlink_metadata().is_ok() {
					let _ = fs::remove_file(&tap_dir);
				}
			}
		}
	}

	write_taps_file(&file)?;
	Ok(())
}

/// Clone a Git repository.
/// Clone a Git repository. Output is suppressed; only shown in debug mode.
fn git_clone(url: &str, dir: &std::path::Path) -> Result<()> {
	let output = std::process::Command::new("git")
		.args(["clone", "--depth", "1", url, &dir.to_string_lossy()])
		.stdout(Stdio::null())
		.stderr(Stdio::null())
		.output()
		.context("Failed to run git clone")?;

	if !output.status.success() {
		crate::log_debug!(
			"git clone failed for {}: {}",
			url,
			String::from_utf8_lossy(&output.stderr).trim()
		);
		anyhow::bail!("Failed to clone tap from {}", url);
	}
	Ok(())
}

/// Pull latest changes for a tap. Output is suppressed; only shown in debug mode.
fn git_pull(dir: &PathBuf) -> Result<()> {
	let output = std::process::Command::new("git")
		.args(["pull"])
		.current_dir(dir)
		.stdout(Stdio::null())
		.stderr(Stdio::null())
		.output()
		.context("Failed to run git pull")?;

	if !output.status.success() {
		crate::log_debug!(
			"Failed to update tap at {}: {}",
			dir.display(),
			String::from_utf8_lossy(&output.stderr).trim()
		);
	}
	Ok(())
}