// SPDX-FileCopyrightText: 2026 Andrew Hundt
// SPDX-License-Identifier: Apache-2.0
//! External client, alias, instruction, and skill integration lifecycle.
use std::env;
use std::fs;
use std::path::{Component, Path, PathBuf};
use anyhow::{anyhow, bail, Context, Result};
use clap::{Args, Subcommand, ValueEnum};
use serde_json::{json, Map, Value};
use crate::text_file_transaction::{
apply_symlink_change_after, execute_text_file_and_symlink_transaction_with,
publish_symlink_change, recover_text_file_transaction, recovery_guidance,
snapshot_symlink_image, snapshot_utf8_regular_file, transaction_recovery_required,
with_text_file_transaction_read_lock, RecoveryOutcome, SymlinkChange, SymlinkImage,
TextFileChange, TextFileImage,
};
#[cfg(test)]
use crate::text_file_transaction::{execute_text_file_transaction, publish_text_change};
use crate::util::{render_posix_shell_command, which};
// Keep the MCP identity aligned with the product and distribution slug. `aise` is the executable;
// `ai_session_search` is a language identifier and a historical client configuration key.
const SERVER_NAME: &str = "ai-session-search";
const LEGACY_SERVER_NAMES: [&str; 2] = ["ai_session_search", "aise"];
const INSTRUCTIONS_FILE: &str = "AI_SESSION_SEARCH.md";
const INSTRUCTIONS_REFERENCE: &str = "@AI_SESSION_SEARCH.md";
const LEGACY_INSTRUCTIONS_LINE: &str = "Before guessing about prior AI work, use aise MCP or run `aise messages search --help` to recover session history from Claude Code, Claude Desktop local agent, Codex, Cursor, Antigravity, Pi coding agent, Google AI Studio, and Gemini CLI by query, repo/path/file, message context, and time range.";
/// The block for a harness that registers the MCP server (and the server's own `initialize`
/// instructions). It names the same four steps as `CLI_INSTRUCTIONS_LINE` in the same order,
/// with the MCP tool for each, so an agent reads one workflow whichever surface it holds.
const INSTRUCTIONS_LINE: &str = "Before guessing about prior AI work, use AI Session Search (`aise`) via the `ai-session-search` MCP server: `list_sessions(path_prefix, when, limit)` for recent sessions by directory or period, `search_sessions(query, when, limit)` for sessions by topic, `search_messages(query, context, limit)` for the exact turn, then read it with `get_session(session_id, message_seq)`. It searches Claude Code, Claude Desktop local agent, Codex, Cursor, Antigravity, Pi coding agent, Prime Agent, Google AI Studio, and Gemini CLI by query, path, context, and time range. The installed `ai-session-search` skill has the full workflow; without MCP, `aise` on the shell does the same (`aise <command> --help`).";
/// The block for a harness that has no MCP registration for aise (Pi, Prime Agent). It leads with
/// the three CLI commands that cover the common path and names the installed skill, because the
/// MCP-first sentence above sends such an agent to a tool it cannot call.
const CLI_INSTRUCTIONS_LINE: &str = "Before guessing about prior AI work, use AI Session Search (`aise`) from the shell; on this harness the `aise` command and the installed skill are the whole integration. List recent sessions with `aise list --path <dir> --when 7d --limit 10`, find sessions by topic with `aise search \"<topic>\" --when 30d --limit 10`, find the exact turn with `aise messages search \"<phrase>\" --context 2 --limit 20`, then read it with `aise messages get <session-id> --seq <N> --context 3` or `aise show <session-id>`. It searches Claude Code, Claude Desktop local agent, Codex, Cursor, Antigravity, Pi coding agent, Prime Agent, Google AI Studio, and Gemini CLI by query, repo/path/file, message context, and time range. The installed `ai-session-search` skill documents the full workflow; `aise <command> --help` lists every current flag.";
/// SHA-256 of each instruction line an earlier release wrote and this build no longer does:
/// 1.0.0rc1's `INSTRUCTIONS_LINE`. The post-upgrade refresh replaces a block that still holds one
/// of these, or any line above, because aise wrote it; any other text is the user's. When a line
/// above changes, add the digest of its old text here.
const EARLIER_INSTRUCTION_LINE_DIGESTS: [&str; 1] =
["3efca133a9dfaf9e359dce78582c83601c4a31c832ebfd6ef1caf5aa4398cfc6"];
const INSTRUCTIONS_START: &str = "<!-- aise-instructions";
const INSTRUCTIONS_END: &str = "<!-- /aise-instructions -->";
const INSTRUCTIONS_FILE_START: &str = "<!-- ai-session-search-managed-file v1 -->";
const INSTRUCTIONS_FILE_END: &str = "<!-- /ai-session-search-managed-file -->";
/// Ownership stamp `aise` writes into every `SKILL.md` it manages.
///
/// `pub(crate)` because ownership is now *reported* by `aise skills list` as well as
/// enforced here: the same byte string has to mean the same thing on both surfaces.
pub(crate) const SKILL_MANAGED_MARKER: &str = "<!-- ai-session-search-managed-skill v1 -->";
/// Whether `text` carries the ownership marker in its exact managed-anchor position.
///
/// Quoting the marker later in prose or a code fence must never authorize an overwrite.
pub(crate) fn is_managed_skill_anchor(text: &str) -> bool {
let mut lines = text.lines();
if lines.next() != Some("---") {
return false;
}
for line in lines.by_ref() {
if line == "---" {
return lines.next() == Some(SKILL_MANAGED_MARKER);
}
}
false
}
/// One file of the managed skill, relative to the skill root.
///
/// Adding a file to the bundled skill is a row here, not a new code path: install, status, and
/// uninstall all iterate this slice.
#[derive(Debug)]
struct ManagedSkillFile {
relative_path: &'static str,
content: &'static str,
}
/// One embedded Agent Skill package managed as a unit.
#[derive(Debug)]
struct ManagedSkillPackage {
name: &'static str,
files: &'static [ManagedSkillFile],
}
/// Every file `aise` writes into the general harness-guidance skill directory.
///
/// An explicit list rather than a directory walk at build time: it is greppable, dependency-free,
/// and puts the complete set of managed paths in one auditable place, which is what makes
/// "modified or damaged" and "unmanaged extra file" answerable at all.
///
/// EVERY entry must be UTF-8 text. `text_file_transaction::snapshot_utf8_regular_file` refuses
/// non-UTF-8, so a binary asset could be embedded here yet not installed by this path.
///
/// `include_str!` resolves relative to THIS source file, so these read the crate-local mirror
/// under `rust/ai-session-search-core/skills/`, held byte-identical to the repo-root copy by
/// `tests/test_repository_contracts.py`.
/// One package rather than two: the Agent Skills specification requires a skill's `name` to match
/// its directory name, so a second package called `corrections` necessarily claims that generic
/// name in every flat skill root (`~/.claude/skills`, `~/.agents/skills`, ...), and its description
/// is loaded at startup in every session whether or not classification is ever run. The
/// specification explicitly permits any additional files beside `SKILL.md`, so message
/// classification ships here as side files instead.
const AI_SESSION_SEARCH_SKILL_FILES: &[ManagedSkillFile] = &[
ManagedSkillFile {
relative_path: "SKILL.md",
content: include_str!("../skills/ai-session-search/SKILL.md"),
},
ManagedSkillFile {
relative_path: crate::skill_catalog::CAPABILITY_FILE,
content: include_str!("../skills/ai-session-search/aise-capability.toml"),
},
ManagedSkillFile {
relative_path: "references/recover-prior-work-with-evidence.md",
content: include_str!(
"../skills/ai-session-search/references/recover-prior-work-with-evidence.md"
),
},
ManagedSkillFile {
relative_path: "references/message-classification.md",
content: include_str!("../skills/ai-session-search/references/message-classification.md"),
},
ManagedSkillFile {
relative_path: "references/search-costs-and-receipts.md",
content: include_str!(
"../skills/ai-session-search/references/search-costs-and-receipts.md"
),
},
];
pub(crate) const AI_SESSION_SEARCH_SKILL_NAME: &str = "ai-session-search";
const AI_SESSION_SEARCH_SKILL_PACKAGE: ManagedSkillPackage = ManagedSkillPackage {
name: AI_SESSION_SEARCH_SKILL_NAME,
files: AI_SESSION_SEARCH_SKILL_FILES,
};
const MANAGED_SKILL_PACKAGES: &[&ManagedSkillPackage] = &[&AI_SESSION_SEARCH_SKILL_PACKAGE];
/// Sibling package retired when message classification moved into this one.
///
/// Removed as a whole skill root rather than file by file: it was installed with its own
/// `SKILL.md` anchor, so ownership is decided by that anchor's marker and the install manifest,
/// exactly as uninstall decides it.
const RETIRED_SKILL_PACKAGE_NAME: &str = "corrections";
const RETIRED_SKILL_PACKAGE: ManagedSkillPackage = ManagedSkillPackage {
name: RETIRED_SKILL_PACKAGE_NAME,
files: AI_SESSION_SEARCH_SKILL_FILES,
};
/// Paths written by the prerelease layout before message classification became its own package.
///
/// Their bytes are intentionally not embedded. A valid install manifest is the ownership
/// authority, and migration removes a path only when the current bytes still match its recorded
/// digest.
const HISTORICAL_CORRECTION_PATHS: &[&str] = &[
"corrections/policy.toml",
"references/corrections-policy.md",
];
/// `SKILL.md` is the ownership anchor: the marker lives in it, so it decides whether `aise` may
/// write ANY file in the directory. Kept as a named constant because three call sites depend on
/// it being the same file.
const SKILL_ANCHOR_FILE: &str = "SKILL.md";
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
pub enum McpClient {
All,
Claude,
Codex,
Gemini,
Antigravity,
Pi,
PrimeAgent,
Cursor,
Windsurf,
Vscode,
Zed,
Opencode,
Openclaw,
Kilocode,
}
#[derive(Debug, Default, Args)]
pub struct IntegrationTargetsArgs {
/// Harness/client to include. Repeat for multiple selections; omit for all detected harnesses.
#[arg(long = "client", value_enum, default_value = "all")]
pub clients: Vec<McpClient>,
/// Harness/client to exclude from the selected set. Repeat for multiple selections.
/// Omit to exclude none.
#[arg(long = "exclude-client", value_enum)]
pub excluded_clients: Vec<McpClient>,
/// Extra JSON config path using the common { "mcpServers": ... } shape. Repeatable;
/// omit to register only the clients discovery finds.
#[arg(long = "json-mcp-config")]
pub json_mcp_configs: Vec<PathBuf>,
/// Extra VS Code-style JSON config path using { "servers": ... }. Repeatable; omit to
/// register only the clients discovery finds.
#[arg(long = "vscode-config")]
pub vscode_configs: Vec<PathBuf>,
/// Extra Zed JSON config path using { "context_servers": ... }. Repeatable; omit to
/// register only the clients discovery finds.
#[arg(long = "zed-config")]
pub zed_configs: Vec<PathBuf>,
/// Extra OpenCode JSON config path using { "mcp": ... }. Repeatable; omit to register
/// only the clients discovery finds.
#[arg(long = "opencode-config")]
pub opencode_configs: Vec<PathBuf>,
/// Extra Codex-style TOML config path using [mcp_servers.ai-session-search]. Repeatable;
/// omit to register only the clients discovery finds.
#[arg(long = "codex-config")]
pub codex_configs: Vec<PathBuf>,
/// Extra CLAUDE.md path where @AI_SESSION_SEARCH.md is managed. Repeatable; omit to
/// manage only the discovered instruction files.
#[arg(long = "claude-md")]
pub claude_md_paths: Vec<PathBuf>,
/// Extra GEMINI.md path where the managed AI Session Search (`aise`) note is managed.
/// Repeatable; omit to manage only the discovered instruction files.
#[arg(long = "gemini-md")]
pub gemini_md_paths: Vec<PathBuf>,
/// Extra AGENTS.md path where the managed AI Session Search (`aise`) note is managed.
/// Repeatable; omit to manage only the discovered instruction files.
#[arg(long = "agents-md")]
pub agents_md_paths: Vec<PathBuf>,
/// Exact managed skill DIRECTORY to install into, ending in `ai-session-search`,
/// e.g. `~/.config/myagent/skills/ai-session-search`.
///
/// The directory itself, not a file inside it. Each skill package keeps SKILL.md, its optional
/// adjacent aise-capability.toml, and references together. Repeat for several exact package roots.
/// Distinct from `aise skills <name-or-path>`, which runs a selected deterministic capability
/// rather than naming an installation destination. Omit to install into the discovered
/// skill roots.
#[arg(long = "skill-root", value_name = "DIR")]
pub skill_roots: Vec<PathBuf>,
}
impl IntegrationTargetsArgs {
fn resolve(
&self,
no_instructions: bool,
no_skill: bool,
) -> Result<(Vec<Target>, Vec<InstructionTarget>, Vec<SkillTarget>)> {
assemble_selected_targets(McpTargetSelection {
clients: &self.clients,
excluded_clients: &self.excluded_clients,
no_instructions,
json_mcp_configs: &self.json_mcp_configs,
vscode_configs: &self.vscode_configs,
zed_configs: &self.zed_configs,
opencode_configs: &self.opencode_configs,
codex_configs: &self.codex_configs,
claude_md_paths: &self.claude_md_paths,
gemini_md_paths: &self.gemini_md_paths,
agents_md_paths: &self.agents_md_paths,
no_skill,
skill_roots: &self.skill_roots,
})
}
}
#[derive(Debug, Args)]
#[command(
after_help = "Default install configures executable aliases plus each detected harness's supported integrations. MCP-capable clients receive the local stdio server; Pi and Prime Agent instead receive their native skill and AGENTS.md guidance because Pi has no MCP client and Prime Agent accepts only remote HTTP MCP integrations. Supported selectors: Claude Code/Desktop, ChatGPT Codex desktop and CLI/IDE, Gemini, Antigravity App/IDE/CLI, Pi, Prime Agent, Cursor, Windsurf, VS Code, Zed, OpenCode, OpenClaw, and KiloCode. Config shapes use the `ai-session-search` server key: mcpServers.ai-session-search, [mcp_servers.ai-session-search], VS Code servers.ai-session-search, Zed context_servers.ai-session-search, or OpenCode mcp.ai-session-search as appropriate. Reinstall migrates the historical `ai_session_search` and `aise` keys without leaving duplicate servers. Use --no-mcp, --no-aliases, --no-instructions, or --no-skill to omit one component; --client selects specific clients; --dry-run previews every write. Canonical skill packages live under ~/.ai-session-search/skills, beside the app config and integration manifest. Harness-native discovery entries link to them from ~/.claude/skills, ~/.agents/skills, ~/.gemini/skills, ~/.gemini/config/skills, ~/.gemini/antigravity-cli/skills, ~/.pi/agent/skills, and ~/.prime/agent/skills. Repeat --skill-root for exact additional package destinations. After a non-dry-run installation commits, aise starts best-effort session index preparation in the background; run `aise doctor` to check readiness and freshness."
)]
pub struct IntegrationInstallArgs {
#[command(flatten)]
pub targets: IntegrationTargetsArgs,
/// Print planned changes without writing files.
#[arg(long)]
pub dry_run: bool,
/// Executable path to store in client configs. Omit to resolve the first `aise` on the
/// installer's PATH and store its absolute path, so GUI clients and shells launch the same
/// installation. Pass an explicit path to select a different installation.
#[arg(long)]
pub binary: Option<PathBuf>,
/// Do not add AI Session Search MCP registrations to client configuration files.
#[arg(long)]
pub no_mcp: bool,
/// Do not add AI Session Search (`aise`) guidance to CLAUDE.md, AGENTS.md, or GEMINI.md.
#[arg(long)]
pub no_instructions: bool,
/// Do not install the AI Session Search skill for any selected skill-capable harness.
#[arg(long)]
pub no_skill: bool,
/// Do not create the `aisearch` and `ai_session_search` executable aliases beside `aise`.
#[arg(long)]
pub no_aliases: bool,
#[command(flatten)]
pub transaction: IntegrationTransactionArgs,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum IntegrationInstallOutcome {
Configured,
Previewed,
NoTargets,
}
impl IntegrationInstallOutcome {
pub(crate) fn should_start_initial_indexing(self) -> bool {
matches!(self, Self::Configured)
}
}
#[derive(Debug, Args)]
#[command(
after_help = "Status checks MCP registrations, executable aliases, managed instructions, and installed AI Session Search skills by default. Use --no-mcp, --no-aliases, --no-instructions, or --no-skill to omit one component."
)]
pub struct IntegrationStatusArgs {
#[command(flatten)]
pub targets: IntegrationTargetsArgs,
/// Do not inspect MCP registrations in client configuration files.
#[arg(long)]
pub no_mcp: bool,
/// Do not inspect CLAUDE.md, AGENTS.md, or GEMINI.md instruction files.
#[arg(long)]
pub no_instructions: bool,
/// Do not inspect installed AI Session Search skills.
#[arg(long)]
pub no_skill: bool,
/// Do not inspect the `aisearch` and `ai_session_search` executable aliases.
#[arg(long)]
pub no_aliases: bool,
/// Output format: table for people or JSON for scripts. Each JSON entry has `component`,
/// `client`, `path`, `state`, and `current`, which is true when it matches this build.
#[arg(long, value_enum, default_value_t = crate::cli::ReportOutputFormat::Table)]
pub format: crate::cli::ReportOutputFormat,
#[command(flatten)]
pub transaction: IntegrationTransactionArgs,
}
#[derive(Debug, Default, Args)]
#[command(
after_help = "Uninstall removes owned MCP registrations, executable aliases, managed instructions, and AI Session Search skills by default while preserving the `aise` executable, database, cache, other client configuration, and user-authored files. Use --keep-mcp, --keep-aliases, --keep-instructions, or --keep-skill to preserve one component.\n\nA skill directory is removed only when every file in it is exactly what install recorded. If anything differs, or holds a file aise did not write, the WHOLE directory is preserved and each reason is reported. --force-full-cleanup deletes everything under one exact --skill-root, including your own edits."
)]
pub struct IntegrationUninstallArgs {
#[command(flatten)]
pub targets: IntegrationTargetsArgs,
/// Print planned changes without writing files.
#[arg(long)]
pub dry_run: bool,
/// Preserve MCP registrations while removing other selected owned components.
#[arg(long)]
pub keep_mcp: bool,
/// Preserve AI Session Search (`aise`) guidance while removing MCP registrations.
#[arg(long)]
pub keep_instructions: bool,
/// Preserve installed AI Session Search skills while removing other owned components.
#[arg(long)]
pub keep_skill: bool,
/// Also delete your own edits and any files you added under the skill directory.
/// This cannot be undone.
///
/// Normal uninstall preserves the whole skill directory whenever anything in it differs from
/// what aise installed. This removes everything under one exact `--skill-root`, including
/// files aise never wrote. It requires that exact root, refuses detected-client fan-out, and
/// never touches your index, cache, or configuration. Pair with `--dry-run` first.
#[arg(long)]
pub force_full_cleanup: bool,
/// Preserve executable aliases while removing MCP registrations and managed instructions.
#[arg(long)]
pub keep_aliases: bool,
#[command(flatten)]
pub transaction: IntegrationTransactionArgs,
}
#[derive(Debug, Clone, Default, Args)]
pub struct IntegrationTransactionArgs {
/// Durable recovery receipt. Defaults beside the selected ai-session-search config file.
/// Omit to use that default location.
#[arg(long, value_name = "PATH")]
pub transaction_receipt: Option<PathBuf>,
}
#[derive(Debug, Clone, Args)]
pub struct IntegrationRecoverArgs {
#[command(flatten)]
pub transaction: IntegrationTransactionArgs,
}
#[derive(Debug, Subcommand)]
pub enum McpCmd {
/// Serve MCP JSON-RPC over standard input/output.
///
/// Use `aise integrations install` to register this server with a client, or
/// `aise integrations status` to see which clients already have it.
Serve,
/// Measure the advertised tool catalogue against every client limit it can silently breach.
///
/// Hidden because it answers a maintainer's question about this build's emitted artifacts,
/// not a user's question about their sessions. `aise mcp schema-budget --help` still works,
/// and the release gate runs it against the shipped executable so the artifact measured is
/// the artifact published.
#[command(hide = true)]
SchemaBudget(crate::mcp_schema_budget::SchemaBudgetArgs),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ConfigFormat {
JsonMcpServers,
CodexToml,
VscodeServers,
ZedContextServers,
OpenCode,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ClientPlatform {
Macos,
Linux,
Windows,
}
#[derive(Debug, Clone)]
struct ClientLayout {
home: PathBuf,
config: PathBuf,
platform: ClientPlatform,
}
impl ClientLayout {
fn new(home: PathBuf, config: PathBuf, platform: ClientPlatform) -> Self {
Self {
home,
config,
platform,
}
}
fn from_discovered_dirs(
home: Option<PathBuf>,
config: Option<PathBuf>,
platform: ClientPlatform,
) -> Result<Self> {
let home = home.ok_or_else(missing_home_error)?;
let config = config.unwrap_or_else(|| match platform {
ClientPlatform::Macos => home.join("Library").join("Application Support"),
ClientPlatform::Linux => home.join(".config"),
ClientPlatform::Windows => home.join("AppData").join("Roaming"),
});
Ok(Self::new(home, config, platform))
}
fn discover() -> Result<Self> {
let platform = if cfg!(target_os = "macos") {
ClientPlatform::Macos
} else if cfg!(windows) {
ClientPlatform::Windows
} else {
ClientPlatform::Linux
};
Self::from_discovered_dirs(dirs::home_dir(), dirs::config_dir(), platform)
}
fn claude_desktop_config_dir(&self) -> PathBuf {
if self.platform == ClientPlatform::Macos {
self.home
.join("Library")
.join("Application Support")
.join("Claude")
} else {
self.config.join("Claude")
}
}
fn vscode_config_dir(&self) -> PathBuf {
if self.platform == ClientPlatform::Macos {
self.home
.join("Library")
.join("Application Support")
.join("Code")
.join("User")
} else {
self.config.join("Code").join("User")
}
}
fn zed_config_dir(&self) -> PathBuf {
if self.platform == ClientPlatform::Macos {
self.home
.join("Library")
.join("Application Support")
.join("Zed")
} else {
self.config.join("zed")
}
}
}
#[derive(Debug, Clone)]
struct Target {
label: &'static str,
path: PathBuf,
format: ConfigFormat,
detect_paths: Vec<PathBuf>,
detect_binaries: Vec<&'static str>,
}
#[derive(Debug, Clone)]
struct InstructionTarget {
label: &'static str,
path: PathBuf,
format: InstructionFormat,
detect_paths: Vec<PathBuf>,
detect_binaries: Vec<&'static str>,
}
#[derive(Debug, Clone)]
struct SkillTarget {
label: &'static str,
/// The skill DIRECTORY, not one file inside it. A skill is a standard-shaped directory, and
/// naming the file would make "is this whole tree ours?" unanswerable.
root: PathBuf,
package: &'static ManagedSkillPackage,
/// Harness discovery entries which point at this app-owned canonical package.
///
/// These are deliberately separate from `root`: `root` lives in the platform config
/// namespace and is owned by aise, while these paths live in harness-owned namespaces.
discovery_links: Vec<PathBuf>,
detect_paths: Vec<PathBuf>,
detect_binaries: Vec<&'static str>,
}
impl SkillTarget {
/// The file whose managed marker decides ownership of this whole directory.
fn anchor(&self) -> PathBuf {
self.root.join(SKILL_ANCHOR_FILE)
}
/// Every managed path under this root, in package declaration order.
#[cfg(test)]
fn managed_paths(&self) -> impl Iterator<Item = (PathBuf, &'static str)> + '_ {
self.package
.files
.iter()
.map(|file| (self.root.join(file.relative_path), file.content))
}
}
#[derive(Debug)]
struct UninstallPlan {
mutations: Vec<PlannedFileMutation>,
/// Per skill target: reasons its whole directory was preserved. Empty means it was removed.
preserved_skills: Vec<Vec<String>>,
/// Directories to try pruning after a successful transaction, deepest first.
prune_skill_directories: Vec<PathBuf>,
changed_targets: Vec<bool>,
changed_instructions: Vec<bool>,
changed_skills: Vec<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum PlannedFileMutation {
Write {
path: PathBuf,
original: Option<String>,
content: String,
},
Remove {
path: PathBuf,
original: String,
},
}
impl PlannedFileMutation {
fn path(&self) -> &Path {
match self {
Self::Write { path, .. } | Self::Remove { path, .. } => path,
}
}
fn as_change(&self) -> TextFileChange {
match self {
Self::Write {
path,
original,
content,
} => TextFileChange::write(
path.clone(),
original.clone().map(TextFileImage::new),
content.clone(),
),
Self::Remove { path, original } => {
TextFileChange::remove(path.clone(), TextFileImage::new(original.clone()))
}
}
}
fn is_noop(&self) -> bool {
matches!(
self,
Self::Write {
original: Some(original),
content,
..
} if original == content
)
}
}
fn read_optional_utf8_regular_file(path: &Path) -> Result<Option<String>> {
Ok(snapshot_utf8_regular_file(path)?.map(|image| image.text().to_string()))
}
fn planned_write(path: &Path, original: &Option<String>, content: String) -> PlannedFileMutation {
PlannedFileMutation::Write {
path: path.to_path_buf(),
original: original.clone(),
content,
}
}
fn normalize_planned_mutations(
mutations: impl IntoIterator<Item = PlannedFileMutation>,
) -> Result<Vec<PlannedFileMutation>> {
let mut positions = std::collections::HashMap::<PathBuf, usize>::new();
let mut normalized = Vec::new();
for mutation in mutations {
if mutation.is_noop() {
continue;
}
let path = mutation.path().to_path_buf();
if let Some(position) = positions.get(&path).copied() {
if normalized[position] != mutation {
return Err(anyhow!(
"multiple integration transformations produce conflicting changes for {}; pass each destination once",
path.display()
));
}
} else {
positions.insert(path, normalized.len());
normalized.push(mutation);
}
}
Ok(normalized)
}
#[cfg(test)]
fn publish_planned_mutations(mutations: &[PlannedFileMutation]) -> Result<()> {
for mutation in mutations {
publish_text_change(&mutation.as_change())?;
}
Ok(())
}
#[cfg(test)]
fn execute_planned_transaction(receipt: &Path, mutations: &[PlannedFileMutation]) -> Result<()> {
let changes = mutations
.iter()
.map(PlannedFileMutation::as_change)
.collect::<Vec<_>>();
execute_text_file_transaction(receipt, &changes)
}
fn execute_planned_transaction_with_links<F>(
receipt: &Path,
mutations: &[PlannedFileMutation],
links: &[SymlinkChange],
publish_link: F,
) -> Result<()>
where
F: FnMut(usize, &SymlinkChange) -> Result<()>,
{
let changes = mutations
.iter()
.map(PlannedFileMutation::as_change)
.collect::<Vec<_>>();
execute_text_file_and_symlink_transaction_with(receipt, &changes, links, publish_link)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum InstructionFormat {
ClaudeImport,
/// One managed inline block whose text leads with the MCP tools, for a harness that also
/// receives an MCP registration from `aise integrations install`.
InlineBlock,
/// The same managed inline block, but its text leads with the CLI, for a harness that gets no
/// MCP registration (Pi and Prime Agent: `mcp_targets_for_layout` returns nothing for them).
/// Telling such an agent to call `search_sessions` sends it to a tool it does not have; the
/// observed cost was repeated `aise ... --help` probes before any search ran.
InlineCliBlock,
}
impl InstructionFormat {
/// The sentence this target's managed block carries.
fn line(self) -> &'static str {
match self {
Self::ClaudeImport | Self::InlineBlock => INSTRUCTIONS_LINE,
Self::InlineCliBlock => CLI_INSTRUCTIONS_LINE,
}
}
}
#[derive(Clone, Copy)]
struct McpTargetSelection<'a> {
clients: &'a [McpClient],
excluded_clients: &'a [McpClient],
no_instructions: bool,
json_mcp_configs: &'a [PathBuf],
vscode_configs: &'a [PathBuf],
zed_configs: &'a [PathBuf],
opencode_configs: &'a [PathBuf],
codex_configs: &'a [PathBuf],
claude_md_paths: &'a [PathBuf],
gemini_md_paths: &'a [PathBuf],
agents_md_paths: &'a [PathBuf],
no_skill: bool,
skill_roots: &'a [PathBuf],
}
fn assemble_selected_targets(
selection: McpTargetSelection<'_>,
) -> Result<(Vec<Target>, Vec<InstructionTarget>, Vec<SkillTarget>)> {
let layout = ClientLayout::discover()?;
let (clients, detected_only) =
resolve_client_selection(selection.clients, selection.excluded_clients)?;
let mut targets = clients
.iter()
.copied()
.flat_map(|client| targets_for_layout(client, &layout))
.filter(|target| !detected_only || target_detected(target))
.chain(custom_targets(
selection.json_mcp_configs,
selection.vscode_configs,
selection.zed_configs,
selection.opencode_configs,
selection.codex_configs,
)?)
.collect::<Vec<_>>();
dedupe_config_targets(&mut targets)?;
let mut instruction_targets = if selection.no_instructions {
Vec::new()
} else {
clients
.iter()
.copied()
.flat_map(|client| instruction_targets_for_layout(client, &layout))
.filter(|target| !detected_only || instruction_detected(target))
.chain(custom_instruction_targets(
selection.claude_md_paths,
selection.gemini_md_paths,
selection.agents_md_paths,
)?)
.collect::<Vec<_>>()
};
dedupe_instruction_targets(&mut instruction_targets)?;
let mut skill_targets = if selection.no_skill {
Vec::new()
} else {
clients
.iter()
.copied()
.flat_map(|client| skill_targets_for_layout(client, &layout))
.filter(|target| !detected_only || skill_target_detected(target))
.chain(custom_skill_targets(selection.skill_roots)?)
.collect::<Vec<_>>()
};
dedupe_skill_targets(&mut skill_targets)?;
Ok((targets, instruction_targets, skill_targets))
}
const CONCRETE_CLIENTS: [McpClient; 13] = [
McpClient::Claude,
McpClient::Codex,
McpClient::Gemini,
McpClient::Antigravity,
McpClient::Pi,
McpClient::PrimeAgent,
McpClient::Cursor,
McpClient::Windsurf,
McpClient::Vscode,
McpClient::Zed,
McpClient::Opencode,
McpClient::Openclaw,
McpClient::Kilocode,
];
fn resolve_client_selection(
included: &[McpClient],
excluded: &[McpClient],
) -> Result<(Vec<McpClient>, bool)> {
if excluded.contains(&McpClient::All) {
bail!("--exclude-client all is invalid; select no clients by using only explicit custom paths");
}
let include_all = included.contains(&McpClient::All);
if include_all && included.len() != 1 {
bail!("--client all cannot be combined with another --client value");
}
let candidates = if include_all || included.is_empty() {
CONCRETE_CLIENTS.to_vec()
} else {
included.to_vec()
};
let mut selected = Vec::new();
for client in candidates {
if client != McpClient::All && !excluded.contains(&client) && !selected.contains(&client) {
selected.push(client);
}
}
Ok((selected, include_all || included.is_empty()))
}
fn dedupe_config_targets(targets: &mut Vec<Target>) -> Result<()> {
let mut seen = std::collections::HashMap::<PathBuf, ConfigFormat>::new();
let mut conflict = None;
targets.retain(|target| match seen.get(&target.path) {
None => {
seen.insert(target.path.clone(), target.format);
true
}
Some(format) if *format == target.format => false,
Some(first_format) => {
conflict = Some((target.path.clone(), *first_format, target.format));
false
}
});
if let Some((path, first_format, second_format)) = conflict {
bail!(
"MCP destination {} was selected with incompatible config formats \
({first_format:?} and {second_format:?}); pass it through exactly one \
format-specific option",
path.display()
);
}
Ok(())
}
fn dedupe_instruction_targets(targets: &mut Vec<InstructionTarget>) -> Result<()> {
let mut seen = std::collections::HashMap::<PathBuf, InstructionFormat>::new();
let mut conflict = None;
targets.retain(|target| match seen.get(&target.path) {
None => {
seen.insert(target.path.clone(), target.format);
true
}
Some(format) if *format == target.format => false,
Some(_) => {
conflict = Some(target.path.clone());
false
}
});
if let Some(path) = conflict {
bail!(
"instruction destination {} was selected with incompatible Markdown ownership formats; pass it through exactly one format-specific option",
path.display()
);
}
Ok(())
}
/// Drop true duplicates and reject incompatible ones, matching [`dedupe_config_targets`] and
/// [`dedupe_instruction_targets`]. Those key `(path, format)`; skills key `(path, label)`, because
/// [`SkillTarget`] carries a label rather than a format. Reaching the same root twice under one
/// label is a genuine duplicate and is dropped silently; the same root claimed by two different
/// labels means one destination was selected two ways, which the caller must resolve.
fn dedupe_skill_targets(targets: &mut Vec<SkillTarget>) -> Result<()> {
let mut seen = std::collections::HashMap::<PathBuf, usize>::new();
let mut conflict = None;
let mut deduped: Vec<SkillTarget> = Vec::new();
for target in targets.drain(..) {
match seen.get(&target.root).copied() {
None => {
seen.insert(target.root.clone(), deduped.len());
deduped.push(target);
}
Some(index)
if deduped[index].label == target.label
&& deduped[index].package.name == target.package.name =>
{
for link in target.discovery_links {
if !deduped[index].discovery_links.contains(&link) {
deduped[index].discovery_links.push(link);
}
}
for path in target.detect_paths {
if !deduped[index].detect_paths.contains(&path) {
deduped[index].detect_paths.push(path);
}
}
for binary in target.detect_binaries {
if !deduped[index].detect_binaries.contains(&binary) {
deduped[index].detect_binaries.push(binary);
}
}
}
Some(index) => {
conflict = Some((target.root.clone(), deduped[index].label, target.label));
}
}
}
*targets = deduped;
if let Some((path, first_label, second_label)) = conflict {
bail!(
"skill destination {} was selected as both {first_label} and {second_label}; \
pass it through exactly one of those options",
path.display()
);
}
Ok(())
}
pub(crate) fn install_with_receipt(
args: IntegrationInstallArgs,
default_receipt: &Path,
) -> Result<IntegrationInstallOutcome> {
let (mut targets, instruction_targets, mut skill_targets) =
args.targets.resolve(args.no_instructions, args.no_skill)?;
rebase_automatic_skill_roots(&mut skill_targets, default_receipt);
if args.no_mcp {
targets.clear();
}
let has_mcp_targets = !targets.is_empty();
let aliases = if args.no_aliases {
None
} else {
Some(crate::executable_alias::ExecutableAliases::discover()?)
};
if let Some(aliases) = &aliases {
aliases.preflight_install()?;
}
if targets.is_empty()
&& instruction_targets.is_empty()
&& skill_targets.is_empty()
&& aliases.is_none()
{
println!(
"No supported MCP client config was detected. Use --client or a custom config path to create one."
);
return Ok(IntegrationInstallOutcome::NoTargets);
}
let binary = if has_mcp_targets {
resolve_mcp_binary(args.binary.as_deref())?
} else {
PathBuf::new()
};
// The manifest lives beside the resolved config, which `default_receipt` already sits next
// to, so both durable records land in one place rather than two.
let manifest = crate::skill_manifest::manifest_path(default_receipt);
let retired_skill_roots = plan_retire_sibling_package(
&crate::skill_manifest::load_manifest(&manifest)?,
&skill_targets,
)?
.retired_roots;
let mutations = preflight_install(
&targets,
&instruction_targets,
&skill_targets,
&binary,
Some(&manifest),
)?;
let skill_manifest = crate::skill_manifest::load_manifest(&manifest)?;
let skill_file_changes = skill_targets
.iter()
.map(|target| {
let migration = plan_migrate_discovery_copies(target, Some(&manifest))?;
let package_changes = plan_refresh_skill_files(target, &skill_manifest, false)?
.iter()
.any(|mutation| !mutation.is_noop());
Ok(package_changes || !migration.mutations.is_empty())
})
.collect::<Result<Vec<_>>>()?;
let retired_discovery_links = plan_retire_sibling_discovery_links(&skill_targets)?;
let mut planned_discovery_links = retired_discovery_links
.iter()
.map(|(link, expected)| SymlinkChange::remove(link.clone(), expected.clone()))
.collect::<Vec<_>>();
planned_discovery_links.extend(plan_install_skill_discovery_links(&skill_targets)?);
let changed_discovery_paths = planned_discovery_links
.iter()
.map(|change| change.path.clone())
.collect::<std::collections::HashSet<_>>();
let discovery_link_changes = skill_targets
.iter()
.map(|target| {
target
.discovery_links
.iter()
.map(|link| changed_discovery_paths.contains(link))
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
let alias_guard = if args.dry_run {
None
} else {
aliases.as_ref().map(|value| value.install()).transpose()?
};
if !args.dry_run {
let receipt = selected_transaction_receipt(&args.transaction, default_receipt)?;
execute_planned_transaction_with_links(
&receipt,
&mutations,
&planned_discovery_links,
|_, change| publish_install_skill_discovery_link(change, &skill_targets),
)?;
prune_retired_skill_directories(&retired_skill_roots);
}
if let Some(guard) = alias_guard {
guard.commit();
}
if let Some(aliases) = &aliases {
for line in aliases.install_lines(args.dry_run)? {
println!("{line}");
}
}
for target in targets {
if args.dry_run {
println!(
"dry-run: would upsert {} MCP server in {}",
target.label,
target.path.display()
);
} else {
println!(
"configured {} MCP server in {}",
target.label,
target.path.display()
);
}
}
for target in instruction_targets {
if args.dry_run {
println!(
"dry-run: would upsert {} instruction guidance in {}",
target.label,
target.path.display()
);
} else {
println!(
"configured {} instruction guidance in {}",
target.label,
target.path.display()
);
}
}
for (link, expected) in &retired_discovery_links {
if args.dry_run {
println!(
"dry-run: would remove retired skill discovery link {} -> {}",
link.display(),
expected.display()
);
} else {
println!(
"removed retired skill discovery link {} -> {}",
link.display(),
expected.display()
);
}
}
for ((target, file_changed), link_changes) in skill_targets
.into_iter()
.zip(skill_file_changes)
.zip(discovery_link_changes)
{
if args.dry_run && file_changed {
println!(
"dry-run: would install {} skill at {}",
target.label,
target.root.display()
);
} else if file_changed {
println!(
"installed {} skill at {}",
target.label,
target.root.display()
);
}
for (link, changed) in target.discovery_links.iter().zip(link_changes) {
if !changed {
continue;
}
if args.dry_run {
println!(
"dry-run: would link skill discovery entry {} -> {}",
link.display(),
target.root.display()
);
} else {
println!(
"linked skill discovery entry {} -> {}",
link.display(),
target.root.display()
);
}
}
}
if args.dry_run {
println!("dry-run: no files were modified");
} else if has_mcp_targets {
println!("Restart your MCP client to load AI Session Search (`aise`).");
}
Ok(if args.dry_run {
IntegrationInstallOutcome::Previewed
} else {
IntegrationInstallOutcome::Configured
})
}
fn plan_install_skill_discovery_links(targets: &[SkillTarget]) -> Result<Vec<SymlinkChange>> {
let mut changes = Vec::new();
for target in targets {
for link in &target.discovery_links {
match snapshot_symlink_image(link)
.with_context(|| format!("inspect skill discovery entry {}", link.display()))?
{
SymlinkImage::Absent => {
changes.push(SymlinkChange::install(link.clone(), target.root.clone()));
}
SymlinkImage::Directory => {
changes.push(SymlinkChange::replace_directory(
link.clone(),
target.root.clone(),
));
}
SymlinkImage::Link { target: existing } => {
let existing = existing.to_path_buf()?;
if existing == target.root {
continue;
}
bail!(
"skill discovery entry {} already points to {}; preserved it instead of \
replacing it with the canonical package at {}",
link.display(),
existing.display(),
target.root.display()
);
}
}
}
}
Ok(changes)
}
fn publish_install_skill_discovery_link(
change: &SymlinkChange,
targets: &[SkillTarget],
) -> Result<()> {
if matches!(&change.before, SymlinkImage::Directory) {
let target = targets
.iter()
.find(|target| target.discovery_links.contains(&change.path))
.ok_or_else(|| {
anyhow!(
"no managed skill target owns discovery link {}",
change.path.display()
)
})?;
if snapshot_symlink_image(&change.path)? != SymlinkImage::Directory {
bail!(
"{} changed after discovery-link preflight; refusing to overwrite concurrent edits",
change.path.display()
);
}
let mut legacy = target.clone();
legacy.root = change.path.clone();
legacy.discovery_links.clear();
for directory in managed_skill_directories(&legacy) {
match fs::remove_dir(&directory) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) if error.kind() == std::io::ErrorKind::DirectoryNotEmpty => {
bail!(
"legacy skill directory {} changed during install; the canonical package remains installed at {}, and no discovery link replaced the changed directory",
change.path.display(),
target.root.display()
)
}
Err(error) => {
return Err(error).with_context(|| {
format!(
"remove emptied legacy skill directory {}",
directory.display()
)
})
}
}
}
apply_symlink_change_after(change)
} else {
publish_symlink_change(change)
}
}
#[cfg(all(test, unix))]
fn create_directory_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
std::os::unix::fs::symlink(target, link)
}
#[cfg(all(test, windows))]
fn create_directory_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
std::os::windows::fs::symlink_dir(target, link)
}
pub(crate) fn status_with_receipt(
args: IntegrationStatusArgs,
default_receipt: &Path,
) -> Result<()> {
let receipt = selected_transaction_receipt(&args.transaction, default_receipt)?;
let (mut targets, instruction_targets, mut skill_targets) =
args.targets.resolve(args.no_instructions, args.no_skill)?;
rebase_automatic_skill_roots(&mut skill_targets, default_receipt);
if args.no_mcp {
targets.clear();
}
let aliases = if args.no_aliases {
None
} else {
Some(crate::executable_alias::ExecutableAliases::discover()?)
};
let mut entries = with_text_file_transaction_read_lock(&receipt, || {
ensure_no_pending_transaction(&receipt)?;
let mut entries =
Vec::with_capacity(targets.len() + instruction_targets.len() + skill_targets.len());
for target in &targets {
entries.push(IntegrationStatusEntry::new(
StatusComponent::Mcp,
Some(target.label),
&target.path,
status_target(target)?,
));
}
for target in &instruction_targets {
entries.push(IntegrationStatusEntry::new(
StatusComponent::Instructions,
Some(target.label),
&target.path,
status_instruction_file(target)?,
));
}
// Read once for the whole report: reloading per target would let two lines disagree if
// something rewrote the manifest mid-run.
let skill_manifest = crate::skill_manifest::load_manifest(
&crate::skill_manifest::manifest_path(default_receipt),
)?;
for target in &skill_targets {
entries.push(IntegrationStatusEntry::new(
StatusComponent::Skill,
Some(target.label),
&target.root,
status_skill_file(target, &skill_manifest)?,
));
for link in &target.discovery_links {
let (state, current) = status_skill_discovery_link(link, &target.root)?;
entries.push(IntegrationStatusEntry {
current,
..IntegrationStatusEntry::new(
StatusComponent::SkillDiscoveryLink,
Some(target.label),
link,
state,
)
});
}
}
Ok(entries)
})?;
if let Some(aliases) = aliases {
for (alias, state) in aliases.statuses()? {
entries.push(IntegrationStatusEntry::new(
StatusComponent::ExecutableAlias,
None,
alias,
state,
));
}
}
match args.format {
crate::cli::ReportOutputFormat::Json => {
println!(
"{}",
serde_json::to_string_pretty(&json!({ "integrations": entries }))?
);
}
crate::cli::ReportOutputFormat::Table if entries.is_empty() => {
println!("No supported MCP client config was detected.");
}
crate::cli::ReportOutputFormat::Table => {
for entry in &entries {
println!("{}", entry.line());
}
}
}
Ok(())
}
/// One line of `aise integrations status`, and one entry of its JSON form.
#[derive(Debug, serde::Serialize)]
struct IntegrationStatusEntry {
component: StatusComponent,
/// The harness the entry belongs to; executable aliases belong to none.
client: Option<&'static str>,
path: String,
state: String,
/// Whether the entry is exactly what this build installs.
current: bool,
}
#[derive(Debug, Clone, Copy, serde::Serialize)]
#[serde(rename_all = "snake_case")]
enum StatusComponent {
Mcp,
Instructions,
Skill,
SkillDiscoveryLink,
ExecutableAlias,
}
impl IntegrationStatusEntry {
fn new(
component: StatusComponent,
client: Option<&'static str>,
path: &Path,
state: impl Into<String>,
) -> Self {
let state = state.into();
Self {
component,
client,
path: path.display().to_string(),
// Every status function reports an integration that matches this build as
// "configured"; a discovery link says where it points and sets this itself.
current: state == ManagedFileState::Current.label(),
state,
}
}
fn line(&self) -> String {
let client = self.client.unwrap_or_default();
match self.component {
StatusComponent::ExecutableAlias => {
format!("executable alias {}: {}", self.path, self.state)
}
StatusComponent::SkillDiscoveryLink => {
format!("{client} discovery {}: {}", self.path, self.state)
}
StatusComponent::Mcp | StatusComponent::Instructions | StatusComponent::Skill => {
format!("{client} {}: {}", self.path, self.state)
}
}
}
}
/// The state word for a skill discovery link, and whether it points at the canonical root.
fn status_skill_discovery_link(link: &Path, canonical_root: &Path) -> Result<(String, bool)> {
match fs::symlink_metadata(link) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
Ok(("missing".to_string(), false))
}
Err(error) => {
Err(error).with_context(|| format!("inspect skill discovery entry {}", link.display()))
}
Ok(metadata) if metadata.file_type().is_symlink() => {
let target = fs::read_link(link)
.with_context(|| format!("read skill discovery link {}", link.display()))?;
if target == canonical_root {
Ok((format!("linked -> {}", canonical_root.display()), true))
} else {
Ok((format!("modified link -> {}", target.display()), false))
}
}
Ok(_) => Ok((
"legacy copied directory or unmanaged entry".to_string(),
false,
)),
}
}
fn ensure_no_pending_transaction(receipt: &Path) -> Result<()> {
if transaction_recovery_required(receipt)? {
bail!(
"integration status is not authoritative while recovery receipt {} exists; {} first",
receipt.display(),
recovery_guidance(receipt)
);
}
Ok(())
}
pub(crate) fn uninstall_with_receipt(
args: IntegrationUninstallArgs,
default_receipt: &Path,
) -> Result<()> {
let (mut targets, instruction_targets, mut skill_targets) = args
.targets
.resolve(args.keep_instructions, args.keep_skill)?;
rebase_automatic_skill_roots(&mut skill_targets, default_receipt);
if args.keep_mcp {
targets.clear();
}
if args.keep_skill {
skill_targets.clear();
}
let aliases = if args.keep_aliases {
None
} else {
Some(crate::executable_alias::ExecutableAliases::discover()?)
};
if targets.is_empty()
&& instruction_targets.is_empty()
&& skill_targets.is_empty()
&& aliases.is_none()
{
println!("No supported MCP client config was detected.");
return Ok(());
}
if args.force_full_cleanup {
validate_force_full_cleanup(&args, &skill_targets)?;
skill_targets.retain(|target| target.label == CUSTOM_SKILL_TARGET_LABEL);
for target in &skill_targets {
println!(
"FORCE CLEANUP: every file under {} will be deleted, including any you wrote. \
This cannot be undone.",
target.root.display()
);
}
} else {
append_retired_sibling_skill_targets(&mut skill_targets)?;
}
let discovery_uninstall = prepare_skill_discovery_uninstall(&skill_targets)?;
let discovery_link_changes = discovery_uninstall
.links_to_remove
.iter()
.map(|(link, expected)| SymlinkChange::remove(link.clone(), expected.clone()))
.collect::<Vec<_>>();
skill_targets = discovery_uninstall.file_targets;
let UninstallPlan {
mutations,
preserved_skills,
prune_skill_directories,
changed_targets,
changed_instructions,
changed_skills,
} = preflight_uninstall(
&targets,
&instruction_targets,
&skill_targets,
Some(&crate::skill_manifest::manifest_path(default_receipt)),
args.force_full_cleanup,
)?;
let removed_discovery_links = if !args.dry_run {
let receipt = selected_transaction_receipt(&args.transaction, default_receipt)?;
execute_planned_transaction_with_links(
&receipt,
&mutations,
&discovery_link_changes,
|_, change| publish_symlink_change(change),
)?;
// Only after the transaction commits, and only directories aise itself creates.
prune_emptied_skill_directories(&prune_skill_directories);
discovery_link_changes
.iter()
.map(|change| change.path.clone())
.collect()
} else {
Vec::new()
};
if args.dry_run {
for (link, expected) in &discovery_uninstall.links_to_remove {
println!(
"dry-run: would remove owned skill discovery link {} -> {}",
link.display(),
expected.display()
);
}
} else {
for link in removed_discovery_links {
println!("removed owned skill discovery link {}", link.display());
}
}
for message in &discovery_uninstall.notices {
println!("{message}");
}
for (target, changed) in targets.into_iter().zip(changed_targets) {
if args.dry_run && changed {
println!(
"dry-run: would remove {} MCP server from {}",
target.label,
target.path.display()
);
} else if changed {
println!(
"removed {} MCP server from {}",
target.label,
target.path.display()
);
}
}
for (target, changed) in instruction_targets.into_iter().zip(changed_instructions) {
if args.dry_run && changed {
println!(
"dry-run: would remove {} instruction guidance from {}",
target.label,
target.path.display()
);
} else if changed {
println!(
"removed {} instruction guidance from {}",
target.label,
target.path.display()
);
}
}
for ((target, changed), preserved) in skill_targets
.into_iter()
.zip(changed_skills)
.zip(preserved_skills)
{
if !preserved.is_empty() {
// Preservation is the interesting outcome, so it is reported first and in full: a
// user who is not told WHY a directory survived cannot decide what to do about it.
println!(
"preserved {} skill directory {} ({} reason{}):",
target.label,
target.root.display(),
preserved.len(),
if preserved.len() == 1 { "" } else { "s" }
);
for reason in &preserved {
println!(" {reason}");
}
println!(
" nothing was removed. Inspect the directory, then delete it yourself, or \
re-run with --force-full-cleanup --skill-root {} to delete everything in it.",
target.root.display()
);
continue;
}
if args.dry_run && changed {
println!(
"dry-run: would remove {} skill from {}",
target.label,
target.root.display()
);
} else if changed {
println!(
"removed {} skill from {}",
target.label,
target.root.display()
);
}
}
if let Some(aliases) = aliases {
for line in aliases.uninstall_lines(args.dry_run)? {
println!("{line}");
}
}
if args.dry_run {
println!("dry-run: no files were modified");
}
Ok(())
}
fn append_retired_sibling_skill_targets(skill_targets: &mut Vec<SkillTarget>) -> Result<()> {
let retired = skill_targets
.iter()
.filter(|target| target.package.name == AI_SESSION_SEARCH_SKILL_PACKAGE.name)
.filter_map(|target| {
let root = target.root.parent()?.join(RETIRED_SKILL_PACKAGE_NAME);
let discovery_links = target
.discovery_links
.iter()
.filter_map(|link| link.parent())
.map(|parent| parent.join(RETIRED_SKILL_PACKAGE_NAME))
.collect();
Some(SkillTarget {
label: "retired corrections",
root,
package: &RETIRED_SKILL_PACKAGE,
discovery_links,
detect_paths: Vec::new(),
detect_binaries: Vec::new(),
})
})
.collect::<Vec<_>>();
skill_targets.extend(retired);
dedupe_skill_targets(skill_targets)
}
#[derive(Debug, Default)]
struct SkillDiscoveryUninstall {
file_targets: Vec<SkillTarget>,
links_to_remove: Vec<(PathBuf, PathBuf)>,
notices: Vec<String>,
}
fn prepare_skill_discovery_uninstall(
selected_targets: &[SkillTarget],
) -> Result<SkillDiscoveryUninstall> {
let mut plan = SkillDiscoveryUninstall::default();
let layout = ClientLayout::discover()?;
for target in selected_targets {
if target.discovery_links.is_empty() {
plan.file_targets.push(target.clone());
continue;
}
for link in &target.discovery_links {
match fs::symlink_metadata(link) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(error).with_context(|| {
format!("inspect skill discovery entry {}", link.display())
})
}
Ok(metadata) if metadata.file_type().is_symlink() => {
let destination = fs::read_link(link)
.with_context(|| format!("read skill discovery link {}", link.display()))?;
if destination == target.root {
plan.links_to_remove
.push((link.clone(), target.root.clone()));
} else {
plan.notices.push(format!(
"preserved modified skill discovery link {} -> {}",
link.display(),
destination.display()
));
}
}
Ok(_) => {
// Compatibility with releases which copied the package into the harness.
// The normal manifest/marker/extra-file checks still decide whether removal
// is allowed; this merely gives that directory to the existing safe planner.
let mut legacy = target.clone();
legacy.root = link.clone();
legacy.discovery_links.clear();
plan.file_targets.push(legacy);
}
}
}
let selected = &target.discovery_links;
let another_owned_link =
another_owned_discovery_link(&layout, target.package, selected, &target.root)?;
if another_owned_link {
plan.notices.push(format!(
"preserved canonical skill package {} because another harness still links to it",
target.root.display()
));
} else {
plan.file_targets.push(target.clone());
}
}
dedupe_skill_targets(&mut plan.file_targets)?;
Ok(plan)
}
fn another_owned_discovery_link(
layout: &ClientLayout,
package: &ManagedSkillPackage,
selected: &[PathBuf],
canonical_root: &Path,
) -> Result<bool> {
for link in all_standard_discovery_links(layout, package)
.into_iter()
.filter(|link| !selected.contains(link))
{
let metadata = match fs::symlink_metadata(&link) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
if missing_discovery_entry_is_benign(&link)? {
continue;
}
return Err(error)
.with_context(|| format!("inspect skill discovery entry {}", link.display()));
}
Err(error) => {
return Err(error)
.with_context(|| format!("inspect skill discovery entry {}", link.display()))
}
};
if !metadata.file_type().is_symlink() {
continue;
}
let destination = fs::read_link(&link)
.with_context(|| format!("read skill discovery link {}", link.display()))?;
if destination == canonical_root {
return Ok(true);
}
}
Ok(false)
}
/// Distinguish an absent discovery entry from a descendant hidden behind a non-directory parent.
/// Windows reports both cases as `NotFound`, while Unix reports the latter as `NotADirectory`.
fn missing_discovery_entry_is_benign(path: &Path) -> Result<bool> {
for ancestor in path.ancestors().skip(1) {
match fs::metadata(ancestor) {
Ok(metadata) => return Ok(metadata.is_dir()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => {
return Err(error).with_context(|| {
format!(
"inspect parent of skill discovery entry {}",
ancestor.display()
)
})
}
}
}
Ok(true)
}
fn all_standard_discovery_links(
layout: &ClientLayout,
package: &ManagedSkillPackage,
) -> Vec<PathBuf> {
[
layout.home.join(".claude").join("skills"),
layout.home.join(".agents").join("skills"),
layout.home.join(".gemini").join("skills"),
layout.home.join(".gemini").join("config").join("skills"),
layout
.home
.join(".gemini")
.join("antigravity-cli")
.join("skills"),
layout.home.join(".pi").join("agent").join("skills"),
layout.home.join(".prime").join("agent").join("skills"),
]
.into_iter()
.map(|root| root.join(package.name))
.collect()
}
/// Keep the canonical packages beside the actual selected app config, including when a caller
/// deliberately relocates that config with an explicit override.
///
/// `default_receipt` already lives beside the resolved config file. Custom `--skill-root`
/// destinations are intentionally left exactly where the caller put them.
fn rebase_automatic_skill_roots(targets: &mut [SkillTarget], default_receipt: &Path) {
let app_root = default_receipt
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
for target in targets
.iter_mut()
.filter(|target| target.label != CUSTOM_SKILL_TARGET_LABEL)
{
target.root = app_root.join("skills").join(target.package.name);
}
}
pub(crate) fn recover_with_receipt(
args: IntegrationRecoverArgs,
default_receipt: &Path,
) -> Result<()> {
let receipt = selected_transaction_receipt(&args.transaction, default_receipt)?;
match recover_text_file_transaction(&receipt)? {
RecoveryOutcome::RolledBack { paths } => println!(
"restored {paths} path(s) from interrupted integration update; removed {}",
receipt.display()
),
RecoveryOutcome::Finalized { paths } => println!(
"verified {paths} published integration path(s); removed stale receipt {}",
receipt.display()
),
}
Ok(())
}
pub(crate) fn default_transaction_receipt(config_path: &Path) -> PathBuf {
config_path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."))
.join(".ai-session-search-mcp-transaction.json")
}
fn selected_transaction_receipt(
args: &IntegrationTransactionArgs,
default_receipt: &Path,
) -> Result<PathBuf> {
absolutize(&expand_tilde(
args.transaction_receipt
.as_deref()
.unwrap_or(default_receipt),
)?)
}
#[cfg(test)]
fn targets_for(client: McpClient) -> Result<Vec<Target>> {
let layout = ClientLayout::discover()?;
Ok(targets_for_layout(client, &layout))
}
fn targets_for_layout(client: McpClient, layout: &ClientLayout) -> Vec<Target> {
match client {
McpClient::All => CONCRETE_CLIENTS
.into_iter()
.flat_map(|client| targets_for_layout(client, layout))
.filter(target_detected)
.collect(),
McpClient::Claude => vec![
json_target_with_detect(
"claude code modern",
layout.home.join(".claude.json"),
vec![layout.home.join(".claude")],
vec!["claude"],
),
json_target_with_detect(
"claude code legacy",
layout.home.join(".claude").join(".mcp.json"),
vec![layout.home.join(".claude")],
vec!["claude"],
),
json_target_with_detect(
"claude desktop",
layout
.claude_desktop_config_dir()
.join("claude_desktop_config.json"),
vec![layout.claude_desktop_config_dir()],
Vec::new(),
),
],
McpClient::Codex => vec![Target {
label: "codex",
path: layout.home.join(".codex").join("config.toml"),
format: ConfigFormat::CodexToml,
detect_paths: vec![layout.home.join(".codex")],
detect_binaries: vec!["codex"],
}],
McpClient::Gemini => vec![json_target_with_detect(
"gemini",
layout.home.join(".gemini").join("settings.json"),
vec![layout.home.join(".gemini")],
vec!["gemini"],
)],
McpClient::Antigravity => vec![
json_target_with_detect(
"antigravity app/cli",
layout
.home
.join(".gemini")
.join("config")
.join("mcp_config.json"),
vec![
layout.home.join(".gemini").join("config"),
layout.home.join(".gemini").join("antigravity-cli"),
layout.home.join(".gemini").join("antigravity"),
],
vec!["agy"],
),
json_target_with_detect(
"antigravity cli",
layout
.home
.join(".gemini")
.join("antigravity-cli")
.join("settings.json"),
vec![layout.home.join(".gemini").join("antigravity-cli")],
vec!["agy"],
),
json_target_with_detect(
"antigravity legacy",
layout
.home
.join(".gemini")
.join("antigravity")
.join("mcp_config.json"),
vec![layout.home.join(".gemini").join("antigravity")],
Vec::new(),
),
],
McpClient::Pi | McpClient::PrimeAgent => Vec::new(),
McpClient::Cursor => vec![json_target(
layout,
"cursor",
layout.home.join(".cursor").join("mcp.json"),
)],
McpClient::Windsurf => vec![json_target(
layout,
"windsurf",
layout
.home
.join(".codeium")
.join("windsurf")
.join("mcp_config.json"),
)],
McpClient::Vscode => vec![Target {
label: "vscode",
path: layout.vscode_config_dir().join("mcp.json"),
format: ConfigFormat::VscodeServers,
detect_paths: vec![layout.vscode_config_dir()],
detect_binaries: vec!["code"],
}],
McpClient::Zed => vec![Target {
label: "zed",
path: layout.zed_config_dir().join("settings.json"),
format: ConfigFormat::ZedContextServers,
detect_paths: vec![layout.zed_config_dir()],
detect_binaries: vec!["zed"],
}],
McpClient::Opencode => vec![Target {
label: "opencode",
// OpenCode intentionally uses this XDG-style location on every supported OS.
path: layout
.home
.join(".config")
.join("opencode")
.join("opencode.json"),
format: ConfigFormat::OpenCode,
detect_paths: vec![layout.home.join(".config").join("opencode")],
detect_binaries: vec!["opencode"],
}],
McpClient::Openclaw => vec![json_target(
layout,
"openclaw",
layout.home.join(".openclaw").join("openclaw.json"),
)],
McpClient::Kilocode => vec![json_target(
layout,
"kilocode legacy vscode extension",
layout
.vscode_config_dir()
.join("globalStorage")
.join("kilocode.kilo-code")
.join("settings")
.join("mcp_settings.json"),
)],
}
}
#[cfg(test)]
fn instruction_targets_for(client: McpClient) -> Result<Vec<InstructionTarget>> {
let layout = ClientLayout::discover()?;
Ok(instruction_targets_for_layout(client, &layout))
}
fn instruction_targets_for_layout(
client: McpClient,
layout: &ClientLayout,
) -> Vec<InstructionTarget> {
match client {
McpClient::All => CONCRETE_CLIENTS
.into_iter()
.flat_map(|client| instruction_targets_for_layout(client, layout))
.filter(instruction_detected)
.collect(),
McpClient::Claude => vec![InstructionTarget {
label: "claude",
path: layout.home.join(".claude").join("CLAUDE.md"),
format: InstructionFormat::ClaudeImport,
detect_paths: vec![layout.home.join(".claude")],
detect_binaries: vec!["claude"],
}],
McpClient::Codex => vec![InstructionTarget {
label: "codex",
path: layout.home.join(".codex").join("AGENTS.md"),
format: InstructionFormat::InlineBlock,
detect_paths: vec![layout.home.join(".codex")],
detect_binaries: vec!["codex"],
}],
McpClient::Gemini | McpClient::Antigravity => {
vec![gemini_instruction_target(layout)]
}
McpClient::Pi => vec![pi_family_instruction_target(
layout,
"pi",
".pi/agent",
"pi",
)],
McpClient::PrimeAgent => vec![pi_family_instruction_target(
layout,
"prime-agent",
".prime/agent",
"prime-agent",
)],
McpClient::Opencode => vec![InstructionTarget {
label: "opencode",
path: layout
.home
.join(".config")
.join("opencode")
.join("AGENTS.md"),
format: InstructionFormat::InlineBlock,
detect_paths: vec![layout.home.join(".config").join("opencode")],
detect_binaries: vec!["opencode"],
}],
_ => Vec::new(),
}
}
fn pi_family_instruction_target(
layout: &ClientLayout,
label: &'static str,
agent_dir: &str,
binary: &'static str,
) -> InstructionTarget {
let root = layout.home.join(agent_dir);
InstructionTarget {
label,
path: root.join("AGENTS.md"),
// Pi-family harnesses receive no MCP registration (`mcp_targets_for_layout`), so their
// block must lead with the CLI.
format: InstructionFormat::InlineCliBlock,
detect_paths: vec![root],
detect_binaries: vec![binary],
}
}
fn gemini_instruction_target(layout: &ClientLayout) -> InstructionTarget {
InstructionTarget {
label: "gemini/antigravity",
path: layout.home.join(".gemini").join("GEMINI.md"),
format: InstructionFormat::InlineBlock,
detect_paths: vec![layout.home.join(".gemini")],
detect_binaries: vec!["gemini", "agy"],
}
}
fn instruction_detected(target: &InstructionTarget) -> bool {
target.path.exists()
|| target
.detect_paths
.iter()
.any(|path| path.exists() && path.is_dir())
|| target
.detect_binaries
.iter()
.any(|binary| which(binary).is_some())
}
fn skill_targets_for_layout(client: McpClient, layout: &ClientLayout) -> Vec<SkillTarget> {
let (discovery_roots, detect_paths, detect_binaries) = match client {
McpClient::Claude => (
vec![layout.home.join(".claude").join("skills")],
vec![layout.home.join(".claude")],
vec!["claude"],
),
McpClient::Codex => (
vec![layout.home.join(".agents").join("skills")],
vec![layout.home.join(".codex"), layout.home.join(".agents")],
vec!["codex"],
),
McpClient::Gemini => (
// Gemini CLI's native user location. It also accepts ~/.agents/skills, but using the
// harness-native directory makes ownership and uninstall expectations unsurprising.
vec![layout.home.join(".gemini").join("skills")],
vec![layout.home.join(".gemini")],
vec!["gemini"],
),
McpClient::Antigravity => (
// Antigravity App/IDE and Antigravity CLI intentionally have distinct global
// discovery roots even though they share the modern MCP configuration file.
vec![
layout.home.join(".gemini").join("config").join("skills"),
layout
.home
.join(".gemini")
.join("antigravity-cli")
.join("skills"),
],
vec![
layout.home.join(".gemini").join("config"),
layout.home.join(".gemini").join("antigravity-cli"),
],
vec!["agy"],
),
McpClient::Pi => pi_family_skill_layout(layout, ".pi/agent", "pi"),
McpClient::PrimeAgent => pi_family_skill_layout(layout, ".prime/agent", "prime-agent"),
_ => return Vec::new(),
};
let canonical_root = layout.home.join(".ai-session-search").join("skills");
MANAGED_SKILL_PACKAGES
.iter()
.map(|package| {
let mut target = skill_target_for_package(
"app",
canonical_root.join(package.name),
package,
detect_paths.clone(),
detect_binaries.clone(),
);
target
.discovery_links
.extend(discovery_roots.iter().map(|root| root.join(package.name)));
target
})
.collect()
}
fn pi_family_skill_layout(
layout: &ClientLayout,
agent_dir: &str,
binary: &'static str,
) -> (Vec<PathBuf>, Vec<PathBuf>, Vec<&'static str>) {
let root = layout.home.join(agent_dir);
(vec![root.join("skills")], vec![root], vec![binary])
}
fn skill_target_for_package(
label: &'static str,
root: PathBuf,
package: &'static ManagedSkillPackage,
detect_paths: Vec<PathBuf>,
detect_binaries: Vec<&'static str>,
) -> SkillTarget {
SkillTarget {
label,
root,
package,
discovery_links: Vec::new(),
detect_paths,
detect_binaries,
}
}
#[cfg(test)]
fn skill_target(
label: &'static str,
root: PathBuf,
detect_paths: Vec<PathBuf>,
detect_binaries: Vec<&'static str>,
) -> SkillTarget {
skill_target_for_package(
label,
root,
&AI_SESSION_SEARCH_SKILL_PACKAGE,
detect_paths,
detect_binaries,
)
}
fn skill_target_detected(target: &SkillTarget) -> bool {
target.root.exists()
|| target.discovery_links.iter().any(|path| path.exists())
|| target.detect_paths.iter().any(|path| path.is_dir())
|| target
.detect_binaries
.iter()
.any(|binary| which(binary).is_some())
}
/// Label given to a skill root the caller named with `--skill-root`.
///
/// A named constant because `--force-full-cleanup` refuses anything else, and a literal in two
/// places could drift apart into a check that silently passes.
const CUSTOM_SKILL_TARGET_LABEL: &str = "custom";
fn custom_skill_targets(paths: &[PathBuf]) -> Result<Vec<SkillTarget>> {
paths
.iter()
.map(|path| {
let root = expand_tilde(path)?;
let package = match root.file_name().and_then(|name| name.to_str()) {
Some(AI_SESSION_SEARCH_SKILL_NAME) => &AI_SESSION_SEARCH_SKILL_PACKAGE,
_ => {
bail!(
"custom skill root {} must end in {}",
root.display(),
AI_SESSION_SEARCH_SKILL_NAME
)
}
};
Ok(skill_target_for_package(
CUSTOM_SKILL_TARGET_LABEL,
root,
package,
Vec::new(),
Vec::new(),
))
})
.collect()
}
fn target_detected(target: &Target) -> bool {
target.path.exists()
|| target
.detect_paths
.iter()
.any(|path| path.exists() && path.is_dir())
|| target
.detect_binaries
.iter()
.any(|binary| which(binary).is_some())
}
fn custom_instruction_targets(
claude_md_paths: &[PathBuf],
gemini_md_paths: &[PathBuf],
agents_md_paths: &[PathBuf],
) -> Result<Vec<InstructionTarget>> {
claude_md_paths
.iter()
.map(|path| {
Ok(InstructionTarget {
label: "custom-claude",
path: expand_tilde(path)?,
format: InstructionFormat::ClaudeImport,
detect_paths: Vec::new(),
detect_binaries: Vec::new(),
})
})
.chain(gemini_md_paths.iter().map(|path| {
Ok(InstructionTarget {
label: "custom-gemini",
path: expand_tilde(path)?,
format: InstructionFormat::InlineBlock,
detect_paths: Vec::new(),
detect_binaries: Vec::new(),
})
}))
.chain(agents_md_paths.iter().map(|path| {
Ok(InstructionTarget {
label: "custom-agents",
path: expand_tilde(path)?,
format: InstructionFormat::InlineBlock,
detect_paths: Vec::new(),
detect_binaries: Vec::new(),
})
}))
.collect()
}
fn custom_targets(
json_mcp_configs: &[PathBuf],
vscode_configs: &[PathBuf],
zed_configs: &[PathBuf],
opencode_configs: &[PathBuf],
codex_configs: &[PathBuf],
) -> Result<Vec<Target>> {
json_mcp_configs
.iter()
.map(|path| {
Ok(Target {
label: "custom-json-mcp",
path: expand_tilde(path)?,
format: ConfigFormat::JsonMcpServers,
detect_paths: Vec::new(),
detect_binaries: Vec::new(),
})
})
.chain(vscode_configs.iter().map(|path| {
Ok(Target {
label: "custom-vscode",
path: expand_tilde(path)?,
format: ConfigFormat::VscodeServers,
detect_paths: Vec::new(),
detect_binaries: Vec::new(),
})
}))
.chain(zed_configs.iter().map(|path| {
Ok(Target {
label: "custom-zed",
path: expand_tilde(path)?,
format: ConfigFormat::ZedContextServers,
detect_paths: Vec::new(),
detect_binaries: Vec::new(),
})
}))
.chain(opencode_configs.iter().map(|path| {
Ok(Target {
label: "custom-opencode",
path: expand_tilde(path)?,
format: ConfigFormat::OpenCode,
detect_paths: Vec::new(),
detect_binaries: Vec::new(),
})
}))
.chain(codex_configs.iter().map(|path| {
Ok(Target {
label: "custom-codex",
path: expand_tilde(path)?,
format: ConfigFormat::CodexToml,
detect_paths: Vec::new(),
detect_binaries: Vec::new(),
})
}))
.collect()
}
fn json_target(layout: &ClientLayout, label: &'static str, path: PathBuf) -> Target {
let detect_paths = path
.parent()
.filter(|parent| *parent != layout.home.as_path())
.map(|parent| vec![parent.to_path_buf()])
.unwrap_or_default();
json_target_with_detect(label, path, detect_paths, Vec::new())
}
fn json_target_with_detect(
label: &'static str,
path: PathBuf,
detect_paths: Vec<PathBuf>,
detect_binaries: Vec<&'static str>,
) -> Target {
Target {
label,
path,
format: ConfigFormat::JsonMcpServers,
detect_paths,
detect_binaries,
}
}
#[cfg(test)]
fn upsert_target(target: &Target, binary: &Path) -> Result<()> {
publish_planned_mutations(&normalize_planned_mutations(plan_upsert_target(
target, binary,
)?)?)
}
fn preflight_install(
targets: &[Target],
instruction_targets: &[InstructionTarget],
skill_targets: &[SkillTarget],
binary: &Path,
manifest_path: Option<&Path>,
) -> Result<Vec<PlannedFileMutation>> {
let skill_manifest = match manifest_path {
Some(path) => crate::skill_manifest::load_manifest(path)?,
None => crate::skill_manifest::ManifestState::Absent,
};
let mut mutations = Vec::new();
let mut retired_skill_roots = Vec::new();
for target in targets {
mutations.extend(plan_upsert_target(target, binary)?);
}
for target in instruction_targets {
mutations.extend(plan_upsert_instruction_file(target)?);
}
for target in skill_targets {
let migration = plan_migrate_discovery_copies(target, manifest_path)?;
mutations.extend(migration.mutations);
retired_skill_roots.extend(migration.retired_roots);
if manifest_path.is_some() {
mutations.extend(plan_retire_removed_managed_skill_files(
target,
&skill_manifest,
)?);
}
mutations.extend(plan_refresh_skill_files(target, &skill_manifest, false)?);
}
if let Some(path) = manifest_path {
if !skill_targets.is_empty() {
mutations.extend(plan_record_skill_manifest(
path,
skill_targets,
&retired_skill_roots,
)?);
}
}
normalize_planned_mutations(mutations)
}
/// Retire files that an earlier package version owned but the current package no longer ships.
///
/// A rename is an add plus a removal. The manifest is the durable evidence that makes the removal
/// safe: exact recorded bytes may be removed, while a modified file is preserved by refusing the
/// write before its transaction starts. Historical correction paths are excluded because their
/// removal has the stronger sibling-package requirement in [`plan_historical_skill_split`].
fn plan_retire_removed_managed_skill_files(
target: &SkillTarget,
manifest: &crate::skill_manifest::ManifestState,
) -> Result<Vec<PlannedFileMutation>> {
let Some(installation) = manifest.installation(&target.root) else {
return Ok(Vec::new());
};
let current_paths = target
.package
.files
.iter()
.map(|file| file.relative_path)
.collect::<std::collections::BTreeSet<_>>();
let mut mutations = Vec::new();
for recorded_file in &installation.files {
if current_paths.contains(recorded_file.relative_path.as_str())
|| (target.package.name == AI_SESSION_SEARCH_SKILL_PACKAGE.name
&& HISTORICAL_CORRECTION_PATHS.contains(&recorded_file.relative_path.as_str()))
{
continue;
}
let relative = Path::new(&recorded_file.relative_path);
if recorded_file.relative_path.is_empty()
|| relative.is_absolute()
|| !relative
.components()
.all(|component| matches!(component, Component::Normal(_)))
{
bail!(
"refusing to retire invalid managed skill path {:?} recorded for {}; \
repair or move the install manifest before retrying",
recorded_file.relative_path,
target.root.display()
);
}
let path = target.root.join(relative);
let Some(original) = read_optional_utf8_regular_file(&path)? else {
continue;
};
if recorded_file.bytes != original.len()
|| recorded_file.sha256 != crate::hashing::sha256(original.as_bytes())
{
bail!(
"refusing to retire removed managed skill file {} because it differs from the \
install manifest; preserving it so aise cannot destroy a user edit or damaged \
file",
path.display()
);
}
mutations.push(PlannedFileMutation::Remove { path, original });
}
Ok(mutations)
}
/// Plan the manifest write that records what this install is about to place.
///
/// One more `PlannedFileMutation` in the SAME transaction, so the record and the files it
/// describes are published together or not at all. A manifest written afterwards could survive a
/// rollback and claim an install that never happened.
///
/// The recorded digests come from the embedded content rather than from re-reading disk: the
/// transaction is about to write exactly those bytes, and hashing the files afterwards would both
/// invert the ordering and risk recording whatever a concurrent writer left there instead.
///
/// The manifest is deliberately NOT in its own file list: it is not part of the skill, it must not
/// hash itself, and uninstall must never treat it as a skill file.
fn plan_record_skill_manifest(
manifest_path: &Path,
skill_targets: &[SkillTarget],
retired_roots: &[PathBuf],
) -> Result<Vec<PlannedFileMutation>> {
let state = crate::skill_manifest::load_manifest(manifest_path)?;
let mut manifest = state.writable_manifest(manifest_path)?;
let mut mutations = plan_historical_skill_split(&state, skill_targets)?;
let retired_siblings = plan_retire_sibling_package(&state, skill_targets)?;
mutations.extend(retired_siblings.mutations);
for root in retired_siblings.retired_roots {
manifest.forget(&root);
}
for root in retired_roots {
manifest.forget(root);
}
for target in skill_targets {
let files = target
.package
.files
.iter()
.map(|file| (file.relative_path.to_string(), file.content))
.collect::<Vec<_>>();
manifest.record(&target.root, &files);
}
let original = read_optional_utf8_regular_file(manifest_path)?;
mutations.push(planned_write(manifest_path, &original, manifest.to_json()?));
Ok(mutations)
}
/// Remove the sibling `corrections` skill root that earlier releases installed.
///
/// Its rules now ship inside this package, so the sibling is dead weight that keeps claiming the
/// generic top-level name `corrections` in every harness skill directory. It is removed in the same
/// transaction that writes the replacement.
///
/// Ownership rules are uninstall's, not install's: [`plan_remove_skill_file`] refuses unless the
/// `SKILL.md` marker and the install manifest agree the tree is untouched, so a user edit or an
/// unmanaged extra file leaves the directory in place and is reported rather than deleted.
fn plan_retire_sibling_package(
manifest: &crate::skill_manifest::ManifestState,
skill_targets: &[SkillTarget],
) -> Result<SkillDiscoveryMigration> {
let mut migration = SkillDiscoveryMigration::default();
let mut planned = std::collections::BTreeSet::new();
for target in skill_targets
.iter()
.filter(|target| target.package.name == AI_SESSION_SEARCH_SKILL_PACKAGE.name)
{
let candidates = std::iter::once(&target.root)
.chain(target.discovery_links.iter())
.filter_map(|path| path.parent())
.map(|parent| parent.join(RETIRED_SKILL_PACKAGE_NAME));
for root in candidates {
// A caller may install into a directory that is itself named `corrections`, via
// `--skill-root`. Retiring it would delete the package this very transaction is
// writing, so the sibling search never selects the target or one of its own links.
if root == target.root || target.discovery_links.contains(&root) {
continue;
}
if !planned.insert(root.clone()) {
continue;
}
match fs::symlink_metadata(&root) {
Ok(metadata) if metadata.file_type().is_symlink() => continue,
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(error).with_context(|| {
format!("inspect retired skill package {}", root.display())
})
}
}
let retired = SkillTarget {
label: target.label,
root: root.clone(),
package: &AI_SESSION_SEARCH_SKILL_PACKAGE,
discovery_links: Vec::new(),
detect_paths: Vec::new(),
detect_binaries: Vec::new(),
};
let removal = plan_remove_skill_file(&retired, manifest, false)?;
if !removal.preserved.is_empty() {
bail!(
"refusing to retire the superseded corrections skill at {} because it may \
contain changes aise did not write:\n {}\nmove or delete it yourself, then \
rerun install",
root.display(),
removal.preserved.join("\n ")
);
}
if removal.mutations.is_empty() {
continue;
}
migration.mutations.extend(removal.mutations);
migration.retired_roots.push(root);
}
}
Ok(migration)
}
fn plan_retire_sibling_discovery_links(
skill_targets: &[SkillTarget],
) -> Result<Vec<(PathBuf, PathBuf)>> {
let mut links = Vec::new();
let mut planned = std::collections::BTreeSet::new();
for target in skill_targets
.iter()
.filter(|target| target.package.name == AI_SESSION_SEARCH_SKILL_PACKAGE.name)
{
let Some(canonical_parent) = target.root.parent() else {
continue;
};
let expected = canonical_parent.join(RETIRED_SKILL_PACKAGE_NAME);
for current_link in &target.discovery_links {
let Some(link_parent) = current_link.parent() else {
continue;
};
let link = link_parent.join(RETIRED_SKILL_PACKAGE_NAME);
if !planned.insert(link.clone()) {
continue;
}
match fs::symlink_metadata(&link) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(error).with_context(|| {
format!("inspect retired skill discovery link {}", link.display())
})
}
Ok(metadata) if metadata.file_type().is_symlink() => {
let existing = fs::read_link(&link).with_context(|| {
format!("read retired skill discovery link {}", link.display())
})?;
if existing != expected {
bail!(
"refusing to retire skill discovery link {} because it points to {} \
instead of the retired aise package {}; preserve or remove it yourself",
link.display(),
existing.display(),
expected.display()
);
}
links.push((link, expected.clone()));
}
Ok(_) => {}
}
}
}
Ok(links)
}
#[derive(Debug, Default)]
struct SkillDiscoveryMigration {
mutations: Vec<PlannedFileMutation>,
retired_roots: Vec<PathBuf>,
}
/// Replace an older copied harness package only when it is provably untouched.
///
/// Older releases wrote complete packages into each harness directory. A current install turns
/// those locations into discovery links, but the same ownership rules as uninstall apply: a
/// modified file, an unmanaged extra, or an unowned directory blocks migration before any write.
fn plan_migrate_discovery_copies(
target: &SkillTarget,
manifest_path: Option<&Path>,
) -> Result<SkillDiscoveryMigration> {
let manifest = match manifest_path {
Some(path) => crate::skill_manifest::load_manifest(path)?,
None => crate::skill_manifest::ManifestState::Absent,
};
let mut migration = SkillDiscoveryMigration::default();
for link in &target.discovery_links {
match fs::symlink_metadata(link) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => {
return Err(error)
.with_context(|| format!("inspect skill discovery entry {}", link.display()))
}
Ok(metadata) if metadata.file_type().is_symlink() => {
let existing = fs::read_link(link)
.with_context(|| format!("read skill discovery link {}", link.display()))?;
if existing != target.root {
bail!(
"refusing to replace skill discovery link {} because it points to {} \
instead of the aise package {}; preserve or remove it yourself",
link.display(),
existing.display(),
target.root.display()
);
}
}
Ok(_) => {
let mut legacy = target.clone();
legacy.root = link.clone();
legacy.discovery_links.clear();
let removal = plan_remove_skill_file(&legacy, &manifest, false)?;
if !removal.preserved.is_empty() {
bail!(
"refusing to replace legacy copied skill directory {} with a discovery \
link because it may contain user changes:\n {}",
link.display(),
removal.preserved.join("\n ")
);
}
migration.mutations.extend(removal.mutations);
migration.retired_roots.push(link.clone());
}
}
}
Ok(migration)
}
/// Remove prerelease correction files only when valid manifest evidence proves they are untouched.
///
/// The old files and both replacement packages share one transaction. If evidence is missing,
/// damaged, or differs from disk, planning fails before any mutation is published.
fn plan_historical_skill_split(
manifest: &crate::skill_manifest::ManifestState,
skill_targets: &[SkillTarget],
) -> Result<Vec<PlannedFileMutation>> {
let mut mutations = Vec::new();
for general in skill_targets
.iter()
.filter(|target| target.package.name == AI_SESSION_SEARCH_SKILL_PACKAGE.name)
{
let recorded = manifest.installation(&general.root);
for relative_path in HISTORICAL_CORRECTION_PATHS {
let path = general.root.join(relative_path);
let disk = read_optional_utf8_regular_file(&path)?;
let recorded_file = recorded.and_then(|installation| installation.file(relative_path));
match (disk, recorded_file) {
(None, None) => {}
(Some(_), None) => bail!(
"refusing to migrate unrecorded historical skill file {}; ownership is \
uncertain, so inspect or move it before installing the current \
ai-session-search package",
path.display()
),
(None, Some(_)) => bail!(
"refusing to migrate historical skill file {} because the install manifest \
records it but it is missing; repair or move the damaged skill tree first",
path.display()
),
(Some(original), Some(recorded_file)) => {
if recorded_file.bytes != original.len()
|| recorded_file.sha256 != crate::hashing::sha256(original.as_bytes())
{
bail!(
"refusing to migrate historical skill file {} because it differs \
from the install manifest; inspect or move it so aise cannot destroy \
an edit or damaged file",
path.display()
);
}
mutations.push(PlannedFileMutation::Remove { path, original });
}
}
}
}
Ok(mutations)
}
/// Plan the manifest write that forgets the roots this uninstall is removing.
///
/// Only the roots being removed lose their entry. An install into another harness must keep its
/// record, or a later uninstall there would report "ownership uncertain" about a tree `aise`
/// definitely wrote.
fn plan_forget_skill_manifest(
manifest_path: &Path,
skill_targets: &[SkillTarget],
) -> Result<Vec<PlannedFileMutation>> {
let Some(original) = read_optional_utf8_regular_file(manifest_path)? else {
return Ok(Vec::new());
};
let state = crate::skill_manifest::load_manifest(manifest_path)?;
let mut manifest = state.writable_manifest(manifest_path)?;
for target in skill_targets {
manifest.forget(&target.root);
}
if manifest.installations.is_empty() {
// Nothing left to record. Removing the file rather than leaving an empty one keeps
// "absent" meaning "no managed install", which is what a fresh machine also looks like.
return Ok(vec![PlannedFileMutation::Remove {
path: manifest_path.to_path_buf(),
original,
}]);
}
Ok(vec![planned_write(
manifest_path,
&Some(original),
manifest.to_json()?,
)])
}
/// Refuse `--force-full-cleanup` unless it names exactly what it will destroy.
///
/// The flag exists so a user CAN delete their own edits, deliberately. Every constraint here
/// exists so they cannot do it by accident:
///
/// 1. An exact `--skill-root` is required. Detected-client fan-out would let one flag delete
/// edits in three harness directories the user never named.
/// 2. `--keep-skill` cannot be combined with it: one says preserve, the other says destroy.
/// 3. Each named root must be provably aise's -- the managed marker, or an install-manifest
/// entry. Without that, this is an arbitrary recursive delete of a directory aise never wrote.
///
/// It never reaches the index, cache, or configuration: `skill_targets` are the only paths it can
/// ever see, and those are skill directories by construction.
fn validate_force_full_cleanup(
args: &IntegrationUninstallArgs,
skill_targets: &[SkillTarget],
) -> Result<()> {
if args.keep_skill {
bail!(
"--force-full-cleanup and --keep-skill contradict each other: one deletes the whole \
skill directory, the other preserves it. Pass exactly one"
);
}
if args.targets.skill_roots.is_empty() {
bail!(
"--force-full-cleanup requires an exact --skill-root DIR. It deletes files you wrote, \
so it will not run against automatically detected client directories. Run \
`aise integrations status` to see the installed roots, then name one"
);
}
// Detected client directories are DROPPED rather than refused. Erroring here was a trap:
// once a skill is installed, `~/.claude` and friends are always detected, so a caller who
// named an exact root could never satisfy the check and had no flag to escape it. Scoping to
// the named roots is also the only reading of the flag that is safe -- it says "this exact
// root" -- so resolving it is better than reporting it.
let dropped: Vec<&Path> = skill_targets
.iter()
.filter(|target| target.label != CUSTOM_SKILL_TARGET_LABEL)
.map(|target| target.root.as_path())
.collect();
for root in &dropped {
println!(
"--force-full-cleanup applies only to roots you named, so {} is left untouched. \
Uninstall it separately, or name it with --skill-root.",
root.display()
);
}
Ok(())
}
fn preflight_uninstall(
targets: &[Target],
instruction_targets: &[InstructionTarget],
skill_targets: &[SkillTarget],
manifest_path: Option<&Path>,
force_full_cleanup: bool,
) -> Result<UninstallPlan> {
let skill_manifest = match manifest_path {
Some(path) => crate::skill_manifest::load_manifest(path)?,
None => crate::skill_manifest::ManifestState::Absent,
};
let mut mutations = Vec::new();
let mut changed_targets = Vec::new();
let mut changed_instructions = Vec::new();
let mut changed_skills = Vec::new();
for target in targets {
let planned = plan_remove_target(target)?;
changed_targets.push(!planned.is_empty());
mutations.extend(planned);
}
for target in instruction_targets {
let planned = plan_remove_instruction_file(target)?;
changed_instructions.push(!planned.is_empty());
mutations.extend(planned);
}
let mut preserved_skills = Vec::with_capacity(skill_targets.len());
let mut prune_skill_directories = Vec::new();
for target in skill_targets {
let planned = plan_remove_skill_file(target, &skill_manifest, force_full_cleanup)?;
changed_skills.push(!planned.mutations.is_empty());
preserved_skills.push(planned.preserved);
prune_skill_directories.extend(planned.prune);
mutations.extend(planned.mutations);
}
if let Some(path) = manifest_path {
// Only roots whose files were actually removed lose their record. Forgetting a preserved
// tree would strip the very evidence that lets a later run explain why it was preserved.
let removed: Vec<SkillTarget> = skill_targets
.iter()
.zip(&preserved_skills)
.filter(|(_, preserved)| preserved.is_empty())
.map(|(target, _)| target.clone())
.collect();
if !removed.is_empty() {
mutations.extend(plan_forget_skill_manifest(path, &removed)?);
}
}
Ok(UninstallPlan {
mutations: normalize_planned_mutations(mutations)?,
preserved_skills,
prune_skill_directories,
changed_targets,
changed_instructions,
changed_skills,
})
}
/// Test the transaction path independently of manifest-aware ownership classification.
///
/// Production install and update callers use [`plan_refresh_skill_files`], which consults the
/// per-file manifest before planning any replacement. This lower-level helper intentionally
/// models only the marker-based, manifest-absent fixture used by transaction tests.
#[cfg(test)]
fn plan_upsert_skill_file(target: &SkillTarget) -> Result<Vec<PlannedFileMutation>> {
let anchor = target.anchor();
if let Some(text) = read_optional_utf8_regular_file(&anchor)?.as_deref() {
if !is_managed_skill_anchor(text) {
bail!(
"refusing to replace unmanaged AI Session Search skill {}; move it or choose \
another --skill-root",
anchor.display()
);
}
}
target
.managed_paths()
.map(|(path, content)| {
let original = read_optional_utf8_regular_file(&path)?;
Ok(planned_write(&path, &original, content.to_string()))
})
.collect()
}
/// Plan the writes that repair or update one owned skill directory.
///
/// `overwrite_changed` is the difference between the two callers. Normal install REFUSES a file
/// whose bytes changed since install, because silently replacing it would destroy an edit the
/// user may have meant; `aise skills restore` sets this to overwrite, which is what makes it the
/// explicit, named repair path rather than a surprise.
///
/// Unmanaged files are never in the package file list, so nothing here can touch them.
fn plan_refresh_skill_files(
target: &SkillTarget,
manifest: &crate::skill_manifest::ManifestState,
overwrite_changed: bool,
) -> Result<Vec<PlannedFileMutation>> {
let anchor = target.anchor();
let owned_by_marker = read_optional_utf8_regular_file(&anchor)?
.as_deref()
.is_some_and(is_managed_skill_anchor);
let recorded = manifest.installation(&target.root);
if !owned_by_marker && recorded.is_none() && crate::durable_fs::entry_exists(&anchor)? {
bail!(
"refusing to write unmanaged AI Session Search skill {}; it carries no managed \
marker and no install record, so aise did not write it",
anchor.display()
);
}
let mut mutations = Vec::new();
for file in target.package.files {
let path = target.root.join(file.relative_path);
let state = classify_managed_file(
&path,
file.content,
recorded.and_then(|installation| installation.file(file.relative_path)),
)?;
if !overwrite_changed
&& matches!(
state,
ManagedFileState::ModifiedOrDamaged | ManagedFileState::LegacyUnknown
)
{
bail!(
"refusing to overwrite {}: it differs from what install recorded, so replacing \
it could destroy an edit you meant to keep.\n\
Inspect it, or run `aise skills restore {} --skill-root {} --dry-run` to see \
exactly what a repair would rewrite",
path.display(),
target.package.name,
target.root.display()
);
}
let original = read_optional_utf8_regular_file(&path)?;
mutations.push(planned_write(&path, &original, file.content.to_string()));
}
Ok(mutations)
}
/// One skill root as `aise skills update` and `aise skills restore` report it.
#[derive(Debug, Clone, serde::Serialize)]
pub(crate) struct SkillWriteOutcome {
/// Which client root this is, or `custom` for a root the caller named.
pub(crate) label: String,
pub(crate) root: String,
/// What happened, or would happen under `--dry-run`.
pub(crate) action: String,
/// Why nothing was written, when that is the outcome.
pub(crate) problem: Option<String>,
/// Whether files on disk changed. `action` is wording for a reader; this is what code tests.
#[serde(skip)]
pub(crate) changed: bool,
}
/// Bring every owned skill root up to the embedded content, or repair one exact root.
///
/// The SAME planner and transaction `integrations install` uses, restricted to skills. A separate
/// writing path would be a second place for ownership rules to drift.
///
/// # Errors
///
/// Returns an error when a root cannot be planned for a reason that is not about ownership, when
/// the transaction fails, or when `restore` names a root aise does not own.
pub(crate) fn write_owned_skills(
explicit_roots: &[PathBuf],
receipt_path: &Path,
dry_run: bool,
overwrite_changed: bool,
) -> Result<Vec<SkillWriteOutcome>> {
let manifest_path = crate::skill_manifest::manifest_path(receipt_path);
let manifest = crate::skill_manifest::load_manifest(&manifest_path)?;
// A write command must not silently succeed, rewrite package bytes, or replace a damaged
// ownership record. Status and conservative removal planning may still inspect this state.
let _ = manifest.writable_manifest(&manifest_path)?;
// Client discovery runs ONLY when no root was named. Naming roots is the precise request, so
// resolving a home directory to answer it would make the command fail on a machine where HOME
// is unset for a reason unrelated to what was asked.
let mut targets: Vec<SkillTarget> = if explicit_roots.is_empty() {
let layout = ClientLayout::discover()?;
McpClient::value_variants()
.iter()
.copied()
.flat_map(|client| skill_targets_for_layout(client, &layout))
.filter(skill_target_detected)
.collect()
} else {
Vec::new()
};
// Move the discovered roots beside the config file before comparing them with the recorded
// ones, which install wrote there: with a config outside the default location, comparing the
// unmoved roots added the recorded app root a second time, as custom, and the command refused.
rebase_automatic_skill_roots(&mut targets, receipt_path);
if explicit_roots.is_empty() {
// Only directories that still exist: updating is not installing. A skill the user deleted
// stays deleted, and a harness installed with --no-skill gets none;
// `aise integrations install` or a named --skill-root puts one there on request.
targets.retain(|target| target.root.is_dir());
let discovered_roots = targets
.iter()
.map(|target| target.root.clone())
.collect::<std::collections::HashSet<_>>();
let recorded_roots = manifest_recorded_skill_roots(&manifest)?
.into_iter()
.filter(|root| root.is_dir() && !discovered_roots.contains(root))
.collect::<Vec<_>>();
targets.extend(custom_skill_targets(&recorded_roots)?);
} else {
targets.extend(custom_skill_targets(explicit_roots)?);
}
dedupe_skill_targets(&mut targets)?;
let mut outcomes = Vec::with_capacity(targets.len());
let mut mutations = Vec::new();
let mut written: Vec<SkillTarget> = Vec::new();
let mut refreshable: Vec<SkillTarget> = Vec::new();
for target in &targets {
let status = status_skill_file(target, &manifest)?;
match plan_refresh_skill_files(target, &manifest, overwrite_changed).and_then(
|mut planned| {
planned.extend(plan_retire_removed_managed_skill_files(target, &manifest)?);
Ok(planned)
},
) {
Ok(planned) => {
let noop = planned.iter().all(PlannedFileMutation::is_noop);
refreshable.push(target.clone());
outcomes.push(SkillWriteOutcome {
label: target.label.to_string(),
root: target.root.display().to_string(),
action: if noop {
"already current".to_string()
} else if dry_run {
format!("would rewrite from {status}")
} else {
format!("rewritten from {status}")
},
problem: None,
changed: !noop && !dry_run,
});
if !noop {
mutations.extend(planned);
written.push(target.clone());
}
}
// Ownership and modified-file refusals are DIAGNOSED per root rather than aborting
// the run: one hand-edited harness directory must not stop the other three from
// being updated.
Err(error) => outcomes.push(SkillWriteOutcome {
label: target.label.to_string(),
root: target.root.display().to_string(),
action: "not written".to_string(),
problem: Some(format!("{error:#}")),
changed: false,
}),
}
}
let historical = plan_historical_skill_split(&manifest, &refreshable)?;
let retired_siblings = plan_retire_sibling_package(&manifest, &refreshable)?;
let retired_discovery_links = plan_retire_sibling_discovery_links(&refreshable)?;
let migration_requires_manifest =
!historical.is_empty() || !retired_siblings.mutations.is_empty();
if !historical.is_empty() {
mutations.extend(historical);
}
if migration_requires_manifest {
// Re-record every successfully validated package in this sibling group. This is safe even
// when its bytes were already current, and it prevents a retired package or old general
// package record from surviving after its files move.
for target in &refreshable {
if !written.iter().any(|existing| {
existing.root == target.root && existing.package.name == target.package.name
}) {
written.push(target.clone());
}
}
}
if !mutations.is_empty() || !retired_siblings.mutations.is_empty() {
mutations.extend(plan_record_skill_manifest(&manifest_path, &written, &[])?);
}
let discovery_link_changes = retired_discovery_links
.iter()
.map(|(link, expected)| SymlinkChange::remove(link.clone(), expected.clone()))
.collect::<Vec<_>>();
let normalized = normalize_planned_mutations(mutations)?;
if !dry_run && (!normalized.is_empty() || !discovery_link_changes.is_empty()) {
execute_planned_transaction_with_links(
receipt_path,
&normalized,
&discovery_link_changes,
|_, change| publish_symlink_change(change),
)?;
prune_retired_skill_directories(&retired_siblings.retired_roots);
}
for root in retired_siblings.retired_roots {
outcomes.push(SkillWriteOutcome {
label: "retired corrections".to_string(),
root: root.display().to_string(),
action: if dry_run {
"would remove retired package".to_string()
} else {
"removed retired package".to_string()
},
problem: None,
changed: !dry_run,
});
}
for (link, _) in retired_discovery_links {
outcomes.push(SkillWriteOutcome {
label: "retired corrections".to_string(),
root: link.display().to_string(),
action: if dry_run {
"would remove retired discovery link".to_string()
} else {
"removed retired discovery link".to_string()
},
problem: None,
changed: !dry_run,
});
}
Ok(outcomes)
}
/// What the post-upgrade refresh did, so the caller can say so in one line.
#[derive(Debug)]
pub(crate) enum UpgradeRefresh {
/// Already refreshed for this version, or no aise-owned skill is recorded as installed.
NotNeeded,
/// Every recorded skill root and every client instruction file was checked once.
Checked {
skills: Vec<SkillWriteOutcome>,
instructions: Vec<InstructionRefresh>,
},
/// Another aise process holds the integration lock; the next command tries again.
Deferred,
}
/// What the post-upgrade refresh did to one client instruction file.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum InstructionRefresh {
/// Its aise block held text an earlier release wrote and now holds this build's.
Updated(PathBuf),
/// Its aise block holds text aise never wrote, so it was left as the user wrote it.
Edited(PathBuf),
}
/// Bring owned, unedited integration files up to this build once after aise changes version.
///
/// Every release changes the packaged skill, if only its `metadata.version` line, so without this
/// an upgrade through `uv tool upgrade`, pip, Cargo, or a native archive left the installed skill
/// outdated until someone ran `aise integrations install`. Only `aise package update` refreshed it.
/// Skills go through the same conservative planner as `aise skills update`: files still
/// byte-identical to what aise recorded are rewritten, and an edited file is left alone and
/// reported. It visits only the roots the install manifest records, so it never discovers or
/// configures a new client. Instruction blocks in the clients' CLAUDE.md, AGENTS.md, and GEMINI.md
/// are replaced in place only while they hold text some release wrote.
///
/// # Errors
///
/// Returns an error when the manifest cannot be read, a root cannot be planned for a reason that is
/// not about ownership, or the transaction fails for a reason other than lock contention.
pub(crate) fn refresh_integrations_after_upgrade(receipt_path: &Path) -> Result<UpgradeRefresh> {
refresh_integrations_for_version(
receipt_path,
env!("CARGO_PKG_VERSION"),
ClientLayout::discover().ok().as_ref(),
)
}
/// `layout` locates the client instruction files; `None` checks skills only.
fn refresh_integrations_for_version(
receipt_path: &Path,
version: &str,
layout: Option<&ClientLayout>,
) -> Result<UpgradeRefresh> {
if !is_newer_than_refreshed(receipt_path, version) {
return Ok(UpgradeRefresh::NotNeeded);
}
let manifest_path = crate::skill_manifest::manifest_path(receipt_path);
let manifest = crate::skill_manifest::load_manifest(&manifest_path)?;
let mut roots = manifest_recorded_skill_roots(&manifest)
.with_context(|| format!("read the skill install record {}", manifest_path.display()))?;
// Instruction files hold no install record, so the recorded skill is the evidence that aise
// installed integrations with this configuration; without one there is nothing to refresh,
// and a process run with its own empty configuration never reads the user's client files.
if roots.is_empty() {
return Ok(UpgradeRefresh::NotNeeded);
}
// A recorded root whose directory is gone was removed by the user; an upgrade must not put it
// back. `aise integrations install` restores it on request.
roots.retain(|root| root.is_dir());
let outcome = (|| {
let skills = if roots.is_empty() {
Vec::new()
} else {
write_owned_skills(&roots, receipt_path, false, false)?
};
let instructions = match layout {
Some(layout) => refresh_earlier_instruction_blocks(receipt_path, layout)?,
None => Vec::new(),
};
Ok::<_, anyhow::Error>((skills, instructions))
})();
let (skills, instructions) = match outcome {
Ok(checked) => checked,
Err(error)
if error
.downcast_ref::<crate::text_file_transaction::IntegrationLockHeld>()
.is_some() =>
{
return Ok(UpgradeRefresh::Deferred)
}
Err(error) => return Err(error),
};
// Recorded only after the check completed, so a failure is retried by the next command rather
// than silenced until the next release; an edited file is an outcome, not a failure, and is
// reported once. A config directory that cannot hold the marker only costs repeating a check
// that is safe to repeat, so failing to write it is not reported.
record_refreshed_version(receipt_path, version);
Ok(UpgradeRefresh::Checked {
skills,
instructions,
})
}
/// Replace every client instruction block that holds text an earlier release wrote.
///
/// The block is rewritten where it stands, unlike `aise integrations install`, which moves it to
/// the end of the file. A symlinked, non-UTF-8, or unreadable file is skipped: install refuses such
/// files, so aise never wrote a block into one.
fn refresh_earlier_instruction_blocks(
receipt_path: &Path,
layout: &ClientLayout,
) -> Result<Vec<InstructionRefresh>> {
let mut seen = std::collections::HashSet::new();
let mut mutations = Vec::new();
let mut refreshes = Vec::new();
for target in CONCRETE_CLIENTS
.into_iter()
.flat_map(|client| instruction_targets_for_layout(client, layout))
{
if !seen.insert(target.path.clone()) {
continue;
}
let path = match target.format {
InstructionFormat::ClaudeImport => {
let imports_it = read_optional_utf8_regular_file(&target.path)
.ok()
.flatten()
.is_some_and(|text| text.lines().any(is_instruction_reference_line));
if !imports_it {
continue;
}
aise_instruction_path(&target.path)
}
InstructionFormat::InlineBlock | InstructionFormat::InlineCliBlock => {
target.path.clone()
}
};
let Some(original) = read_optional_utf8_regular_file(&path).ok().flatten() else {
continue;
};
let refreshed = match target.format {
InstructionFormat::ClaudeImport => refreshed_instruction_file(&original),
format => refreshed_instruction_block(&original, format),
};
match refreshed {
BlockRefresh::Current => {}
BlockRefresh::Edited => refreshes.push(InstructionRefresh::Edited(path)),
BlockRefresh::Replace(next) => {
mutations.push(planned_write(&path, &Some(original), next));
refreshes.push(InstructionRefresh::Updated(path));
}
}
}
let mutations = normalize_planned_mutations(mutations)?;
if !mutations.is_empty() {
execute_planned_transaction_with_links(receipt_path, &mutations, &[], |_, _| Ok(()))?;
}
Ok(refreshes)
}
enum BlockRefresh {
/// Already this build's text, or not an aise block this refresh can read.
Current,
Edited,
Replace(String),
}
/// Whether `line` is instruction text some release of aise wrote.
fn is_aise_instruction_line(line: &str) -> bool {
[
INSTRUCTIONS_LINE,
CLI_INSTRUCTIONS_LINE,
LEGACY_INSTRUCTIONS_LINE,
]
.contains(&line)
|| EARLIER_INSTRUCTION_LINE_DIGESTS
.contains(&crate::hashing::sha256(line.as_bytes()).as_str())
}
/// The inline block in AGENTS.md or GEMINI.md, brought up to this build if aise wrote its text.
fn refreshed_instruction_block(text: &str, format: InstructionFormat) -> BlockRefresh {
if text.matches(INSTRUCTIONS_START).count() != 1 || text.matches(INSTRUCTIONS_END).count() != 1
{
return BlockRefresh::Current;
}
let (Some(start), Some(end)) = (text.find(INSTRUCTIONS_START), text.find(INSTRUCTIONS_END))
else {
return BlockRefresh::Current;
};
let end = end + INSTRUCTIONS_END.len();
if end <= start {
return BlockRefresh::Current;
}
let block = &text[start..end];
// Written with the block's own line endings, so a CRLF file (common on Windows) stays CRLF.
let current = with_line_endings_of(block, instruction_block(format).trim_end());
if block == current {
return BlockRefresh::Current;
}
let body = block
.split_once('\n')
.and_then(|(_, rest)| rest.strip_suffix(INSTRUCTIONS_END))
.map(str::trim);
if !body.is_some_and(is_aise_instruction_line) {
return BlockRefresh::Edited;
}
BlockRefresh::Replace(format!("{}{current}{}", &text[..start], &text[end..]))
}
/// AI_SESSION_SEARCH.md, which CLAUDE.md imports, brought up to this build if aise wrote its text.
fn refreshed_instruction_file(original: &str) -> BlockRefresh {
if is_current_instruction_file(original) {
return BlockRefresh::Current;
}
let text = &original.replace("\r\n", "\n");
let body = if text.trim_end() == legacy_instruction_file_content().trim_end() {
Some(LEGACY_INSTRUCTIONS_LINE)
} else {
text.trim_end()
.strip_prefix("# AI Session Search (`aise`)\n\n")
.and_then(|managed| managed.strip_prefix(INSTRUCTIONS_FILE_START))
.and_then(|managed| managed.strip_suffix(INSTRUCTIONS_FILE_END))
.map(str::trim)
};
if body.is_some_and(is_aise_instruction_line) {
BlockRefresh::Replace(with_line_endings_of(original, &instruction_file_content()))
} else {
BlockRefresh::Edited
}
}
/// `text`, which uses `\n`, with CRLF line endings when `sample` uses them.
fn with_line_endings_of(sample: &str, text: &str) -> String {
if sample.contains("\r\n") {
text.replace('\n', "\r\n")
} else {
text.to_string()
}
}
/// Whether `version` is newer than the version that last refreshed the installed integrations.
///
/// Only a newer version refreshes. Two installs of different versions, such as an old Cargo build
/// on PATH beside a newer uv install that MCP clients launch, must not rewrite the skill back and
/// forth, and the older one must never downgrade it.
fn is_newer_than_refreshed(receipt_path: &Path, version: &str) -> bool {
let marker = crate::skill_manifest::refreshed_version_path(receipt_path);
let Ok(recorded) = fs::read_to_string(marker) else {
return true;
};
let recorded = recorded.trim();
match (
semver::Version::parse(version),
semver::Version::parse(recorded),
) {
(Ok(running), Ok(recorded)) => running > recorded,
_ => version != recorded,
}
}
/// Record that `version` checked every installed integration, unless a newer one already did.
fn record_refreshed_version(receipt_path: &Path, version: &str) {
if is_newer_than_refreshed(receipt_path, version) {
let marker = crate::skill_manifest::refreshed_version_path(receipt_path);
let _ = fs::write(marker, format!("{version}\n"));
}
}
/// Run [`refresh_integrations_after_upgrade`] for a CLI or MCP command and report it on stderr.
///
/// Stdout carries command output and the MCP protocol, so every line goes to stderr, and nothing
/// is printed when nothing changed. `[integrations] refresh_after_upgrade = false` turns it off.
pub(crate) fn refresh_integrations_after_upgrade_and_report(
config: &crate::config::Config,
config_path: &Path,
) {
if !config.integrations.refresh_after_upgrade {
return;
}
let receipt = default_transaction_receipt(config_path);
let version = env!("CARGO_PKG_VERSION");
match refresh_integrations_after_upgrade(&receipt) {
Ok(UpgradeRefresh::NotNeeded | UpgradeRefresh::Deferred) => {}
Ok(UpgradeRefresh::Checked {
skills,
instructions,
}) => {
for outcome in skills {
if let Some(problem) = outcome.problem {
eprintln!(
"aise: left the skill at {} unchanged after upgrading to {version}: {problem}",
outcome.root
);
} else if outcome.changed {
// A skill rewrite is the common case and gets the plain sentence; other
// changes, such as removing a retired package, say what they did.
if outcome.action.starts_with("rewritten") {
eprintln!(
"aise: updated the installed skill at {} to {version}",
outcome.root
);
} else {
eprintln!("aise: {} at {} for {version}", outcome.action, outcome.root);
}
}
}
for refresh in instructions {
match refresh {
InstructionRefresh::Updated(path) => eprintln!(
"aise: updated the aise instructions in {} to {version}",
path.display()
),
InstructionRefresh::Edited(path) => eprintln!(
"aise: left the aise instructions in {} unchanged after upgrading to \
{version}: they differ from any text aise wrote, so replacing them could \
destroy an edit you meant to keep.\nInspect them, or run `aise \
integrations install --no-skill` to replace them with this version's",
path.display()
),
}
}
}
Err(error) => eprintln!(
"aise: could not update the installed integrations for {version}: {error:#}\nEvery \
command retries until this succeeds; `aise skills update` retries now and explains \
any repair, and `aise integrations status` shows their state. To stop retrying, set `[integrations] \
refresh_after_upgrade = false` in {}",
config_path.display()
),
}
}
fn manifest_recorded_skill_roots(
manifest: &crate::skill_manifest::ManifestState,
) -> Result<Vec<PathBuf>> {
match manifest {
crate::skill_manifest::ManifestState::Absent => Ok(Vec::new()),
crate::skill_manifest::ManifestState::Unreadable(problem) => {
bail!("cannot enumerate recorded skill roots: {problem}")
}
crate::skill_manifest::ManifestState::Loaded(manifest) => manifest
.installations
.iter()
.map(|installation| {
installation
.root
.to_path_buf()
.context("decode a skill root from the install manifest")
})
.collect(),
}
}
/// What removing one skill directory would do, and what it refuses to touch.
#[derive(Debug, Default)]
struct SkillRemovalPlan {
mutations: Vec<PlannedFileMutation>,
/// Reasons the whole tree is being preserved. Non-empty means nothing is removed.
preserved: Vec<String>,
/// Directories to try pruning afterwards, deepest first. Only ones this removal emptied.
prune: Vec<PathBuf>,
}
/// Every file under `root`, relative to it, as slash-separated strings.
///
/// Used to find entries `aise` did not write. A skill directory is small and flat by design, so a
/// full walk is cheap and, unlike a shallow read, actually notices a file dropped into
/// `references/`.
fn skill_tree_entries(root: &Path) -> Result<Vec<String>> {
fn walk(base: &Path, dir: &Path, found: &mut Vec<String>) -> Result<()> {
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => {
return Err(error)
.with_context(|| format!("failed to read skill directory {}", dir.display()))
}
};
for entry in entries {
let entry = entry
.with_context(|| format!("failed to list skill directory {}", dir.display()))?;
let path = entry.path();
// `symlink_metadata`, not `metadata`: a symlink to a directory must be recorded as an
// entry rather than followed out of the tree.
let meta = std::fs::symlink_metadata(&path)
.with_context(|| format!("failed to inspect {}", path.display()))?;
if meta.is_dir() {
walk(base, &path, found)?;
} else if let Ok(relative) = path.strip_prefix(base) {
found.push(
relative
.components()
.map(|component| component.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/"),
);
}
}
Ok(())
}
let mut found = Vec::new();
walk(root, root, &mut found)?;
found.sort();
Ok(found)
}
/// Directories under a skill root that this removal may have emptied, deepest first.
///
/// Derived from the package file list rather than by walking, so pruning can only ever consider
/// directories `aise` itself created. Nothing recursive: each is removed only if already empty.
fn managed_skill_directories(target: &SkillTarget) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = target
.package
.files
.iter()
.filter_map(|file| Path::new(file.relative_path).parent())
.filter(|parent| !parent.as_os_str().is_empty())
.map(|parent| target.root.join(parent))
.collect();
dirs.sort();
dirs.dedup();
// Deepest first, then the root itself last.
dirs.sort_by_key(|path| std::cmp::Reverse(path.components().count()));
dirs.push(target.root.clone());
dirs
}
fn prune_retired_skill_directories(roots: &[PathBuf]) {
for root in roots {
let target = SkillTarget {
label: "retired corrections",
root: root.clone(),
package: &RETIRED_SKILL_PACKAGE,
discovery_links: Vec::new(),
detect_paths: Vec::new(),
detect_binaries: Vec::new(),
};
prune_emptied_skill_directories(&managed_skill_directories(&target));
}
}
/// Exact directories that may become empty after removing an already-enumerated skill tree.
///
/// Used only by explicit force cleanup. It derives ancestors from relative file entries that were
/// read under the exact authorized root; it never walks or removes a directory recursively.
fn skill_tree_directories(root: &Path, entries: &[String]) -> Vec<PathBuf> {
let mut directories = std::collections::BTreeSet::new();
for entry in entries {
let mut parent = Path::new(entry).parent();
while let Some(relative) = parent {
if relative.as_os_str().is_empty() {
break;
}
directories.insert(root.join(relative));
parent = relative.parent();
}
}
let mut directories = directories.into_iter().collect::<Vec<_>>();
directories.sort_by_key(|path| std::cmp::Reverse(path.components().count()));
directories.push(root.to_path_buf());
directories
}
/// Plan the removal of one managed skill directory.
///
/// Normal uninstall removes a tree ONLY when every managed file is exactly what install recorded
/// and nothing else lives in the directory. Any other state preserves the whole directory and
/// says why. Removing an unchanged `SKILL.md` while leaving a user's edited policy would orphan
/// their data behind a skill that no longer declares itself, which is worse than leaving both.
///
/// `force` is the explicitly destructive path: it removes every entry under the root, including
/// files `aise` never wrote. Its caller is responsible for the ownership and exact-root checks.
fn plan_remove_skill_file(
target: &SkillTarget,
manifest: &crate::skill_manifest::ManifestState,
force: bool,
) -> Result<SkillRemovalPlan> {
let anchor = target.anchor();
let anchor_text = read_optional_utf8_regular_file(&anchor)?;
let entries = skill_tree_entries(&target.root)?;
if anchor_text.is_none() && entries.is_empty() {
return Ok(SkillRemovalPlan::default());
}
let owned_by_marker = anchor_text.as_deref().is_some_and(is_managed_skill_anchor);
let recorded = manifest.installation(&target.root);
if !owned_by_marker && recorded.is_none() {
bail!(
"refusing to remove unmanaged AI Session Search skill {}; delete it manually if \
you're sure, or pass --keep-skill to leave it untouched",
anchor.display()
);
}
let mut reasons = Vec::new();
for file in target.package.files {
let path = target.root.join(file.relative_path);
match classify_managed_file(
&path,
file.content,
recorded.and_then(|installation| installation.file(file.relative_path)),
)? {
ManagedFileState::Current | ManagedFileState::OutdatedUntouched => {}
// `Missing` is fine: there is simply nothing to remove for that entry.
ManagedFileState::Missing => {}
ManagedFileState::ModifiedOrDamaged => reasons.push(format!(
"{} differs from what install recorded (modified or damaged)",
path.display()
)),
ManagedFileState::LegacyUnknown => reasons.push(format!(
"{} is not the version this build ships and there is no install record, so \
whether you changed it cannot be determined",
path.display()
)),
}
}
// Files the manifest records that this build no longer ships, as when a package is retired.
// They are owned — aise wrote them — but the loop above cannot vet them, because there is no
// embedded copy to compare against. Comparing to the recorded digest is what keeps an edit from
// being deleted as though it were untouched.
let shipped: std::collections::BTreeSet<&str> = target
.package
.files
.iter()
.map(|file| file.relative_path)
.collect();
let recorded_only = recorded
.into_iter()
.flat_map(|installation| installation.files.iter())
.filter(|file| !shipped.contains(file.relative_path.as_str()));
let mut recorded_only_paths = std::collections::BTreeSet::new();
for file in recorded_only {
recorded_only_paths.insert(file.relative_path.as_str());
let path = target.root.join(&file.relative_path);
let Some(text) = read_optional_utf8_regular_file(&path)? else {
continue;
};
if file.sha256 != crate::hashing::sha256(text.as_bytes()) {
reasons.push(format!(
"{} differs from what install recorded (modified or damaged)",
path.display()
));
}
}
// What this build ships, plus whatever the manifest records for this root. The manifest is the
// ownership authority, so a file aise installed under an earlier layout stays owned even after
// it leaves the current package list; judging by the current list alone would report a file
// aise itself wrote as "not written by aise" and block its own cleanup.
let managed: std::collections::BTreeSet<&str> = shipped
.iter()
.copied()
.chain(recorded_only_paths.iter().copied())
.collect();
let unmanaged: Vec<&String> = entries
.iter()
.filter(|entry| !managed.contains(entry.as_str()))
.collect();
for entry in &unmanaged {
reasons.push(format!(
"{} was not written by aise",
target.root.join(entry).display()
));
}
if !reasons.is_empty() && !force {
return Ok(SkillRemovalPlan {
preserved: reasons,
..Default::default()
});
}
// Managed files always; every other entry only under force.
let removable: Vec<String> = if force {
entries
} else {
entries
.into_iter()
.filter(|entry| managed.contains(entry.as_str()))
.collect()
};
let prune = if force {
skill_tree_directories(&target.root, &removable)
} else {
managed_skill_directories(target)
};
let mut mutations = Vec::with_capacity(removable.len());
for entry in removable {
let path = target.root.join(&entry);
if let Some(original) = read_optional_utf8_regular_file(&path)? {
mutations.push(PlannedFileMutation::Remove { path, original });
} else if force {
// A non-UTF-8 or non-regular entry cannot go through the text transaction. Refuse
// rather than silently leaving it behind while claiming a full cleanup.
bail!(
"cannot remove {}: it is not a regular UTF-8 text file, so this transaction \
cannot record it for rollback. Delete it manually, then re-run",
path.display()
);
}
}
Ok(SkillRemovalPlan {
mutations,
preserved: Vec::new(),
prune,
})
}
/// Remove directories this uninstall emptied, best effort.
///
/// Deliberately NOT recursive and deliberately not an error: `remove_dir` refuses a non-empty
/// directory, which is exactly the guard wanted here. A leftover empty directory is untidy; a
/// recursive delete that raced a user creating a file in it is data loss.
fn prune_emptied_skill_directories(directories: &[PathBuf]) {
for directory in directories {
let _ = std::fs::remove_dir(directory);
}
}
/// One managed file's state, relative to both the embedded bytes and the install record.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum ManagedFileState {
/// Byte-identical to what this build embeds.
Current,
/// Not current, but exactly the bytes recorded at install: an older aise, untouched.
OutdatedUntouched,
/// Not current, and there is no install record to compare against, so whether anyone edited
/// it is unknowable. Never reported as either "untouched" or "modified".
LegacyUnknown,
/// The file aise wrote is gone.
Missing,
/// Differs from what install recorded. Intent is unknowable, so this never claims "edited".
ModifiedOrDamaged,
}
impl ManagedFileState {
/// The word `status` prints for a whole directory in this state.
const fn label(self) -> &'static str {
match self {
Self::Current => "configured",
Self::OutdatedUntouched => "outdated, untouched",
Self::LegacyUnknown => "legacy, ownership uncertain",
Self::Missing => "missing",
Self::ModifiedOrDamaged => "modified or damaged",
}
}
}
/// Classify one managed file against the embedded bytes and the install record.
///
/// The record is what makes "outdated" and "modified" separable. Byte comparison alone answers
/// only "does this equal the current build", and a NO there covers both an older install nobody
/// touched and a file somebody rewrote -- reporting one as the other tells the user something
/// false about their own filesystem (finding S13).
fn classify_managed_file(
path: &Path,
embedded: &str,
recorded: Option<&crate::skill_manifest::InstalledSkillFile>,
) -> Result<ManagedFileState> {
let Some(text) = read_optional_utf8_regular_file(path)? else {
return Ok(ManagedFileState::Missing);
};
if text == embedded {
return Ok(ManagedFileState::Current);
}
Ok(match recorded {
None => ManagedFileState::LegacyUnknown,
Some(entry) if entry.sha256 == crate::hashing::sha256(text.as_bytes()) => {
ManagedFileState::OutdatedUntouched
}
Some(_) => ManagedFileState::ModifiedOrDamaged,
})
}
/// Report one skill directory's state, considering EVERY managed file.
///
/// A directory whose `SKILL.md` is current but whose `corrections/policy.toml` is missing is not
/// `configured`: reporting the anchor alone would call a half-installed skill healthy, which is
/// what an interrupted upgrade leaves behind.
///
/// The whole directory takes its most alarming file's state, ordered by how much a user needs to
/// know: `modified or damaged` outranks `missing`, which outranks `legacy`, which outranks
/// `outdated`. Summarizing to the mildest state would hide the one file that needs attention.
fn status_skill_file(
target: &SkillTarget,
manifest: &crate::skill_manifest::ManifestState,
) -> Result<String> {
let anchor_text = read_optional_utf8_regular_file(&target.anchor())?;
// Present but unowned: whatever else is in the directory, `aise` will not touch it.
if anchor_text
.as_deref()
.is_some_and(|text| !is_managed_skill_anchor(text))
{
return Ok("modified or unmanaged".to_string());
}
let recorded = manifest.installation(&target.root);
let mut states = Vec::with_capacity(target.package.files.len());
for file in target.package.files {
states.push(classify_managed_file(
&target.root.join(file.relative_path),
file.content,
recorded.and_then(|installation| installation.file(file.relative_path)),
)?);
}
let absent = states
.iter()
.filter(|state| **state == ManagedFileState::Missing)
.count();
if absent == states.len() {
return Ok(ManagedFileState::Missing.label().to_string());
}
// Some files gone is a different fact from every file gone, and `max()` alone gave both the
// same word: an install one reference file short of a newer build reported `missing` while
// four of five files were byte-current, which reads as "nothing is installed" about a working
// skill. The count is the actionable part, and the repair rewrites only what is absent.
if absent > 0 {
let remaining = states
.iter()
.filter(|state| **state != ManagedFileState::Missing)
.copied()
.max()
.unwrap_or(ManagedFileState::Current);
let mut label = format!(
"incomplete: {absent} of {} managed files missing; `aise integrations install` \
restores them",
states.len()
);
// A package can be short some files and hold damaged ones at the same time. Reporting only
// the count would drop the second fact, which the repair does not address.
if remaining != ManagedFileState::Current {
label.push_str(&format!("; remaining files {}", remaining.label()));
}
return Ok(label);
}
let worst = states
.into_iter()
.max()
.unwrap_or(ManagedFileState::Missing);
Ok(worst.label().to_string())
}
fn plan_upsert_target(target: &Target, binary: &Path) -> Result<Vec<PlannedFileMutation>> {
let binary = binary_config_value(binary)?;
match target.format {
ConfigFormat::JsonMcpServers => plan_upsert_keyed_json_server(
&target.path,
"mcpServers",
json!({
"command": binary,
"args": ["mcp", "serve"]
}),
),
ConfigFormat::CodexToml => plan_upsert_codex_mcp_server(&target.path, binary),
ConfigFormat::VscodeServers => plan_upsert_keyed_json_server(
&target.path,
"servers",
json!({
"type": "stdio",
"command": binary,
"args": ["mcp", "serve"]
}),
),
ConfigFormat::ZedContextServers => plan_upsert_keyed_json_server(
&target.path,
"context_servers",
json!({
"command": binary,
"args": ["mcp", "serve"]
}),
),
ConfigFormat::OpenCode => plan_upsert_keyed_json_server(
&target.path,
"mcp",
json!({
"command": [binary, "mcp", "serve"],
"enabled": true
}),
),
}
}
#[cfg(test)]
fn remove_target(target: &Target) -> Result<bool> {
let mutations = normalize_planned_mutations(plan_remove_target(target)?)?;
let changed = !mutations.is_empty();
publish_planned_mutations(&mutations)?;
Ok(changed)
}
fn plan_remove_target(target: &Target) -> Result<Vec<PlannedFileMutation>> {
match target.format {
ConfigFormat::JsonMcpServers => plan_remove_keyed_json_server(&target.path, "mcpServers"),
ConfigFormat::CodexToml => plan_remove_codex_mcp_server(&target.path),
ConfigFormat::VscodeServers => plan_remove_keyed_json_server(&target.path, "servers"),
ConfigFormat::ZedContextServers => {
plan_remove_keyed_json_server(&target.path, "context_servers")
}
ConfigFormat::OpenCode => plan_remove_keyed_json_server(&target.path, "mcp"),
}
}
fn status_target(target: &Target) -> Result<&'static str> {
match target.format {
ConfigFormat::JsonMcpServers => {
status_json_keyed_server(&target.path, "mcpServers", target.format)
}
ConfigFormat::CodexToml => status_codex_mcp_server(&target.path),
ConfigFormat::VscodeServers => {
status_json_keyed_server(&target.path, "servers", target.format)
}
ConfigFormat::ZedContextServers => {
status_json_keyed_server(&target.path, "context_servers", target.format)
}
ConfigFormat::OpenCode => status_json_keyed_server(&target.path, "mcp", target.format),
}
}
#[cfg(test)]
fn upsert_json_mcp_server(path: &Path, entry: Value) -> Result<()> {
upsert_keyed_json_server(path, "mcpServers", entry)
}
#[cfg(test)]
fn remove_json_mcp_server(path: &Path) -> Result<bool> {
remove_keyed_json_server(path, "mcpServers")
}
#[cfg(test)]
fn upsert_keyed_json_server(path: &Path, container_key: &str, entry: Value) -> Result<()> {
let mutations =
normalize_planned_mutations(plan_upsert_keyed_json_server(path, container_key, entry)?)?;
publish_planned_mutations(&mutations)
}
fn plan_upsert_keyed_json_server(
path: &Path,
container_key: &str,
entry: Value,
) -> Result<Vec<PlannedFileMutation>> {
let original = read_optional_utf8_regular_file(path)?;
let mut root = parse_json_object_or_empty(path, original.as_deref())?;
let servers = root
.entry(container_key.to_string())
.or_insert_with(|| Value::Object(Map::new()));
let Some(servers) = servers.as_object_mut() else {
return Err(anyhow!(
"{} has a non-object `{container_key}` value; fix the JSON so `{container_key}` is \
`{{}}`-shaped, or back up and move the file aside before rerunning `aise integrations install`",
path.display()
));
};
for legacy_name in LEGACY_SERVER_NAMES {
servers.remove(legacy_name);
}
servers.insert(SERVER_NAME.to_string(), entry);
let content = serde_json::to_string_pretty(&Value::Object(root))? + "\n";
Ok(vec![planned_write(path, &original, content)])
}
#[cfg(test)]
fn remove_keyed_json_server(path: &Path, container_key: &str) -> Result<bool> {
let mutations =
normalize_planned_mutations(plan_remove_keyed_json_server(path, container_key)?)?;
let changed = !mutations.is_empty();
publish_planned_mutations(&mutations)?;
Ok(changed)
}
fn plan_remove_keyed_json_server(
path: &Path,
container_key: &str,
) -> Result<Vec<PlannedFileMutation>> {
let original = read_optional_utf8_regular_file(path)?;
let Some(text) = original.as_deref() else {
return Ok(Vec::new());
};
let mut root = parse_json_object_or_empty(path, Some(text))?;
let removed = root
.get_mut(container_key)
.and_then(Value::as_object_mut)
.is_some_and(remove_owned_json_server_entries);
if removed {
let content = serde_json::to_string_pretty(&Value::Object(root))? + "\n";
Ok(vec![planned_write(path, &original, content)])
} else {
Ok(Vec::new())
}
}
#[cfg(test)]
fn upsert_codex_mcp_server(path: &Path, binary: &Path) -> Result<()> {
let binary = binary_config_value(binary)?;
publish_planned_mutations(&normalize_planned_mutations(plan_upsert_codex_mcp_server(
path, binary,
)?)?)
}
fn plan_upsert_codex_mcp_server(path: &Path, binary: &str) -> Result<Vec<PlannedFileMutation>> {
let original = read_optional_utf8_regular_file(path)?;
let text = original.as_deref().unwrap_or_default();
let mut document = parse_codex_document(path, text)?;
let servers = document
.entry("mcp_servers")
.or_insert_with(|| toml_edit::Item::Table(toml_edit::Table::new()))
.as_table_mut()
.ok_or_else(|| {
anyhow!(
"{} has non-table mcp_servers; convert it to [mcp_servers] before installing aise",
path.display()
)
})?;
let mut server = toml_edit::Table::new();
server.insert("command", toml_edit::value(binary));
let mut args = toml_edit::Array::new();
args.extend(["mcp", "serve"]);
server.insert("args", toml_edit::value(args));
for legacy_name in LEGACY_SERVER_NAMES {
servers.remove(legacy_name);
}
servers.insert(SERVER_NAME, toml_edit::Item::Table(server));
let content = document.to_string();
parse_codex_document(path, &content).with_context(|| {
format!(
"refusing to publish generated Codex TOML for {}",
path.display()
)
})?;
Ok(vec![planned_write(path, &original, content)])
}
#[cfg(test)]
fn remove_codex_mcp_server(path: &Path) -> Result<bool> {
let mutations = normalize_planned_mutations(plan_remove_codex_mcp_server(path)?)?;
let changed = !mutations.is_empty();
publish_planned_mutations(&mutations)?;
Ok(changed)
}
fn plan_remove_codex_mcp_server(path: &Path) -> Result<Vec<PlannedFileMutation>> {
let original = read_optional_utf8_regular_file(path)?;
let Some(text) = original.as_deref() else {
return Ok(Vec::new());
};
let mut document = parse_codex_document(path, text)?;
let removed = document
.get_mut("mcp_servers")
.and_then(toml_edit::Item::as_table_mut)
.is_some_and(remove_owned_toml_server_entries);
if !removed {
Ok(Vec::new())
} else {
let content = document.to_string();
parse_codex_document(path, &content).with_context(|| {
format!(
"refusing to publish generated Codex TOML for {}",
path.display()
)
})?;
Ok(vec![planned_write(path, &original, content)])
}
}
fn parse_codex_document(path: &Path, text: &str) -> Result<toml_edit::DocumentMut> {
text.parse::<toml_edit::DocumentMut>()
.with_context(|| format!("failed to parse TOML in {}", path.display()))
}
fn remove_owned_json_server_entries(servers: &mut Map<String, Value>) -> bool {
let mut removed = servers.remove(SERVER_NAME).is_some();
for legacy_name in LEGACY_SERVER_NAMES {
removed |= servers.remove(legacy_name).is_some();
}
removed
}
fn remove_owned_toml_server_entries(servers: &mut toml_edit::Table) -> bool {
let mut removed = servers.remove(SERVER_NAME).is_some();
for legacy_name in LEGACY_SERVER_NAMES {
removed |= servers.remove(legacy_name).is_some();
}
removed
}
fn binary_config_value(binary: &Path) -> Result<&str> {
binary.to_str().ok_or_else(|| {
anyhow!(
"MCP executable path {} is not valid UTF-8 and cannot be represented in JSON or TOML \
client configuration; install aise at a UTF-8 path or pass a different path with \
--binary",
binary.display()
)
})
}
fn parse_json_object_or_empty(path: &Path, text: Option<&str>) -> Result<Map<String, Value>> {
let text = text.unwrap_or_default();
if text.trim().is_empty() {
return Ok(Map::new());
}
match serde_json::from_str::<Value>(text)
.with_context(|| format!("failed to parse JSON in {}", path.display()))?
{
Value::Object(map) => Ok(map),
_ => Err(anyhow!(
"{} must contain a JSON object at the top level, not an array or scalar; fix the \
file's contents, or back up and move it aside before rerunning `aise integrations install`",
path.display()
)),
}
}
fn json_array_is_strings(value: Option<&Value>, expected: &[&str]) -> bool {
value.and_then(Value::as_array).is_some_and(|items| {
items.len() == expected.len()
&& items
.iter()
.zip(expected)
.all(|(item, expected)| item.as_str() == Some(*expected))
})
}
fn json_entry_is_current(entry: &Value, format: ConfigFormat) -> bool {
let Some(entry) = entry.as_object() else {
return false;
};
match format {
ConfigFormat::OpenCode => {
entry.get("enabled").and_then(Value::as_bool) == Some(true)
&& entry
.get("command")
.and_then(Value::as_array)
.is_some_and(|command| {
command.len() == 3
&& command[0].as_str().is_some_and(|value| !value.is_empty())
&& command[1].as_str() == Some("mcp")
&& command[2].as_str() == Some("serve")
})
}
ConfigFormat::JsonMcpServers
| ConfigFormat::VscodeServers
| ConfigFormat::ZedContextServers => {
let command_is_set = entry
.get("command")
.and_then(Value::as_str)
.is_some_and(|command| !command.is_empty());
let type_is_valid = !matches!(format, ConfigFormat::VscodeServers)
|| entry.get("type").and_then(Value::as_str) == Some("stdio");
command_is_set
&& type_is_valid
&& json_array_is_strings(entry.get("args"), &["mcp", "serve"])
}
ConfigFormat::CodexToml => false,
}
}
fn status_json_keyed_server(
path: &Path,
container_key: &str,
format: ConfigFormat,
) -> Result<&'static str> {
let text = read_optional_utf8_regular_file(path)?;
if text.is_none() {
return Ok("missing");
}
let root = parse_json_object_or_empty(path, text.as_deref())?;
let servers = root.get(container_key).and_then(Value::as_object);
let entry = servers.and_then(|servers| servers.get(SERVER_NAME));
Ok(match entry {
Some(entry) if json_entry_is_current(entry, format) => "configured",
Some(_) => "stale",
None if servers.is_some_and(|servers| {
LEGACY_SERVER_NAMES
.iter()
.any(|name| servers.contains_key(*name))
}) =>
{
"stale"
}
None => "not configured",
})
}
fn status_codex_mcp_server(path: &Path) -> Result<&'static str> {
let text = read_optional_utf8_regular_file(path)?;
let Some(text) = text else {
return Ok("missing");
};
let root = parse_codex_document(path, &text)?;
let servers = root.get("mcp_servers").and_then(toml_edit::Item::as_table);
let entry = servers
.and_then(|servers| servers.get(SERVER_NAME))
.and_then(toml_edit::Item::as_table);
Ok(match entry {
Some(entry)
if entry
.get("command")
.and_then(toml_edit::Item::as_value)
.and_then(toml_edit::Value::as_str)
.is_some_and(|command| !command.is_empty())
&& entry
.get("args")
.and_then(toml_edit::Item::as_value)
.and_then(toml_edit::Value::as_array)
.is_some_and(|items| {
items.len() == 2
&& items.get(0).and_then(toml_edit::Value::as_str) == Some("mcp")
&& items.get(1).and_then(toml_edit::Value::as_str) == Some("serve")
}) =>
{
"configured"
}
Some(_) => "stale",
None if servers.is_some_and(|servers| {
LEGACY_SERVER_NAMES
.iter()
.any(|name| servers.contains_key(name))
}) =>
{
"stale"
}
None => "not configured",
})
}
#[cfg(test)]
fn upsert_instruction_file(target: &InstructionTarget) -> Result<()> {
publish_planned_mutations(&normalize_planned_mutations(plan_upsert_instruction_file(
target,
)?)?)
}
#[cfg(test)]
fn remove_instruction_file(target: &InstructionTarget) -> Result<bool> {
let mutations = normalize_planned_mutations(plan_remove_instruction_file(target)?)?;
let changed = !mutations.is_empty();
publish_planned_mutations(&mutations)?;
Ok(changed)
}
fn plan_upsert_instruction_file(target: &InstructionTarget) -> Result<Vec<PlannedFileMutation>> {
match target.format {
InstructionFormat::ClaudeImport => plan_upsert_claude_instruction_file(&target.path),
InstructionFormat::InlineBlock | InstructionFormat::InlineCliBlock => {
plan_upsert_inline_instruction_file(&target.path, target.format)
}
}
}
fn plan_remove_instruction_file(target: &InstructionTarget) -> Result<Vec<PlannedFileMutation>> {
match target.format {
InstructionFormat::ClaudeImport => plan_remove_claude_instruction_file(&target.path),
InstructionFormat::InlineBlock | InstructionFormat::InlineCliBlock => {
plan_remove_inline_instruction_file(&target.path)
}
}
}
fn status_instruction_file(target: &InstructionTarget) -> Result<&'static str> {
match target.format {
InstructionFormat::ClaudeImport => status_claude_instruction_file(&target.path),
InstructionFormat::InlineBlock | InstructionFormat::InlineCliBlock => {
status_inline_instruction_file(&target.path, target.format)
}
}
}
fn plan_upsert_claude_instruction_file(path: &Path) -> Result<Vec<PlannedFileMutation>> {
let original = read_optional_utf8_regular_file(path)?;
let next = upsert_claude_instruction_text(original.as_deref().unwrap_or_default())?;
let mut mutations = vec![plan_write_aise_instruction_file(path)?];
mutations.push(planned_write(path, &original, next));
Ok(mutations)
}
fn plan_remove_claude_instruction_file(path: &Path) -> Result<Vec<PlannedFileMutation>> {
let original = read_optional_utf8_regular_file(path)?;
let mut mutations = Vec::new();
if let Some(text) = original.as_deref() {
if let Some(next) = remove_claude_instruction_text(text)? {
mutations.push(planned_write(path, &original, next));
}
}
if let Some(removal) = plan_remove_aise_instruction_file(path)? {
mutations.push(removal);
}
Ok(mutations)
}
fn status_claude_instruction_file(path: &Path) -> Result<&'static str> {
let text = read_optional_utf8_regular_file(path)?;
let Some(text) = text else {
return Ok("missing");
};
let instruction_file = aise_instruction_path(path);
let instruction_text = read_optional_utf8_regular_file(&instruction_file)?;
if !text.lines().any(is_instruction_reference_line) {
return Ok(
if instruction_text
.as_deref()
.is_some_and(is_managed_instruction_file)
{
"orphaned managed file"
} else {
"not configured"
},
);
}
Ok(match instruction_text.as_deref() {
None => "instruction file missing",
Some(content) if is_current_instruction_file(content) => "configured",
Some(content) if is_managed_instruction_file(content) => "outdated",
Some(_) => "instruction file modified",
})
}
fn upsert_claude_instruction_text(text: &str) -> Result<String> {
let without_legacy = remove_inline_instruction_block(text)?.unwrap_or_else(|| text.to_string());
let without_existing_ref =
remove_instruction_reference(&without_legacy)?.unwrap_or(without_legacy);
let removed = without_existing_ref;
let mut next = removed.trim_end().to_string();
if !next.is_empty() {
next.push_str("\n\n");
}
next.push_str(INSTRUCTIONS_REFERENCE);
next.push('\n');
Ok(next)
}
fn remove_claude_instruction_text(text: &str) -> Result<Option<String>> {
let without_reference = remove_instruction_reference(text)?;
let base = without_reference.as_deref().unwrap_or(text);
let without_legacy = remove_inline_instruction_block(base)?;
Ok(without_legacy.or(without_reference))
}
fn plan_upsert_inline_instruction_file(
path: &Path,
format: InstructionFormat,
) -> Result<Vec<PlannedFileMutation>> {
let original = read_optional_utf8_regular_file(path)?;
let next = upsert_inline_instruction_text(original.as_deref().unwrap_or_default(), format)?;
let mut mutations = vec![planned_write(path, &original, next)];
if let Some(removal) = plan_remove_aise_instruction_file(path)? {
mutations.push(removal);
}
Ok(mutations)
}
fn plan_remove_inline_instruction_file(path: &Path) -> Result<Vec<PlannedFileMutation>> {
let original = read_optional_utf8_regular_file(path)?;
let Some(original_text) = original.as_deref() else {
return Ok(Vec::new());
};
let removed_inline = remove_inline_instruction_block(original_text)?;
let base = removed_inline.as_deref().unwrap_or(original_text);
let removed_reference = remove_instruction_reference(base)?;
let mut mutations = Vec::new();
if let Some(next) = removed_reference.or(removed_inline) {
mutations.push(planned_write(path, &original, next));
}
if let Some(removal) = plan_remove_aise_instruction_file(path)? {
mutations.push(removal);
}
Ok(mutations)
}
fn status_inline_instruction_file(path: &Path, format: InstructionFormat) -> Result<&'static str> {
let text = read_optional_utf8_regular_file(path)?;
let Some(text) = text else {
return Ok("missing");
};
remove_inline_instruction_block(&text)?;
let starts = text.matches(INSTRUCTIONS_START).count();
let ends = text.matches(INSTRUCTIONS_END).count();
if starts == 0 && ends == 0 {
return Ok("not configured");
}
let start = text
.find(INSTRUCTIONS_START)
.expect("validated managed block has a start marker");
let end = start
+ text[start..]
.find(INSTRUCTIONS_END)
.expect("validated managed block has an end marker")
+ INSTRUCTIONS_END.len();
Ok(
if starts == 1
&& ends == 1
&& text[start..end].replace("\r\n", "\n").trim_end()
== instruction_block(format).trim_end()
{
"configured"
} else {
"outdated"
},
)
}
fn upsert_inline_instruction_text(text: &str, format: InstructionFormat) -> Result<String> {
let without_inline = remove_inline_instruction_block(text)?.unwrap_or_else(|| text.to_string());
let removed = remove_instruction_reference(&without_inline)?.unwrap_or(without_inline);
let mut next = removed.trim_end().to_string();
if !next.is_empty() {
next.push_str("\n\n");
}
next.push_str(&instruction_block(format));
Ok(next)
}
fn remove_instruction_reference(text: &str) -> Result<Option<String>> {
let mut removed = false;
let mut lines = Vec::new();
for line in text.lines() {
if is_instruction_reference_line(line) {
removed = true;
} else {
lines.push(line);
}
}
if !removed {
return Ok(None);
}
let mut next = lines.join("\n");
while next.contains("\n\n\n") {
next = next.replace("\n\n\n", "\n\n");
}
if !next.is_empty() {
next.push('\n');
}
Ok(Some(next))
}
fn remove_inline_instruction_block(text: &str) -> Result<Option<String>> {
let mut next = text.to_string();
let mut removed = false;
loop {
let start = next.find(INSTRUCTIONS_START);
let end = next.find(INSTRUCTIONS_END);
let Some(start) = start else {
if end.is_some() {
return Err(anyhow!(
"found an `{INSTRUCTIONS_END}` marker with no matching `{INSTRUCTIONS_START}\
...-->` before it; this file's aise-managed instruction block is corrupted, \
remove the unmatched end marker after reviewing the surrounding text, then \
rerun `aise integrations install`"
));
}
break;
};
if end.is_some_and(|end| end < start) {
return Err(anyhow!(
"found an `{INSTRUCTIONS_END}` marker before its matching \
`{INSTRUCTIONS_START}...-->`; this file's aise-managed instruction block is \
corrupted, remove the out-of-order end marker after reviewing the surrounding \
text, then rerun `aise integrations install`"
));
}
let end_relative = next[start..].find(INSTRUCTIONS_END).ok_or_else(|| {
anyhow!(
"found a `{INSTRUCTIONS_START}...-->` marker with no matching \
`{INSTRUCTIONS_END}`; this file's aise-managed instruction block is corrupted; \
restore the missing end marker, or remove the unmatched start marker and partial \
managed text after reviewing it, then rerun `aise integrations install`"
)
})?;
let mut end = start + end_relative + INSTRUCTIONS_END.len();
if next[end..].starts_with('\n') {
end += 1;
}
let mut start = start;
if end == next.len() && next[..start].ends_with("\n\n") {
start -= 1;
}
next.replace_range(start..end, "");
removed = true;
}
if !removed {
return Ok(None);
}
while next.contains("\n\n\n") {
next = next.replace("\n\n\n", "\n\n");
}
if !next.is_empty() && !next.ends_with('\n') {
next.push('\n');
}
Ok(Some(next))
}
fn is_instruction_reference_line(line: &str) -> bool {
line.trim() == INSTRUCTIONS_REFERENCE
}
fn plan_write_aise_instruction_file(instruction_ref_path: &Path) -> Result<PlannedFileMutation> {
let path = aise_instruction_path(instruction_ref_path);
let original = read_optional_utf8_regular_file(&path)?;
if original
.as_deref()
.is_some_and(|text| !is_managed_instruction_file(text))
{
return Err(anyhow!(
"refusing to replace unmanaged instruction file {}; move it aside manually and \
rerun `aise integrations install`, or pass --no-instructions to skip managing it",
path.display()
));
}
Ok(planned_write(&path, &original, instruction_file_content()))
}
fn plan_remove_aise_instruction_file(
instruction_ref_path: &Path,
) -> Result<Option<PlannedFileMutation>> {
let path = aise_instruction_path(instruction_ref_path);
let Some(text) = read_optional_utf8_regular_file(&path)? else {
return Ok(None);
};
Ok(
is_managed_instruction_file(&text).then_some(PlannedFileMutation::Remove {
path,
original: text,
}),
)
}
fn aise_instruction_path(instruction_ref_path: &Path) -> PathBuf {
instruction_ref_path
.parent()
.unwrap_or_else(|| Path::new("."))
.join(INSTRUCTIONS_FILE)
}
fn instruction_file_content() -> String {
format!(
"# AI Session Search (`aise`)\n\n{INSTRUCTIONS_FILE_START}\n{INSTRUCTIONS_LINE}\n{INSTRUCTIONS_FILE_END}\n"
)
}
pub(crate) fn agent_instructions() -> &'static str {
INSTRUCTIONS_LINE
}
fn legacy_instruction_file_content() -> String {
format!("# aise\n\n{LEGACY_INSTRUCTIONS_LINE}\n")
}
/// Line endings do not count: an editor or Git on Windows may have turned them into CRLF.
fn is_current_instruction_file(text: &str) -> bool {
text.replace("\r\n", "\n").trim_end() == instruction_file_content().trim_end()
}
fn is_managed_instruction_file(text: &str) -> bool {
let text = &text.replace("\r\n", "\n");
if text.trim_end() == legacy_instruction_file_content().trim_end() {
return true;
}
let trimmed = text.trim_end();
let Some(managed) = trimmed.strip_prefix("# AI Session Search (`aise`)\n\n") else {
return false;
};
managed.starts_with(INSTRUCTIONS_FILE_START) && managed.ends_with(INSTRUCTIONS_FILE_END)
}
fn instruction_block(format: InstructionFormat) -> String {
format!(
"<!-- aise-instructions v1 -->\n{}\n{INSTRUCTIONS_END}\n",
format.line()
)
}
fn resolve_mcp_binary(explicit: Option<&Path>) -> Result<PathBuf> {
if let Some(path) = explicit {
return validate_mcp_binary(absolutize(&expand_tilde(path)?)?);
}
resolve_detected_mcp_binary(which("aise"))
}
fn resolve_detected_mcp_binary(detected: Option<PathBuf>) -> Result<PathBuf> {
let path =
detected.ok_or_else(|| anyhow!("aise is not on PATH; pass --binary /path/to/aise"))?;
validate_mcp_binary(absolutize(&path)?)
}
fn validate_mcp_binary(path: PathBuf) -> Result<PathBuf> {
let metadata = fs::metadata(&path).with_context(|| {
format!(
"MCP binary {} does not exist or is not accessible",
path.display()
)
})?;
if !metadata.is_file() {
return Err(anyhow!(
"MCP binary {} is not a regular file; pass the path to the `aise` executable \
with --binary",
path.display()
));
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if metadata.permissions().mode() & 0o111 == 0 {
let chmod_command = render_posix_shell_command(&[
"chmod".to_string(),
"+x".to_string(),
path.to_string_lossy().into_owned(),
])?;
return Err(anyhow!(
"MCP binary {} is not executable (mode {:o}); run `{chmod_command}` or pass a \
different path with --binary",
path.display(),
metadata.permissions().mode() & 0o777
));
}
}
Ok(path)
}
fn absolutize(path: &Path) -> Result<PathBuf> {
if path.is_absolute() {
Ok(path.to_path_buf())
} else {
Ok(env::current_dir()?.join(path))
}
}
/// Expand `~` for an install destination, refusing when no home directory exists.
///
/// One definition, shared with skill discovery and scaffolding, so the three places that decide
/// where a file lands cannot disagree about what `~/skills` means.
fn expand_tilde(path: &Path) -> Result<PathBuf> {
crate::util::expand_tilde_required(path)
}
fn missing_home_error() -> anyhow::Error {
anyhow!(
"cannot determine the home directory for MCP client configuration; set HOME or USERPROFILE"
)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn json_array_is_strings_requires_exact_length_and_values() {
assert!(json_array_is_strings(
Some(&json!(["mcp", "serve"])),
&["mcp", "serve"]
));
// Wrong length, wrong value, non-string element, non-array, and absent all fail.
assert!(!json_array_is_strings(
Some(&json!(["mcp"])),
&["mcp", "serve"]
));
assert!(!json_array_is_strings(
Some(&json!(["mcp", "other"])),
&["mcp", "serve"]
));
assert!(!json_array_is_strings(
Some(&json!(["mcp", 7])),
&["mcp", "serve"]
));
assert!(!json_array_is_strings(Some(&json!("mcp serve")), &["mcp"]));
assert!(!json_array_is_strings(None, &["mcp", "serve"]));
}
#[test]
fn json_entry_is_current_matches_each_config_format_shape() {
// JSON mcpServers: string command plus args ["mcp","serve"].
let ok = json!({"command": "aise", "args": ["mcp", "serve"]});
assert!(json_entry_is_current(&ok, ConfigFormat::JsonMcpServers));
// Missing args or empty command are not current.
assert!(!json_entry_is_current(
&json!({"command": "aise"}),
ConfigFormat::JsonMcpServers
));
assert!(!json_entry_is_current(
&json!({"command": "", "args": ["mcp", "serve"]}),
ConfigFormat::JsonMcpServers
));
// VS Code additionally requires type="stdio".
assert!(!json_entry_is_current(&ok, ConfigFormat::VscodeServers));
assert!(json_entry_is_current(
&json!({"command": "aise", "type": "stdio", "args": ["mcp", "serve"]}),
ConfigFormat::VscodeServers
));
// OpenCode: enabled=true and command=[bin,"mcp","serve"].
assert!(json_entry_is_current(
&json!({"enabled": true, "command": ["aise", "mcp", "serve"]}),
ConfigFormat::OpenCode
));
assert!(!json_entry_is_current(
&json!({"enabled": false, "command": ["aise", "mcp", "serve"]}),
ConfigFormat::OpenCode
));
// CodexToml is never "current" via this JSON predicate, and a non-object is false.
assert!(!json_entry_is_current(&ok, ConfigFormat::CodexToml));
assert!(!json_entry_is_current(
&json!("not an object"),
ConfigFormat::JsonMcpServers
));
}
#[test]
fn injected_platform_layout_resolves_opencode_and_kilocode_paths() {
for (platform, home, config, expected_opencode, expected_kilocode) in [
(
ClientPlatform::Macos,
"/Users/alice",
"/Users/alice/Library/Application Support",
"/Users/alice/.config/opencode/opencode.json",
"/Users/alice/Library/Application Support/Code/User/globalStorage/kilocode.kilo-code/settings/mcp_settings.json",
),
(
ClientPlatform::Linux,
"/home/alice",
"/home/alice/.config",
"/home/alice/.config/opencode/opencode.json",
"/home/alice/.config/Code/User/globalStorage/kilocode.kilo-code/settings/mcp_settings.json",
),
(
ClientPlatform::Windows,
"C:/Users/Alice",
"C:/Users/Alice/AppData/Roaming",
"C:/Users/Alice/.config/opencode/opencode.json",
"C:/Users/Alice/AppData/Roaming/Code/User/globalStorage/kilocode.kilo-code/settings/mcp_settings.json",
),
] {
let layout = ClientLayout::new(PathBuf::from(home), PathBuf::from(config), platform);
let opencode = targets_for_layout(McpClient::Opencode, &layout);
let kilocode = targets_for_layout(McpClient::Kilocode, &layout);
assert_eq!(opencode.len(), 1);
assert_eq!(opencode[0].path, PathBuf::from(expected_opencode));
assert_eq!(kilocode.len(), 1);
assert_eq!(kilocode[0].path, PathBuf::from(expected_kilocode));
}
}
#[test]
fn pi_family_paths_are_platform_independent_home_relative_contracts() {
for (platform, home, config) in [
(
ClientPlatform::Macos,
"/Users/alice",
"/Users/alice/Library/Application Support",
),
(ClientPlatform::Linux, "/home/alice", "/home/alice/.config"),
(
ClientPlatform::Windows,
"C:/Users/Alice",
"C:/Users/Alice/AppData/Roaming",
),
] {
let layout = ClientLayout::new(PathBuf::from(home), PathBuf::from(config), platform);
for (client, agent_dir) in [
(McpClient::Pi, ".pi/agent"),
(McpClient::PrimeAgent, ".prime/agent"),
] {
let expected_root = PathBuf::from(home).join(agent_dir);
assert_eq!(
instruction_targets_for_layout(client, &layout)[0].path,
expected_root.join("AGENTS.md")
);
assert_eq!(
skill_targets_for_layout(client, &layout)[0].discovery_links,
vec![expected_root.join("skills/ai-session-search")]
);
}
}
}
#[test]
fn injected_layout_rejects_missing_home_instead_of_using_cwd() {
let error = ClientLayout::from_discovered_dirs(
None,
Some(PathBuf::from("/tmp/config")),
ClientPlatform::Linux,
)
.unwrap_err();
assert_eq!(
error.to_string(),
"cannot determine the home directory for MCP client configuration; set HOME or USERPROFILE"
);
}
#[test]
fn upsert_json_preserves_existing_servers() {
let dir = tempdir().unwrap();
let path = dir.path().join(".claude.json");
fs::write(
&path,
r#"{"mcpServers":{"aise":{"command":"oldest"},"ai_session_search":{"command":"legacy"},"other":{"command":"other"}},"keep":true}"#,
)
.unwrap();
upsert_json_mcp_server(&path, json!({"command": "/bin/aise"})).unwrap();
let data: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
let servers = data["mcpServers"].as_object().unwrap();
assert_eq!(SERVER_NAME, "ai-session-search");
assert!(servers.contains_key("other"));
assert!(!servers.contains_key("aise"));
assert!(!servers.contains_key("ai_session_search"));
assert_eq!(servers[SERVER_NAME]["command"], "/bin/aise");
assert_eq!(data["keep"], true);
}
#[test]
fn uninstall_json_preserves_other_servers_and_root_keys() {
let dir = tempdir().unwrap();
let path = dir.path().join("mcp.json");
fs::write(
&path,
r#"{"mcpServers":{"ai-session-search":{"command":"/current"},"aise":{"command":"/oldest"},"ai_session_search":{"command":"/legacy"},"other":{"command":"other"}},"keep":true}"#,
)
.unwrap();
assert!(remove_json_mcp_server(&path).unwrap());
let data: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
let servers = data["mcpServers"].as_object().unwrap();
assert!(!servers.contains_key("aise"));
assert!(!servers.contains_key("ai_session_search"));
assert!(!servers.contains_key("ai-session-search"));
assert!(servers.contains_key("other"));
assert_eq!(data["keep"], true);
}
#[test]
fn vscode_and_zed_use_their_native_container_keys() {
let dir = tempdir().unwrap();
let vscode = dir.path().join("vscode.json");
let zed = dir.path().join("zed.json");
upsert_target(
&Target {
label: "vscode",
path: vscode.clone(),
format: ConfigFormat::VscodeServers,
detect_paths: Vec::new(),
detect_binaries: Vec::new(),
},
Path::new("/bin/aise"),
)
.unwrap();
upsert_target(
&Target {
label: "zed",
path: zed.clone(),
format: ConfigFormat::ZedContextServers,
detect_paths: Vec::new(),
detect_binaries: Vec::new(),
},
Path::new("/bin/aise"),
)
.unwrap();
let vscode_data: Value =
serde_json::from_str(&fs::read_to_string(vscode).unwrap()).unwrap();
assert_eq!(vscode_data["servers"][SERVER_NAME]["type"], "stdio");
assert_eq!(vscode_data["servers"][SERVER_NAME]["command"], "/bin/aise");
assert_eq!(
vscode_data["servers"][SERVER_NAME]["args"],
json!(["mcp", "serve"])
);
let zed_data: Value = serde_json::from_str(&fs::read_to_string(zed).unwrap()).unwrap();
assert_eq!(
zed_data["context_servers"][SERVER_NAME]["command"],
"/bin/aise"
);
assert_eq!(
zed_data["context_servers"][SERVER_NAME]["args"],
json!(["mcp", "serve"])
);
}
#[test]
fn opencode_uses_command_array() {
let dir = tempdir().unwrap();
let path = dir.path().join("opencode.json");
upsert_target(
&Target {
label: "opencode",
path: path.clone(),
format: ConfigFormat::OpenCode,
detect_paths: Vec::new(),
detect_binaries: Vec::new(),
},
Path::new("/bin/aise"),
)
.unwrap();
let data: Value = serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap();
assert_eq!(data["mcp"][SERVER_NAME]["command"][0], "/bin/aise");
assert_eq!(data["mcp"][SERVER_NAME]["command"][1], "mcp");
assert_eq!(data["mcp"][SERVER_NAME]["command"][2], "serve");
assert_eq!(data["mcp"][SERVER_NAME]["enabled"], true);
}
#[test]
fn remove_target_matches_each_config_shape() {
let dir = tempdir().unwrap();
let binary = Path::new("/bin/aise");
let targets = [
Target {
label: "json",
path: dir.path().join("json.json"),
format: ConfigFormat::JsonMcpServers,
detect_paths: Vec::new(),
detect_binaries: Vec::new(),
},
Target {
label: "vscode",
path: dir.path().join("vscode.json"),
format: ConfigFormat::VscodeServers,
detect_paths: Vec::new(),
detect_binaries: Vec::new(),
},
Target {
label: "zed",
path: dir.path().join("zed.json"),
format: ConfigFormat::ZedContextServers,
detect_paths: Vec::new(),
detect_binaries: Vec::new(),
},
Target {
label: "opencode",
path: dir.path().join("opencode.json"),
format: ConfigFormat::OpenCode,
detect_paths: Vec::new(),
detect_binaries: Vec::new(),
},
Target {
label: "codex",
path: dir.path().join("config.toml"),
format: ConfigFormat::CodexToml,
detect_paths: Vec::new(),
detect_binaries: Vec::new(),
},
];
for target in &targets {
upsert_target(target, binary).unwrap();
if matches!(target.format, ConfigFormat::JsonMcpServers) {
let data: Value =
serde_json::from_str(&fs::read_to_string(&target.path).unwrap()).unwrap();
assert_eq!(
data["mcpServers"][SERVER_NAME]["args"],
json!(["mcp", "serve"])
);
}
assert_eq!(status_target(target).unwrap(), "configured");
assert!(remove_target(target).unwrap());
assert_eq!(status_target(target).unwrap(), "not configured");
}
}
#[test]
fn codex_upsert_is_idempotent_and_preserves_other_sections() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
fs::write(&path, "[existing]\nvalue = true\n").unwrap();
upsert_codex_mcp_server(&path, Path::new("/bin/aise")).unwrap();
upsert_codex_mcp_server(&path, Path::new("/bin/aise")).unwrap();
let text = fs::read_to_string(&path).unwrap();
assert!(text.contains("[existing]\nvalue = true"));
assert_eq!(text.matches("[mcp_servers.ai-session-search]").count(), 1);
assert!(text.contains("command = \"/bin/aise\""));
assert!(text.contains("args = [\"mcp\", \"serve\"]"));
assert!(!text.contains("startup_timeout_sec"));
}
#[test]
fn status_distinguishes_stale_execution_contracts() {
let dir = tempdir().unwrap();
let json_path = dir.path().join("client.json");
fs::write(
&json_path,
r#"{"mcpServers":{"aise":{"command":"aise-mcp","args":[]}}}"#,
)
.unwrap();
assert_eq!(
status_json_keyed_server(&json_path, "mcpServers", ConfigFormat::JsonMcpServers)
.unwrap(),
"stale"
);
let opencode_path = dir.path().join("opencode.json");
fs::write(
&opencode_path,
r#"{"mcp":{"aise":{"command":["aise-mcp"],"enabled":true}}}"#,
)
.unwrap();
assert_eq!(
status_json_keyed_server(&opencode_path, "mcp", ConfigFormat::OpenCode).unwrap(),
"stale"
);
let codex_path = dir.path().join("config.toml");
fs::write(
&codex_path,
"[mcp_servers.ai_session_search]\ncommand = \"aise-mcp\"\n",
)
.unwrap();
assert_eq!(status_codex_mcp_server(&codex_path).unwrap(), "stale");
upsert_codex_mcp_server(&codex_path, Path::new("aise")).unwrap();
assert_eq!(status_codex_mcp_server(&codex_path).unwrap(), "configured");
}
#[test]
fn explicit_mcp_binary_must_exist() {
let dir = tempdir().unwrap();
let missing = dir.path().join("missing-aise");
let error = resolve_mcp_binary(Some(&missing)).unwrap_err();
assert!(error.to_string().contains("does not exist"));
}
#[cfg(unix)]
#[test]
fn explicit_mcp_binary_must_be_executable() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir().unwrap();
let binary = dir.path().join("aise tool's");
fs::write(&binary, "not executable").unwrap();
fs::set_permissions(&binary, fs::Permissions::from_mode(0o644)).unwrap();
let error = resolve_mcp_binary(Some(&binary)).unwrap_err().to_string();
let expected_command = render_posix_shell_command(&[
"chmod".to_string(),
"+x".to_string(),
binary.to_string_lossy().into_owned(),
])
.unwrap();
assert!(error.contains("not executable"), "{error}");
assert!(error.contains(&expected_command), "{error}");
assert!(error.contains("--binary"), "{error}");
}
#[test]
fn explicit_mcp_binary_must_be_a_regular_file() {
let dir = tempdir().unwrap();
let not_a_file = dir.path().join("some-dir");
fs::create_dir(¬_a_file).unwrap();
let error = resolve_mcp_binary(Some(¬_a_file))
.unwrap_err()
.to_string();
assert!(error.contains("is not a regular file"), "{error}");
assert!(error.contains("--binary"), "{error}");
}
#[cfg(unix)]
#[test]
fn implicit_mcp_binary_persists_the_detected_absolute_executable() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir().unwrap();
let binary = dir.path().join("aise");
fs::write(&binary, "#!/bin/sh\n").unwrap();
fs::set_permissions(&binary, fs::Permissions::from_mode(0o755)).unwrap();
let resolved = resolve_detected_mcp_binary(Some(binary.clone())).unwrap();
assert_eq!(resolved, binary);
assert!(resolved.is_absolute());
}
#[test]
fn codex_remove_handles_quoted_and_nested_tables_without_touching_other_content() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
fs::write(
&path,
"# keep this comment\n[a]\nx = 1\n\n[mcp_servers.\"aise\"]\ncommand = \"/old\"\n\n[mcp_servers.\"aise\".env]\nTOKEN = \"managed\"\n\n[b]\ny = 2\n",
)
.unwrap();
assert!(remove_codex_mcp_server(&path).unwrap());
let output = fs::read_to_string(&path).unwrap();
output.parse::<toml_edit::DocumentMut>().unwrap();
assert!(output.contains("# keep this comment"));
assert!(output.contains("[a]\nx = 1"));
assert!(output.contains("[b]\ny = 2"));
assert!(!output.contains("managed"));
assert!(!output.contains("mcp_servers"));
}
#[test]
fn codex_upsert_uses_toml_string_encoding_and_preserves_comments() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
fs::write(&path, "# keep this comment\n[existing]\nvalue = true\n").unwrap();
let binary = Path::new("C:\\Program Files\\aise\"quoted\nname.exe");
upsert_codex_mcp_server(&path, binary).unwrap();
let output = fs::read_to_string(&path).unwrap();
let parsed: toml::Value = toml::from_str(&output).unwrap();
assert_eq!(
parsed["mcp_servers"][SERVER_NAME]["command"].as_str(),
binary.to_str()
);
assert!(output.contains("# keep this comment"));
}
#[cfg(unix)]
#[test]
fn config_generation_rejects_non_utf8_binary_paths_instead_of_replacing_bytes() {
use std::os::unix::ffi::OsStringExt;
let dir = tempdir().unwrap();
let target = Target {
label: "custom",
path: dir.path().join("mcp.json"),
format: ConfigFormat::JsonMcpServers,
detect_paths: Vec::new(),
detect_binaries: Vec::new(),
};
let binary = PathBuf::from(std::ffi::OsString::from_vec(b"/tmp/aise-\xff".to_vec()));
let error = plan_upsert_target(&target, &binary)
.unwrap_err()
.to_string();
assert!(error.contains("not valid UTF-8"), "{error}");
assert!(!target.path.exists());
}
#[test]
fn inline_instruction_upsert_adds_replaces_and_stays_single() {
let original = "# Team rules\n";
let first =
upsert_inline_instruction_text(original, InstructionFormat::InlineBlock).unwrap();
assert!(first.contains(instruction_block(InstructionFormat::InlineBlock).trim_end()));
assert!(first.contains("# Team rules"));
let stale = first.replace(INSTRUCTIONS_LINE, "old wording");
let updated =
upsert_inline_instruction_text(&stale, InstructionFormat::InlineBlock).unwrap();
assert!(updated.contains(INSTRUCTIONS_LINE));
assert!(!updated.contains("old wording"));
assert_eq!(updated.matches(INSTRUCTIONS_START).count(), 1);
}
#[test]
fn inline_instruction_upsert_collapses_duplicates_and_uninstall_removes_every_block() {
let duplicated = format!(
"# Team rules\n\n{}Keep this.\n\n{}",
instruction_block(InstructionFormat::InlineBlock),
instruction_block(InstructionFormat::InlineBlock)
);
let updated =
upsert_inline_instruction_text(&duplicated, InstructionFormat::InlineBlock).unwrap();
assert_eq!(updated.matches(INSTRUCTIONS_START).count(), 1);
assert_eq!(updated.matches(INSTRUCTIONS_END).count(), 1);
assert!(updated.contains("# Team rules"));
assert!(updated.contains("Keep this."));
let removed = remove_inline_instruction_block(&duplicated)
.unwrap()
.unwrap();
assert!(!removed.contains(INSTRUCTIONS_START));
assert!(!removed.contains(INSTRUCTIONS_END));
assert!(removed.contains("# Team rules"));
assert!(removed.contains("Keep this."));
}
#[test]
fn inline_instruction_remove_only_deletes_managed_block() {
let input = format!(
"# Team rules\n\n{}Keep this.\n",
instruction_block(InstructionFormat::InlineBlock)
);
let output = remove_inline_instruction_block(&input).unwrap().unwrap();
assert!(output.contains("# Team rules"));
assert!(output.contains("Keep this."));
assert!(output.contains("# Team rules\n\nKeep this."));
assert!(!output.contains(INSTRUCTIONS_START));
}
#[test]
fn inline_instruction_remove_rejects_malformed_block_with_a_concrete_fix() {
// Start marker present, no matching end marker: must name what's missing and give a
// copy-pastable recovery step, not just restate "malformed".
let err = remove_inline_instruction_block("before\n<!-- aise-instructions v1 -->\npartial")
.unwrap_err()
.to_string();
assert!(
err.contains("no matching `<!-- /aise-instructions -->`"),
"{err}"
);
assert!(err.contains("rerun `aise integrations install`"), "{err}");
assert!(!err.contains("without end marker"), "{err}");
// End marker present, no start marker at all.
let err = remove_inline_instruction_block("before\n<!-- /aise-instructions -->\nafter\n")
.unwrap_err()
.to_string();
assert!(err.contains("no matching `<!-- aise-instructions"), "{err}");
assert!(err.contains("rerun `aise integrations install`"), "{err}");
assert!(!err.contains("without start marker"), "{err}");
// End marker appears before its start marker.
let err = remove_inline_instruction_block(
"before\n<!-- /aise-instructions -->\nmiddle\n<!-- aise-instructions v1 -->\nafter\n",
)
.unwrap_err()
.to_string();
assert!(
err.contains("before its matching `<!-- aise-instructions"),
"{err}"
);
assert!(err.contains("rerun `aise integrations install`"), "{err}");
}
#[test]
fn instruction_content_names_product_and_gives_an_exact_mcp_workflow() {
let content = instruction_file_content();
assert!(content.contains("AI Session Search (`aise`)"));
for tool in [
"`list_sessions(",
"`search_sessions(",
"`search_messages(",
"`get_session(",
] {
assert!(content.contains(tool), "missing {tool}: {content}");
}
assert!(
content.contains("installed `ai-session-search` skill"),
"the block names the skill that holds the full workflow: {content}"
);
assert!(
content.contains("`aise <command> --help`"),
"the block names the CLI fallback: {content}"
);
// Both blocks walk the same four steps in the same order, so an agent that reads the
// MCP block on one harness and the CLI block on another meets one workflow.
for line in [INSTRUCTIONS_LINE, CLI_INSTRUCTIONS_LINE] {
let lower = line.to_ascii_lowercase();
let positions: Vec<usize> = [
"recent sessions",
"by topic",
"the exact turn",
"then read it",
]
.iter()
.map(|phrase| {
lower
.find(phrase)
.unwrap_or_else(|| panic!("missing {phrase:?}: {line}"))
})
.collect();
assert!(
positions.windows(2).all(|pair| pair[0] < pair[1]),
"steps out of order: {line}"
);
}
for provider in [
"Claude Code",
"Claude Desktop local agent",
"Codex",
"Cursor",
"Antigravity",
"Pi coding agent",
"Prime Agent",
"Google AI Studio",
"Gemini CLI",
] {
assert!(content.contains(provider), "missing {provider}: {content}");
}
}
#[test]
fn claude_instruction_uses_import_file_and_migrates_inline_block() {
let dir = tempdir().unwrap();
let claude_md = dir.path().join("CLAUDE.md");
fs::write(
&claude_md,
format!(
"# Team rules\n\n{}Keep this.\n",
instruction_block(InstructionFormat::InlineBlock)
),
)
.unwrap();
let target = InstructionTarget {
label: "claude",
path: claude_md.clone(),
format: InstructionFormat::ClaudeImport,
detect_paths: Vec::new(),
detect_binaries: Vec::new(),
};
upsert_instruction_file(&target).unwrap();
let claude_text = fs::read_to_string(&claude_md).unwrap();
assert!(claude_text.contains(INSTRUCTIONS_REFERENCE));
assert!(!claude_text.contains(INSTRUCTIONS_START));
assert_eq!(
fs::read_to_string(dir.path().join(INSTRUCTIONS_FILE)).unwrap(),
instruction_file_content()
);
assert_eq!(status_instruction_file(&target).unwrap(), "configured");
assert!(remove_instruction_file(&target).unwrap());
let claude_text = fs::read_to_string(&claude_md).unwrap();
assert!(claude_text.contains("# Team rules"));
assert!(claude_text.contains("Keep this."));
assert!(!claude_text.contains(INSTRUCTIONS_REFERENCE));
assert!(!dir.path().join(INSTRUCTIONS_FILE).exists());
}
#[test]
fn claude_instruction_upgrades_owned_legacy_file_but_refuses_unmanaged_content() {
let dir = tempdir().unwrap();
let claude_md = dir.path().join("CLAUDE.md");
let instruction_path = dir.path().join(INSTRUCTIONS_FILE);
fs::write(&claude_md, "# Team rules\n\n@AI_SESSION_SEARCH.md\n").unwrap();
fs::write(&instruction_path, legacy_instruction_file_content()).unwrap();
let target = InstructionTarget {
label: "claude",
path: claude_md,
format: InstructionFormat::ClaudeImport,
detect_paths: Vec::new(),
detect_binaries: Vec::new(),
};
assert_eq!(status_instruction_file(&target).unwrap(), "outdated");
upsert_instruction_file(&target).unwrap();
assert_eq!(status_instruction_file(&target).unwrap(), "configured");
assert_eq!(
fs::read_to_string(&instruction_path).unwrap(),
instruction_file_content()
);
fs::write(&instruction_path, "# User-owned notes\nDo not replace.\n").unwrap();
assert_eq!(
status_instruction_file(&target).unwrap(),
"instruction file modified"
);
let error = upsert_instruction_file(&target).unwrap_err();
assert!(error.to_string().contains("refusing to replace unmanaged"));
assert_eq!(
fs::read_to_string(instruction_path).unwrap(),
"# User-owned notes\nDo not replace.\n"
);
}
#[test]
fn inline_instruction_status_distinguishes_current_and_outdated_content() {
let dir = tempdir().unwrap();
let agents_md = dir.path().join("AGENTS.md");
let target = InstructionTarget {
label: "codex",
path: agents_md.clone(),
format: InstructionFormat::InlineBlock,
detect_paths: Vec::new(),
detect_binaries: Vec::new(),
};
fs::write(
&agents_md,
instruction_block(InstructionFormat::InlineBlock),
)
.unwrap();
assert_eq!(status_instruction_file(&target).unwrap(), "configured");
fs::write(
&agents_md,
instruction_block(InstructionFormat::InlineBlock)
.replace(INSTRUCTIONS_LINE, LEGACY_INSTRUCTIONS_LINE),
)
.unwrap();
assert_eq!(status_instruction_file(&target).unwrap(), "outdated");
}
#[test]
fn agents_instruction_uses_inline_block_not_import_reference() {
let dir = tempdir().unwrap();
let agents_md = dir.path().join("AGENTS.md");
let target = InstructionTarget {
label: "codex",
path: agents_md.clone(),
format: InstructionFormat::InlineBlock,
detect_paths: Vec::new(),
detect_binaries: Vec::new(),
};
upsert_instruction_file(&target).unwrap();
let agents_text = fs::read_to_string(&agents_md).unwrap();
assert!(agents_text.contains(INSTRUCTIONS_START));
assert!(agents_text.contains(INSTRUCTIONS_LINE));
assert!(!agents_text.contains(INSTRUCTIONS_REFERENCE));
assert!(!dir.path().join(INSTRUCTIONS_FILE).exists());
assert_eq!(status_instruction_file(&target).unwrap(), "configured");
assert!(remove_instruction_file(&target).unwrap());
let agents_text = fs::read_to_string(&agents_md).unwrap();
assert!(!agents_text.contains(INSTRUCTIONS_START));
}
#[test]
fn agents_instruction_migrates_old_import_reference() {
let dir = tempdir().unwrap();
let agents_md = dir.path().join("AGENTS.md");
fs::write(&agents_md, "# Team rules\n\n@AI_SESSION_SEARCH.md\n").unwrap();
fs::write(
dir.path().join(INSTRUCTIONS_FILE),
instruction_file_content(),
)
.unwrap();
let target = InstructionTarget {
label: "codex",
path: agents_md.clone(),
format: InstructionFormat::InlineBlock,
detect_paths: Vec::new(),
detect_binaries: Vec::new(),
};
upsert_instruction_file(&target).unwrap();
let agents_text = fs::read_to_string(&agents_md).unwrap();
assert!(agents_text.contains(INSTRUCTIONS_START));
assert!(!agents_text.contains(INSTRUCTIONS_REFERENCE));
assert!(!dir.path().join(INSTRUCTIONS_FILE).exists());
}
// PATTERN: the three dedupers share one contract. An identical duplicate is dropped
// SILENTLY (that is intended, not a defect); an *incompatible* duplicate — the same
// destination claimed two different ways — is an error naming both claims.
// `dedupe_config_targets:552-575` keys `(path, format)`, `dedupe_instruction_targets:577-598`
// keys `(path, format)`, and skills key `(path, label)` because `SkillTarget` carries a
// label rather than a format. Only skills diverged, returning `()` and dropping both cases.
#[test]
fn dedupe_skill_targets_drops_true_duplicates_and_names_label_conflicts() {
let root = PathBuf::from("/tmp/aise-dedupe-fixture/skills/ai-session-search");
let mut duplicates = vec![
skill_target("claude", root.clone(), Vec::new(), Vec::new()),
skill_target("claude", root.clone(), Vec::new(), Vec::new()),
];
dedupe_skill_targets(&mut duplicates).unwrap();
assert_eq!(
duplicates.len(),
1,
"the same root under the same label is a true duplicate and is dropped silently"
);
let mut conflicting = vec![
skill_target("claude", root.clone(), Vec::new(), Vec::new()),
skill_target("custom", root.clone(), Vec::new(), Vec::new()),
];
let err = dedupe_skill_targets(&mut conflicting)
.expect_err("one root claimed by two labels must not be silently dropped")
.to_string();
for expected in ["claude", "custom", "ai-session-search"] {
assert!(
err.contains(expected),
"error must name both claims and the path; missing {expected:?} in {err:?}"
);
}
}
// The skill lifecycle test below uses `publish_planned_mutations`, a `#[cfg(test)]` helper
// that writes each change directly. That bypasses the receipt, the lock, preimage
// verification, and reverse-order rollback — i.e. everything that makes install recoverable,
// and everything that must hold once install publishes a whole directory rather than one
// file. This test drives the same plan through `execute_planned_transaction`, the path
// production actually uses.
#[test]
fn skill_install_survives_the_real_transaction_path() {
let dir = tempdir().unwrap();
// No install record: these tests predate the manifest and exercise the
// byte-comparison path a legacy install still takes.
let absent = crate::skill_manifest::ManifestState::Absent;
let receipt = dir.path().join("state/install-receipt.json");
let root = dir.path().join("skills/corrections");
let target = skill_target_for_package(
"test",
root.clone(),
&AI_SESSION_SEARCH_SKILL_PACKAGE,
Vec::new(),
Vec::new(),
);
let mutations =
normalize_planned_mutations(plan_upsert_skill_file(&target).unwrap()).unwrap();
assert_eq!(
mutations.len(),
target.package.files.len(),
"every managed file is published, not just the anchor"
);
execute_planned_transaction(&receipt, &mutations).unwrap();
for file in target.package.files {
assert_eq!(
fs::read_to_string(root.join(file.relative_path)).unwrap(),
file.content,
"{} did not land",
file.relative_path
);
}
assert_eq!(status_skill_file(&target, &absent).unwrap(), "configured");
// A skill missing one reference file is NOT `configured`: reporting the anchor alone
// would call a half-installed directory healthy, which is what an interrupted upgrade
// leaves behind. It is not `missing` either, which would say the package is absent when
// every other file is current.
fs::remove_file(root.join("references/message-classification.md")).unwrap();
let partial = status_skill_file(&target, &absent).unwrap();
assert!(
partial.starts_with("incomplete: 1 of 5 managed files missing"),
"a deleted managed file is reported with its count, the actionable fact, rather than \
summarized away by the files that are still current or overstated as an absent \
package: {partial}"
);
assert!(partial.contains("aise integrations install"), "{partial}");
let repair = normalize_planned_mutations(plan_upsert_skill_file(&target).unwrap()).unwrap();
assert_eq!(repair.len(), 1, "repair rewrites only the missing file");
execute_planned_transaction(&receipt, &repair).unwrap();
assert_eq!(status_skill_file(&target, &absent).unwrap(), "configured");
assert!(
!receipt.exists(),
"a committed transaction must not leave its receipt behind"
);
// Re-planning against the now-current file yields nothing to do, so install is its own
// idempotent repair (`is_noop`) even through the real path.
let repeat = normalize_planned_mutations(plan_upsert_skill_file(&target).unwrap()).unwrap();
assert!(
repeat.is_empty(),
"an already-current managed file must plan no mutation"
);
// ALL-OR-NOTHING across files. This is the assertion the `#[cfg(test)]` bypass cannot
// satisfy and the reason this test exists: `publish_planned_mutations` applies changes one
// at a time, so a later failure leaves earlier files already written. The real transaction
// verifies every preimage before applying any change, so a doomed set writes nothing.
//
// Probed by swapping in `publish_planned_mutations`: the receipt and stale-preimage
// assertions still passed (the bypass verifies preimages too, and never creates a
// receipt to leave behind), so only this one actually distinguishes the two paths.
let first = dir
.path()
.join("skills/ai-session-search/references/first.md");
let second = dir
.path()
.join("skills/ai-session-search/references/second.md");
fs::create_dir_all(second.parent().unwrap()).unwrap();
fs::write(&second, "on disk\n").unwrap();
let doomed = vec![
planned_write(
&first,
&None,
"written only if the set commits\n".to_string(),
),
// Expects bytes that are not there, so the set as a whole must be refused.
planned_write(
&second,
&Some("a preimage that no longer matches\n".to_string()),
"never applied\n".to_string(),
),
];
assert!(
execute_planned_transaction(&receipt, &doomed).is_err(),
"a set containing a stale preimage must be refused"
);
assert!(
!first.exists(),
"no file may be written when a later change in the same set is refused"
);
assert_eq!(
fs::read_to_string(&second).unwrap(),
"on disk\n",
"the refused transaction must leave existing bytes untouched"
);
assert!(
!receipt.exists(),
"a refused transaction must not strand a receipt"
);
}
#[test]
fn managed_skill_content_uses_current_product_surface() {
let skill_content = AI_SESSION_SEARCH_SKILL_FILES
.iter()
.find(|file| file.relative_path == SKILL_ANCHOR_FILE)
.expect("the managed file list includes its own ownership anchor")
.content;
assert!(skill_content.starts_with("---\nname: ai-session-search\n"));
assert!(skill_content.contains(SKILL_MANAGED_MARKER));
assert!(skill_content.contains("aise messages search"));
assert!(skill_content.contains("query_session_index"));
assert!(skill_content.contains("short distinctive fragment"));
assert!(skill_content.contains("Literal is the default: the query text as typed"));
assert!(skill_content.contains("List recent sessions by metadata, then read them"));
assert!(skill_content.contains("aise list --path ~/source/project --when 7d --limit 10"));
assert!(skill_content.contains("one-significant-figure measurements"));
assert!(skill_content.contains("Recover an edited file"));
assert!(skill_content
.contains("aise messages search \"foreign key\" --workspace-path ~/source/project"));
assert!(skill_content.contains(
"`--workspace-path` on\n message search, `--path` on session commands and analytics"
));
let costs = AI_SESSION_SEARCH_SKILL_FILES
.iter()
.find(|file| file.relative_path == "references/search-costs-and-receipts.md")
.expect("the managed skill includes its cost and receipt reference")
.content;
assert!(costs.contains("O(T + N + W log W)"));
assert!(costs.contains("`corpus`"));
assert!(
!skill_content.contains("aise messages search \"foreign key\" --path ~/source/project"),
"the installed skill must use the command-specific message-search scope flag"
);
assert!(!skill_content.contains("aise messages inspect"));
assert!(!skill_content.contains("aise tools search"));
assert!(is_managed_skill_anchor(skill_content));
let prior_work_recovery = AI_SESSION_SEARCH_SKILL_FILES
.iter()
.find(|file| file.relative_path == "references/recover-prior-work-with-evidence.md")
.expect("the managed skill includes its prior-work evidence recovery workflow")
.content;
assert!(prior_work_recovery.contains("bounded evidence packet"));
assert!(prior_work_recovery.contains("The harness starts and supervises the subagent"));
}
#[test]
fn marker_quoted_in_a_skill_body_does_not_authorize_overwrite() {
let dir = tempdir().unwrap();
let parent = dir.path().join("skills");
let general_root = parent.join("ai-session-search");
let corrections_root = parent.join("corrections");
fs::create_dir_all(&corrections_root).unwrap();
let unmanaged = format!(
"---\nname: corrections\ndescription: user owned\n---\n\n\
This document mentions the marker without granting ownership:\n\
```html\n{SKILL_MANAGED_MARKER}\n```\n"
);
fs::write(corrections_root.join("SKILL.md"), &unmanaged).unwrap();
let targets = vec![
skill_target_for_package(
"test",
general_root.clone(),
&AI_SESSION_SEARCH_SKILL_PACKAGE,
vec![],
vec![],
),
skill_target_for_package(
"test",
corrections_root.clone(),
&AI_SESSION_SEARCH_SKILL_PACKAGE,
vec![],
vec![],
),
];
let manifest_path =
crate::skill_manifest::manifest_path(&dir.path().join("state/config.toml"));
let error = preflight_install(&[], &[], &targets, Path::new("aise"), Some(&manifest_path))
.expect_err("body text must not spoof the managed ownership anchor");
let message = format!("{error:#}");
assert!(
message.contains("refusing to write unmanaged AI Session Search skill")
&& message.contains("no managed marker and no install record"),
"the refusal must name both missing ownership proofs: {message}"
);
assert!(!general_root.exists(), "preflight must publish nothing");
assert_eq!(
fs::read_to_string(corrections_root.join("SKILL.md")).unwrap(),
unmanaged
);
assert!(!manifest_path.exists());
}
#[test]
fn one_client_install_publishes_both_managed_skill_packages_in_one_transaction() {
let dir = tempdir().unwrap();
let layout = ClientLayout::new(
dir.path().join("home"),
dir.path().join("config"),
ClientPlatform::Macos,
);
let targets = skill_targets_for_layout(McpClient::Claude, &layout);
assert_eq!(
targets
.iter()
.map(|target| target.package.name)
.collect::<Vec<_>>(),
vec!["ai-session-search"]
);
let receipt = dir.path().join("state/transaction.json");
let manifest_path = crate::skill_manifest::manifest_path(&dir.path().join("config.toml"));
let mutations =
preflight_install(&[], &[], &targets, Path::new("aise"), Some(&manifest_path)).unwrap();
assert_eq!(
mutations.len(),
MANAGED_SKILL_PACKAGES
.iter()
.map(|package| package.files.len())
.sum::<usize>()
+ 1
);
execute_planned_transaction(&receipt, &mutations).unwrap();
let manifest = crate::skill_manifest::load_manifest(&manifest_path).unwrap();
for target in &targets {
assert!(manifest.installation(&target.root).is_some());
for file in target.package.files {
assert_eq!(
fs::read_to_string(target.root.join(file.relative_path)).unwrap(),
file.content
);
}
}
}
/// A routine integration reinstall must not act like the explicitly destructive
/// `skills restore` path when one managed side file changed after installation.
#[test]
fn integration_reinstall_refuses_a_changed_managed_side_file() {
let dir = tempdir().unwrap();
let root = dir.path().join("skills/ai-session-search");
let target = skill_target_for_package(
CUSTOM_SKILL_TARGET_LABEL,
root.clone(),
&AI_SESSION_SEARCH_SKILL_PACKAGE,
vec![],
vec![],
);
let config_path = dir.path().join("state/config.toml");
let manifest_path = crate::skill_manifest::manifest_path(&config_path);
let receipt = dir.path().join("state/install-transaction.json");
let first = preflight_install(
&[],
&[],
std::slice::from_ref(&target),
Path::new("aise"),
Some(&manifest_path),
)
.unwrap();
execute_planned_transaction(&receipt, &first).unwrap();
let policy = root.join("aise-capability.toml");
fs::write(&policy, "my retained integration policy\n").unwrap();
let manifest_before = fs::read(&manifest_path).unwrap();
let error = preflight_install(
&[],
&[],
std::slice::from_ref(&target),
Path::new("aise"),
Some(&manifest_path),
)
.expect_err("integration reinstall must refuse a changed managed file");
let message = format!("{error:#}");
assert!(
message.contains("could destroy an edit you meant to keep")
&& message.contains("skills restore"),
"the refusal must explain the retained edit and explicit repair path: {message}"
);
assert_eq!(
fs::read_to_string(&policy).unwrap(),
"my retained integration policy\n",
"planning a reinstall must leave the edit untouched"
);
assert_eq!(
fs::read(&manifest_path).unwrap(),
manifest_before,
"a refused reinstall must not rewrite its ownership evidence"
);
assert!(
!receipt.exists(),
"a refused reinstall must not publish a transaction receipt"
);
}
#[test]
fn retiring_the_sibling_package_refuses_when_one_of_its_files_was_edited() {
let dir = tempdir().unwrap();
let parent = dir.path().join("skills");
let general_root = parent.join("ai-session-search");
let corrections_root = parent.join(RETIRED_SKILL_PACKAGE_NAME);
let targets = vec![skill_target_for_package(
"test",
general_root.clone(),
&AI_SESSION_SEARCH_SKILL_PACKAGE,
vec![],
vec![],
)];
fs::create_dir_all(&general_root).unwrap();
fs::write(
general_root.join("SKILL.md"),
AI_SESSION_SEARCH_SKILL_FILES[0].content,
)
.unwrap();
let retired_skill_md = "---\nname: corrections\ndescription: retired\n---\n\
<!-- ai-session-search-managed-skill v1 -->\nbody\n";
let recorded_capability = "schema_version = 1\n";
fs::create_dir_all(&corrections_root).unwrap();
fs::write(corrections_root.join("SKILL.md"), retired_skill_md).unwrap();
// Recorded at install, then edited by the user afterwards.
fs::write(
corrections_root.join("capability.toml"),
"schema_version = 1\n# my own rule\n",
)
.unwrap();
let config_path = dir.path().join("state/config.toml");
let manifest_path = crate::skill_manifest::manifest_path(&config_path);
fs::create_dir_all(manifest_path.parent().unwrap()).unwrap();
let mut manifest = crate::skill_manifest::SkillInstallManifest::default();
manifest.record(
&corrections_root,
&[
("SKILL.md".to_string(), retired_skill_md),
("capability.toml".to_string(), recorded_capability),
],
);
fs::write(&manifest_path, manifest.to_json().unwrap()).unwrap();
let error = preflight_install(&[], &[], &targets, Path::new("aise"), Some(&manifest_path))
.expect_err("an edited file in the retired package must block its removal");
let message = format!("{error:#}");
assert!(
message.contains("capability.toml"),
"the refusal must name the edited file: {message}"
);
assert!(
corrections_root.join("capability.toml").is_file(),
"the user's edit must survive"
);
}
#[test]
fn install_retires_the_sibling_corrections_package_and_historical_files() {
let dir = tempdir().unwrap();
let parent = dir.path().join("skills");
let general_root = parent.join("ai-session-search");
let corrections_root = parent.join(RETIRED_SKILL_PACKAGE_NAME);
let targets = vec![skill_target_for_package(
"test",
general_root.clone(),
&AI_SESSION_SEARCH_SKILL_PACKAGE,
vec![],
vec![],
)];
let historical_policy = "historical policy\n";
let historical_reference = "historical reference\n";
fs::create_dir_all(general_root.join("corrections")).unwrap();
fs::create_dir_all(general_root.join("references")).unwrap();
fs::write(
general_root.join("SKILL.md"),
AI_SESSION_SEARCH_SKILL_FILES[0].content,
)
.unwrap();
fs::write(
general_root.join("corrections/policy.toml"),
historical_policy,
)
.unwrap();
fs::write(
general_root.join("references/corrections-policy.md"),
historical_reference,
)
.unwrap();
fs::write(general_root.join("references/my-notes.md"), "preserve me\n").unwrap();
// A sibling package installed by an earlier release, marker and all.
let retired_skill_md = "---\nname: corrections\ndescription: retired\n---\n <!-- ai-session-search-managed-skill v1 -->\nbody\n";
let retired_capability = "schema_version = 1\n";
fs::create_dir_all(&corrections_root).unwrap();
fs::write(corrections_root.join("SKILL.md"), retired_skill_md).unwrap();
fs::write(
corrections_root.join("aise-capability.toml"),
retired_capability,
)
.unwrap();
let config_path = dir.path().join("state/config.toml");
let manifest_path = crate::skill_manifest::manifest_path(&config_path);
fs::create_dir_all(manifest_path.parent().unwrap()).unwrap();
let mut historical_manifest = crate::skill_manifest::SkillInstallManifest::default();
historical_manifest.record(
&general_root,
&[
(
"SKILL.md".to_string(),
AI_SESSION_SEARCH_SKILL_FILES[0].content,
),
("corrections/policy.toml".to_string(), historical_policy),
(
"references/corrections-policy.md".to_string(),
historical_reference,
),
],
);
historical_manifest.record(
&corrections_root,
&[
("SKILL.md".to_string(), retired_skill_md),
("aise-capability.toml".to_string(), retired_capability),
],
);
fs::write(&manifest_path, historical_manifest.to_json().unwrap()).unwrap();
let mutations =
preflight_install(&[], &[], &targets, Path::new("aise"), Some(&manifest_path)).unwrap();
execute_planned_transaction(&dir.path().join("state/transaction.json"), &mutations)
.unwrap();
assert!(!general_root.join("corrections/policy.toml").exists());
assert!(!general_root
.join("references/corrections-policy.md")
.exists());
assert_eq!(
fs::read_to_string(general_root.join("references/my-notes.md")).unwrap(),
"preserve me\n"
);
// Every file aise wrote is gone, so the directory is no longer a discoverable skill: the
// Agent Skills specification keys discovery on SKILL.md. An emptied directory may remain,
// which `prune_emptied_skill_directories` treats as untidy rather than as data loss worth
// a recursive delete.
assert!(
!corrections_root.join("SKILL.md").exists(),
"removing the anchor is what stops the retired package being discovered"
);
assert!(!corrections_root.join("aise-capability.toml").exists());
assert_eq!(
fs::read_dir(&corrections_root)
.map(|entries| entries.count())
.unwrap_or(0),
0,
"no aise-written file may survive in the retired package"
);
for file in AI_SESSION_SEARCH_SKILL_PACKAGE.files {
assert_eq!(
fs::read_to_string(general_root.join(file.relative_path)).unwrap(),
file.content
);
}
let migrated = crate::skill_manifest::load_manifest(&manifest_path).unwrap();
let general = migrated.installation(&general_root).unwrap();
assert_eq!(
general
.files
.iter()
.map(|file| file.relative_path.as_str())
.collect::<Vec<_>>(),
AI_SESSION_SEARCH_SKILL_PACKAGE
.files
.iter()
.map(|file| file.relative_path)
.collect::<Vec<_>>()
);
assert!(
migrated.installation(&corrections_root).is_none(),
"the retired root must be forgotten so a later uninstall does not claim it"
);
}
#[test]
fn package_update_retires_only_an_exact_recorded_removed_file() {
let dir = tempdir().unwrap();
let root = dir.path().join("skills/ai-session-search");
let target = skill_target_for_package(
"test",
root.clone(),
&AI_SESSION_SEARCH_SKILL_PACKAGE,
vec![],
vec![],
);
for file in target.package.files {
let path = root.join(file.relative_path);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, file.content).unwrap();
}
let retired_relative = "references/delegated-session-research.md";
let retired_content = "# Delegated session research\n";
let retired_path = root.join(retired_relative);
fs::write(&retired_path, retired_content).unwrap();
let manifest_path =
crate::skill_manifest::manifest_path(&dir.path().join("state/config.toml"));
fs::create_dir_all(manifest_path.parent().unwrap()).unwrap();
let mut manifest = crate::skill_manifest::SkillInstallManifest::default();
let mut recorded = target
.package
.files
.iter()
.map(|file| (file.relative_path.to_string(), file.content))
.collect::<Vec<_>>();
recorded.push((retired_relative.to_string(), retired_content));
manifest.record(&root, &recorded);
fs::write(&manifest_path, manifest.to_json().unwrap()).unwrap();
let mutations = preflight_install(
&[],
&[],
std::slice::from_ref(&target),
Path::new("aise"),
Some(&manifest_path),
)
.unwrap();
assert!(mutations.iter().any(
|mutation| matches!(mutation, PlannedFileMutation::Remove { path, .. } if path == &retired_path)
));
execute_planned_transaction(&dir.path().join("state/transaction.json"), &mutations)
.unwrap();
assert!(!retired_path.exists());
let installed = crate::skill_manifest::load_manifest(&manifest_path).unwrap();
assert!(installed
.installation(&root)
.unwrap()
.file(retired_relative)
.is_none());
}
#[test]
fn package_update_preserves_a_modified_removed_file_before_any_write() {
let dir = tempdir().unwrap();
let root = dir.path().join("skills/ai-session-search");
let target = skill_target_for_package(
"test",
root.clone(),
&AI_SESSION_SEARCH_SKILL_PACKAGE,
vec![],
vec![],
);
for file in target.package.files {
let path = root.join(file.relative_path);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, file.content).unwrap();
}
let retired_relative = "references/delegated-session-research.md";
let retired_path = root.join(retired_relative);
fs::write(&retired_path, "# My preserved research workflow\n").unwrap();
let manifest_path =
crate::skill_manifest::manifest_path(&dir.path().join("state/config.toml"));
fs::create_dir_all(manifest_path.parent().unwrap()).unwrap();
let mut manifest = crate::skill_manifest::SkillInstallManifest::default();
let mut recorded = target
.package
.files
.iter()
.map(|file| (file.relative_path.to_string(), file.content))
.collect::<Vec<_>>();
recorded.push((
retired_relative.to_string(),
"# Delegated session research\n",
));
manifest.record(&root, &recorded);
let manifest_before = manifest.to_json().unwrap();
fs::write(&manifest_path, &manifest_before).unwrap();
let error = preflight_install(
&[],
&[],
std::slice::from_ref(&target),
Path::new("aise"),
Some(&manifest_path),
)
.expect_err("a modified retired file must be preserved");
assert!(format!("{error:#}").contains("preserving it"));
assert_eq!(
fs::read_to_string(&retired_path).unwrap(),
"# My preserved research workflow\n"
);
assert_eq!(fs::read_to_string(&manifest_path).unwrap(), manifest_before);
}
#[test]
fn package_update_rejects_an_unsafe_removed_manifest_path() {
let dir = tempdir().unwrap();
let root = dir.path().join("skills/ai-session-search");
let outside = root.parent().unwrap().join("outside.md");
let target = skill_target_for_package(
"test",
root.clone(),
&AI_SESSION_SEARCH_SKILL_PACKAGE,
vec![],
vec![],
);
for file in target.package.files {
let path = root.join(file.relative_path);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, file.content).unwrap();
}
fs::write(&outside, "must remain outside\n").unwrap();
let manifest_path =
crate::skill_manifest::manifest_path(&dir.path().join("state/config.toml"));
fs::create_dir_all(manifest_path.parent().unwrap()).unwrap();
let mut manifest = crate::skill_manifest::SkillInstallManifest::default();
let mut recorded = target
.package
.files
.iter()
.map(|file| (file.relative_path.to_string(), file.content))
.collect::<Vec<_>>();
recorded.push(("../outside.md".to_string(), "must remain outside\n"));
manifest.record(&root, &recorded);
fs::write(&manifest_path, manifest.to_json().unwrap()).unwrap();
let error = preflight_install(
&[],
&[],
std::slice::from_ref(&target),
Path::new("aise"),
Some(&manifest_path),
)
.expect_err("a manifest path must never escape its skill root");
assert!(format!("{error:#}").contains("invalid managed skill path"));
assert_eq!(
fs::read_to_string(outside).unwrap(),
"must remain outside\n"
);
}
#[test]
fn package_split_refuses_modified_historical_files_before_any_write() {
let dir = tempdir().unwrap();
let parent = dir.path().join("skills");
let general_root = parent.join("ai-session-search");
let corrections_root = parent.join("corrections");
fs::create_dir_all(general_root.join("corrections")).unwrap();
fs::write(
general_root.join("SKILL.md"),
AI_SESSION_SEARCH_SKILL_FILES[0].content,
)
.unwrap();
fs::write(
general_root.join("corrections/policy.toml"),
"edited after install\n",
)
.unwrap();
let config_path = dir.path().join("state/config.toml");
let manifest_path = crate::skill_manifest::manifest_path(&config_path);
fs::create_dir_all(manifest_path.parent().unwrap()).unwrap();
let mut historical_manifest = crate::skill_manifest::SkillInstallManifest::default();
historical_manifest.record(
&general_root,
&[
(
"SKILL.md".to_string(),
AI_SESSION_SEARCH_SKILL_FILES[0].content,
),
(
"corrections/policy.toml".to_string(),
"recorded historical bytes\n",
),
],
);
let manifest_before = historical_manifest.to_json().unwrap();
fs::write(&manifest_path, &manifest_before).unwrap();
let skill_before = fs::read_to_string(general_root.join("SKILL.md")).unwrap();
let targets = vec![
skill_target_for_package(
"test",
general_root.clone(),
&AI_SESSION_SEARCH_SKILL_PACKAGE,
vec![],
vec![],
),
skill_target_for_package(
"test",
corrections_root.clone(),
&AI_SESSION_SEARCH_SKILL_PACKAGE,
vec![],
vec![],
),
];
let error = preflight_install(&[], &[], &targets, Path::new("aise"), Some(&manifest_path))
.expect_err("modified historical bytes must block the split");
assert!(format!("{error:#}").contains("differs from the install manifest"));
assert_eq!(
fs::read_to_string(general_root.join("corrections/policy.toml")).unwrap(),
"edited after install\n"
);
assert_eq!(
fs::read_to_string(general_root.join("SKILL.md")).unwrap(),
skill_before
);
assert!(!corrections_root.exists());
assert_eq!(fs::read_to_string(&manifest_path).unwrap(), manifest_before);
}
#[test]
fn skill_update_finishes_a_pending_split_when_current_files_are_already_present() {
let dir = tempdir().unwrap();
let parent = dir.path().join("skills");
let general_root = parent.join("ai-session-search");
let corrections_root = parent.join("corrections");
for (root, package) in [
(&general_root, &AI_SESSION_SEARCH_SKILL_PACKAGE),
(&corrections_root, &AI_SESSION_SEARCH_SKILL_PACKAGE),
] {
for file in package.files {
let path = root.join(file.relative_path);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, file.content).unwrap();
}
}
let historical_policy = "historical policy\n";
fs::create_dir_all(general_root.join("corrections")).unwrap();
fs::write(
general_root.join("corrections/policy.toml"),
historical_policy,
)
.unwrap();
let config_path = dir.path().join("state/config.toml");
let manifest_path = crate::skill_manifest::manifest_path(&config_path);
fs::create_dir_all(manifest_path.parent().unwrap()).unwrap();
let mut manifest = crate::skill_manifest::SkillInstallManifest::default();
manifest.record(
&general_root,
&[
(
"SKILL.md".to_string(),
AI_SESSION_SEARCH_SKILL_FILES[0].content,
),
("corrections/policy.toml".to_string(), historical_policy),
],
);
manifest.record(
&corrections_root,
&AI_SESSION_SEARCH_SKILL_PACKAGE
.files
.iter()
.map(|file| (file.relative_path.to_string(), file.content))
.collect::<Vec<_>>(),
);
fs::write(&manifest_path, manifest.to_json().unwrap()).unwrap();
let dry_run = write_owned_skills(
std::slice::from_ref(&general_root),
&config_path,
true,
false,
)
.unwrap();
assert!(dry_run.iter().any(|outcome| {
outcome.root == corrections_root.display().to_string()
&& outcome.action == "would remove retired package"
}));
assert!(general_root.join("corrections/policy.toml").exists());
assert!(corrections_root.exists());
let outcomes = write_owned_skills(
std::slice::from_ref(&general_root),
&config_path,
false,
false,
)
.unwrap();
assert!(
outcomes.iter().all(|outcome| outcome.problem.is_none()),
"{outcomes:#?}"
);
assert!(!general_root.join("corrections/policy.toml").exists());
assert!(!corrections_root.exists());
let migrated = crate::skill_manifest::load_manifest(&manifest_path).unwrap();
assert_eq!(
migrated.installation(&general_root).unwrap().files.len(),
AI_SESSION_SEARCH_SKILL_PACKAGE.files.len()
);
}
/// `update` never destroys an edit; `restore` does, and that is the whole difference.
///
/// A routine command that silently replaced a changed file would be the surprise `restore`
/// exists to make explicit and opt-in.
#[test]
fn update_refuses_a_changed_file_and_restore_is_the_named_repair() {
let dir = tempdir().unwrap();
let receipt = dir.path().join("receipt.json");
let root = dir.path().join("skills/ai-session-search");
let roots = vec![root.clone()];
let policy = root.join("aise-capability.toml");
let user_file = root.join("references/my-notes.md");
// First write creates the tree and records it.
let outcomes = write_owned_skills(&roots, &receipt, false, false).unwrap();
assert_eq!(outcomes.len(), 1);
assert!(outcomes[0].problem.is_none(), "{outcomes:#?}");
fs::write(&user_file, "mine").unwrap();
// Already current: nothing to do, and it says so rather than rewriting.
let outcomes = write_owned_skills(&roots, &receipt, false, false).unwrap();
assert_eq!(outcomes[0].action, "already current");
fs::write(&policy, "my own rules\n").unwrap();
let outcomes = write_owned_skills(&roots, &receipt, false, false).unwrap();
let problem = outcomes[0]
.problem
.as_deref()
.expect("a changed file must be refused by update");
assert!(
problem.contains("could destroy an edit you meant to keep")
&& problem.contains("skills restore"),
"the refusal must name the repair path: {problem}"
);
assert_eq!(
fs::read_to_string(&policy).unwrap(),
"my own rules\n",
"update must not have touched it"
);
// Dry-run restore reports the rewrite without performing it.
let outcomes = write_owned_skills(&roots, &receipt, true, true).unwrap();
assert_eq!(outcomes[0].action, "would rewrite from modified or damaged");
assert_eq!(fs::read_to_string(&policy).unwrap(), "my own rules\n");
// Restore overwrites the managed file and leaves the user's own file alone.
let outcomes = write_owned_skills(&roots, &receipt, false, true).unwrap();
assert_eq!(outcomes[0].action, "rewritten from modified or damaged");
assert_eq!(
fs::read_to_string(&policy).unwrap(),
AI_SESSION_SEARCH_SKILL_FILES
.iter()
.find(|file| file.relative_path == "aise-capability.toml")
.unwrap()
.content
);
assert_eq!(
fs::read_to_string(&user_file).unwrap(),
"mine",
"a file aise did not write is never rewritten, even by restore"
);
}
/// Installs the packaged skill at `root`, then makes it look like an older release installed
/// it: the policy file and its manifest record both hold older bytes nobody edited.
fn install_skill_as_an_older_release(receipt: &Path, root: &Path) -> PathBuf {
let outcomes = write_owned_skills(
std::slice::from_ref(&root.to_path_buf()),
receipt,
false,
false,
)
.unwrap();
assert!(outcomes[0].problem.is_none(), "{outcomes:#?}");
let manifest_path = crate::skill_manifest::manifest_path(receipt);
let policy = root.join("aise-capability.toml");
let older = "# an older shipped policy\nschema_version = 1\n";
fs::write(&policy, older).unwrap();
let mut recorded = crate::skill_manifest::load_manifest(&manifest_path)
.unwrap()
.writable_manifest(&manifest_path)
.unwrap();
recorded.record(
root,
&AI_SESSION_SEARCH_SKILL_FILES
.iter()
.map(|file| {
let content = if file.relative_path == "aise-capability.toml" {
older
} else {
file.content
};
(file.relative_path.to_string(), content)
})
.collect::<Vec<_>>(),
);
fs::write(&manifest_path, recorded.to_json().unwrap()).unwrap();
policy
}
fn embedded_policy() -> &'static str {
AI_SESSION_SEARCH_SKILL_FILES
.iter()
.find(|file| file.relative_path == "aise-capability.toml")
.unwrap()
.content
}
#[test]
fn an_upgrade_refreshes_an_unedited_skill_once_without_a_command() {
// Upgrading through uv, pip, Cargo, or a native archive left the skill outdated until the
// user ran `aise integrations install`; only `aise package update` refreshed it.
let dir = tempdir().unwrap();
let receipt = default_transaction_receipt(&dir.path().join("config.toml"));
let root = dir.path().join("skills/ai-session-search");
let policy = install_skill_as_an_older_release(&receipt, &root);
let UpgradeRefresh::Checked {
skills: outcomes, ..
} = refresh_integrations_for_version(&receipt, "9.9.9", None).unwrap()
else {
panic!("an outdated install must be refreshed");
};
assert_eq!(outcomes.len(), 1, "{outcomes:#?}");
assert_eq!(outcomes[0].action, "rewritten from outdated, untouched");
assert_eq!(fs::read_to_string(&policy).unwrap(), embedded_policy());
assert!(
matches!(
refresh_integrations_for_version(&receipt, "9.9.9", None).unwrap(),
UpgradeRefresh::NotNeeded
),
"a version is refreshed once, not on every command"
);
assert!(
matches!(
refresh_integrations_for_version(&receipt, "9.9.10", None).unwrap(),
UpgradeRefresh::Checked { .. }
),
"the next version checks again"
);
}
#[test]
fn an_upgrade_leaves_an_edited_skill_alone_and_reports_it_once() {
let dir = tempdir().unwrap();
let receipt = default_transaction_receipt(&dir.path().join("config.toml"));
let root = dir.path().join("skills/ai-session-search");
let policy = install_skill_as_an_older_release(&receipt, &root);
fs::write(&policy, "# my own rules\nschema_version = 1\n").unwrap();
let UpgradeRefresh::Checked {
skills: outcomes, ..
} = refresh_integrations_for_version(&receipt, "9.9.9", None).unwrap()
else {
panic!("the edited root must be checked and reported");
};
assert!(outcomes[0].problem.is_some(), "{outcomes:#?}");
assert_eq!(
fs::read_to_string(&policy).unwrap(),
"# my own rules\nschema_version = 1\n",
"an automatic refresh must never overwrite an edit"
);
assert!(matches!(
refresh_integrations_for_version(&receipt, "9.9.9", None).unwrap(),
UpgradeRefresh::NotNeeded
));
}
#[test]
fn status_json_carries_each_line_with_whether_it_matches_this_build() {
// Scripts read `current` instead of parsing the words after the colon, which differ by
// component and have changed between releases.
let skill = IntegrationStatusEntry::new(
StatusComponent::Skill,
Some("claude"),
Path::new("/h/skills/ai-session-search"),
ManagedFileState::Current.label(),
);
assert_eq!(
skill.line(),
"claude /h/skills/ai-session-search: configured"
);
assert_eq!(
serde_json::to_value(&skill).unwrap(),
json!({
"component": "skill",
"client": "claude",
"path": "/h/skills/ai-session-search",
"state": "configured",
"current": true
})
);
let outdated = IntegrationStatusEntry::new(
StatusComponent::Instructions,
Some("codex"),
Path::new("/h/.codex/AGENTS.md"),
"outdated",
);
assert!(!outdated.current);
assert_eq!(outdated.line(), "codex /h/.codex/AGENTS.md: outdated");
let alias = IntegrationStatusEntry::new(
StatusComponent::ExecutableAlias,
None,
Path::new("/bin/aisearch"),
"configured",
);
assert_eq!(alias.line(), "executable alias /bin/aisearch: configured");
assert_eq!(serde_json::to_value(&alias).unwrap()["client"], Value::Null);
let dir = tempdir().unwrap();
let root = dir.path().join("root");
fs::create_dir(&root).unwrap();
let link = dir.path().join("link");
let (state, current) = status_skill_discovery_link(&link, &root).unwrap();
assert_eq!((state.as_str(), current), ("missing", false));
#[cfg(unix)]
{
std::os::unix::fs::symlink(&root, &link).unwrap();
let (state, current) = status_skill_discovery_link(&link, &root).unwrap();
assert_eq!(state, format!("linked -> {}", root.display()));
assert!(current);
}
}
#[test]
fn changing_instruction_text_keeps_the_old_text_refreshable() {
// Instruction blocks have no install record, so the post-upgrade refresh recognizes a
// block aise wrote only by its text. Changing either line below without recording the old
// one would leave every installed block "outdated" until the user ran
// `aise integrations install`, the manual upgrade step the refresh exists to remove.
// Before changing the text, add the old line's digest to
// `EARLIER_INSTRUCTION_LINE_DIGESTS`, then update the pin here.
assert_eq!(
crate::hashing::sha256(INSTRUCTIONS_LINE.as_bytes()),
"80e00c44e12fb57a3ba7cebd804880a7ce28ebd004e7442a0415ea32cb5399ab"
);
assert_eq!(
crate::hashing::sha256(CLI_INSTRUCTIONS_LINE.as_bytes()),
"a7bfbb5efc4fd40b0268ce38c0091e17844e2edddd9b8cdfbf7be91588c54442"
);
assert!(is_aise_instruction_line(RC1_INSTRUCTIONS_LINE));
}
/// The line 1.0.0rc1 wrote into every instruction block, including Pi's.
const RC1_INSTRUCTIONS_LINE: &str = "Before guessing about prior AI work, use AI Session Search (`aise`): call the `ai-session-search` MCP `search_sessions` tool to find relevant sessions or `search_messages` for message-level matches, then pass a returned session ID to `get_session`. It searches Claude Code, Claude Desktop local agent, Codex, Cursor, Antigravity, Pi coding agent, Google AI Studio, and Gemini CLI by query, repo/path/file, message context, and time range. If MCP is unavailable, run `aise messages search --help`.";
/// A home holding what 1.0.0rc1 installed for Claude Code, Codex, and Pi, plus the recorded
/// skill that shows aise installed integrations with this configuration.
fn home_with_rc1_instructions(dir: &Path) -> (PathBuf, ClientLayout) {
let receipt = default_transaction_receipt(&dir.join("config.toml"));
install_skill_as_an_older_release(&receipt, &dir.join("skills/ai-session-search"));
let home = dir.join("home");
let rc1_block =
format!("<!-- aise-instructions v1 -->\n{RC1_INSTRUCTIONS_LINE}\n{INSTRUCTIONS_END}");
for agents in [
home.join(".codex/AGENTS.md"),
home.join(".pi/agent/AGENTS.md"),
] {
fs::create_dir_all(agents.parent().unwrap()).unwrap();
fs::write(&agents, format!("# Mine\n\n{rc1_block}\n\n# Also mine\n")).unwrap();
}
fs::create_dir_all(home.join(".claude")).unwrap();
fs::write(
home.join(".claude/CLAUDE.md"),
"# Mine\n\n@AI_SESSION_SEARCH.md\n",
)
.unwrap();
fs::write(
home.join(".claude/AI_SESSION_SEARCH.md"),
format!(
"# AI Session Search (`aise`)\n\n{INSTRUCTIONS_FILE_START}\n{RC1_INSTRUCTIONS_LINE}\n{INSTRUCTIONS_FILE_END}\n"
),
)
.unwrap();
let layout = ClientLayout::new(home.clone(), home.join(".config"), ClientPlatform::Linux);
(receipt, layout)
}
#[test]
fn an_upgrade_replaces_instruction_blocks_an_earlier_release_wrote_where_they_stand() {
// 1.0.0rc2 changed the instruction text, so every block 1.0.0rc1 installed read
// "outdated" until the user ran `aise integrations install`.
let dir = tempdir().unwrap();
let (receipt, layout) = home_with_rc1_instructions(dir.path());
let UpgradeRefresh::Checked { instructions, .. } =
refresh_integrations_for_version(&receipt, "9.9.9", Some(&layout)).unwrap()
else {
panic!("the recorded install must be checked");
};
let home = &layout.home;
assert_eq!(
instructions,
[
InstructionRefresh::Updated(home.join(".claude/AI_SESSION_SEARCH.md")),
InstructionRefresh::Updated(home.join(".codex/AGENTS.md")),
InstructionRefresh::Updated(home.join(".pi/agent/AGENTS.md")),
]
);
for client in [McpClient::Claude, McpClient::Codex, McpClient::Pi] {
for target in instruction_targets_for_layout(client, &layout) {
assert_eq!(
status_instruction_file(&target).unwrap(),
"configured",
"{client:?}"
);
}
}
assert_eq!(
fs::read_to_string(home.join(".codex/AGENTS.md")).unwrap(),
format!(
"# Mine\n\n{}\n\n# Also mine\n",
instruction_block(InstructionFormat::InlineBlock).trim_end()
),
"the block is replaced where it stands and the user's text around it is kept"
);
assert_eq!(
fs::read_to_string(home.join(".claude/CLAUDE.md")).unwrap(),
"# Mine\n\n@AI_SESSION_SEARCH.md\n"
);
assert!(matches!(
refresh_integrations_for_version(&receipt, "9.9.9", Some(&layout)).unwrap(),
UpgradeRefresh::NotNeeded
));
}
#[test]
fn an_upgrade_keeps_crlf_line_endings_and_status_reads_crlf_blocks_as_current() {
// Git and editors on Windows often store these files with CRLF. The refresh rewrote such
// a block with LF lines inside a CRLF file, and status called a current CRLF block
// "outdated" or a current AI_SESSION_SEARCH.md edited.
let dir = tempdir().unwrap();
let (receipt, layout) = home_with_rc1_instructions(dir.path());
let codex = layout.home.join(".codex/AGENTS.md");
let claude_file = layout.home.join(".claude/AI_SESSION_SEARCH.md");
for path in [&codex, &claude_file] {
let crlf = fs::read_to_string(path).unwrap().replace('\n', "\r\n");
fs::write(path, crlf).unwrap();
}
for target in instruction_targets_for_layout(McpClient::Claude, &layout) {
assert_eq!(status_instruction_file(&target).unwrap(), "outdated");
}
let UpgradeRefresh::Checked { instructions, .. } =
refresh_integrations_for_version(&receipt, "9.9.9", Some(&layout)).unwrap()
else {
panic!("the recorded install must be checked");
};
assert!(instructions.contains(&InstructionRefresh::Updated(codex.clone())));
assert!(instructions.contains(&InstructionRefresh::Updated(claude_file.clone())));
for path in [&codex, &claude_file] {
let text = fs::read_to_string(path).unwrap();
assert!(!text.replace("\r\n", "").contains('\n'), "{text:?}");
}
for client in [McpClient::Claude, McpClient::Codex] {
for target in instruction_targets_for_layout(client, &layout) {
assert_eq!(
status_instruction_file(&target).unwrap(),
"configured",
"{client:?}"
);
}
}
}
#[test]
fn an_upgrade_leaves_an_edited_instruction_block_alone_and_reports_it() {
let dir = tempdir().unwrap();
let (receipt, layout) = home_with_rc1_instructions(dir.path());
let codex = layout.home.join(".codex/AGENTS.md");
let edited = fs::read_to_string(&codex)
.unwrap()
.replace(RC1_INSTRUCTIONS_LINE, "Search my sessions with aise first.");
fs::write(&codex, &edited).unwrap();
let UpgradeRefresh::Checked { instructions, .. } =
refresh_integrations_for_version(&receipt, "9.9.9", Some(&layout)).unwrap()
else {
panic!("the recorded install must be checked");
};
assert!(instructions.contains(&InstructionRefresh::Edited(codex.clone())));
assert_eq!(fs::read_to_string(&codex).unwrap(), edited);
}
#[test]
fn an_upgrade_reads_no_client_file_without_a_recorded_install() {
// A process that runs with its own empty configuration, as every test does, must not
// rewrite the client files of whoever runs it.
let dir = tempdir().unwrap();
let (_, layout) = home_with_rc1_instructions(dir.path());
let receipt = default_transaction_receipt(&dir.path().join("other/config.toml"));
let codex = layout.home.join(".codex/AGENTS.md");
let before = fs::read_to_string(&codex).unwrap();
assert!(matches!(
refresh_integrations_for_version(&receipt, "9.9.9", Some(&layout)).unwrap(),
UpgradeRefresh::NotNeeded
));
assert_eq!(fs::read_to_string(&codex).unwrap(), before);
}
#[test]
fn an_upgrade_does_not_restore_a_skill_the_user_deleted() {
let dir = tempdir().unwrap();
let receipt = default_transaction_receipt(&dir.path().join("config.toml"));
let root = dir.path().join("skills/ai-session-search");
install_skill_as_an_older_release(&receipt, &root);
fs::remove_dir_all(&root).unwrap();
let UpgradeRefresh::Checked { skills, .. } =
refresh_integrations_for_version(&receipt, "9.9.9", None).unwrap()
else {
panic!("the recorded install must still be checked");
};
assert!(skills.is_empty(), "{skills:#?}");
assert!(!root.exists(), "a deleted skill must stay deleted");
}
#[test]
fn an_older_aise_never_downgrades_a_skill_a_newer_one_refreshed() {
// With an old Cargo build on PATH beside a newer uv install that MCP clients launch,
// alternating commands would otherwise rewrite the skill back and forth.
let dir = tempdir().unwrap();
let receipt = default_transaction_receipt(&dir.path().join("config.toml"));
let root = dir.path().join("skills/ai-session-search");
let policy = install_skill_as_an_older_release(&receipt, &root);
assert!(matches!(
refresh_integrations_for_version(&receipt, "1.0.0-rc.5", None).unwrap(),
UpgradeRefresh::Checked { .. }
));
let refreshed = fs::read_to_string(&policy).unwrap();
fs::write(&policy, "# an older shipped policy\nschema_version = 1\n").unwrap();
assert!(matches!(
refresh_integrations_for_version(&receipt, "1.0.0-rc.4", None).unwrap(),
UpgradeRefresh::NotNeeded
));
assert_ne!(
fs::read_to_string(&policy).unwrap(),
refreshed,
"left for the newer version"
);
}
#[test]
fn a_failed_refresh_is_retried_by_the_next_command() {
// The marker records a completed check. Written after a failure, it would silence the
// problem until the next release.
let dir = tempdir().unwrap();
let receipt = default_transaction_receipt(&dir.path().join("config.toml"));
let root = dir.path().join("skills/ai-session-search");
install_skill_as_an_older_release(&receipt, &root);
let manifest_path = crate::skill_manifest::manifest_path(&receipt);
fs::write(&manifest_path, "{ not json").unwrap();
assert!(refresh_integrations_for_version(&receipt, "9.9.9", None).is_err());
assert!(!crate::skill_manifest::refreshed_version_path(&receipt).exists());
assert!(refresh_integrations_for_version(&receipt, "9.9.9", None).is_err());
}
#[test]
fn an_upgrade_with_no_installed_skill_writes_nothing() {
let dir = tempdir().unwrap();
let receipt = default_transaction_receipt(&dir.path().join("config.toml"));
assert!(matches!(
refresh_integrations_for_version(&receipt, "9.9.9", None).unwrap(),
UpgradeRefresh::NotNeeded
));
assert_eq!(
fs::read_dir(dir.path()).unwrap().count(),
0,
"a user who never installed integrations gets no files"
);
}
#[test]
fn an_upgrade_defers_while_another_aise_holds_the_integration_lock() {
let dir = tempdir().unwrap();
let receipt = default_transaction_receipt(&dir.path().join("config.toml"));
let root = dir.path().join("skills/ai-session-search");
let policy = install_skill_as_an_older_release(&receipt, &root);
let mut lock =
crate::durable_fs::open_file_lock(&crate::text_file_transaction::lock_path(&receipt))
.unwrap();
let guard = lock.try_write().unwrap();
assert!(matches!(
refresh_integrations_for_version(&receipt, "9.9.9", None).unwrap(),
UpgradeRefresh::Deferred
));
drop(guard);
assert!(
matches!(
refresh_integrations_for_version(&receipt, "9.9.9", None).unwrap(),
UpgradeRefresh::Checked { .. }
),
"a deferred refresh runs on the next command"
);
assert_eq!(fs::read_to_string(&policy).unwrap(), embedded_policy());
}
#[test]
fn routine_skill_update_includes_every_manifest_recorded_custom_root() {
let dir = tempdir().unwrap();
let receipt = dir.path().join("state/transaction.json");
let root = dir.path().join("custom/ai-session-search");
fs::create_dir_all(receipt.parent().unwrap()).unwrap();
fs::create_dir_all(&root).unwrap();
for file in AI_SESSION_SEARCH_SKILL_PACKAGE.files {
let path = root.join(file.relative_path);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, file.content).unwrap();
}
let manifest_path = crate::skill_manifest::manifest_path(&receipt);
let mut manifest = crate::skill_manifest::SkillInstallManifest::default();
manifest.record(
&root,
&AI_SESSION_SEARCH_SKILL_PACKAGE
.files
.iter()
.map(|file| (file.relative_path.to_string(), file.content))
.collect::<Vec<_>>(),
);
fs::write(&manifest_path, manifest.to_json().unwrap()).unwrap();
let loaded = crate::skill_manifest::load_manifest(&manifest_path).unwrap();
assert_eq!(manifest_recorded_skill_roots(&loaded).unwrap(), vec![root]);
}
/// A directory aise never wrote is diagnosed per root, not written and not fatal.
#[test]
fn writing_an_unowned_root_is_refused_without_stopping_the_owned_ones() {
let dir = tempdir().unwrap();
let receipt = dir.path().join("receipt.json");
let owned = dir.path().join("owned/ai-session-search");
let foreign = dir.path().join("foreign/ai-session-search");
fs::create_dir_all(&foreign).unwrap();
fs::write(foreign.join("SKILL.md"), "---\nname: mine\n---\n").unwrap();
let outcomes =
write_owned_skills(&[owned.clone(), foreign.clone()], &receipt, false, false).unwrap();
assert_eq!(outcomes.len(), 2);
let foreign_outcome = outcomes
.iter()
.find(|outcome| outcome.root == foreign.display().to_string())
.unwrap();
assert!(
foreign_outcome
.problem
.as_deref()
.is_some_and(|text| text.contains("no managed marker and no install record")),
"{foreign_outcome:#?}"
);
assert_eq!(
fs::read_to_string(foreign.join("SKILL.md")).unwrap(),
"---\nname: mine\n---\n"
);
assert!(
owned.join("SKILL.md").is_file(),
"one refused root must not stop the others from being written"
);
}
/// A manifest that names a file which is now gone must report `missing`, not `configured`.
///
/// The record says aise wrote three files. If one has since been deleted, summarizing the two
/// that survive as healthy hides the only fact worth acting on.
#[test]
fn a_manifest_naming_a_vanished_file_reports_it_missing() {
let dir = tempdir().unwrap();
let receipt = dir.path().join("receipt.json");
let manifest_path = crate::skill_manifest::manifest_path(&dir.path().join("config.toml"));
let root = dir.path().join("skills/corrections");
let target = skill_target_for_package(
CUSTOM_SKILL_TARGET_LABEL,
root.clone(),
&AI_SESSION_SEARCH_SKILL_PACKAGE,
vec![],
vec![],
);
let mutations = preflight_install(
&[],
&[],
std::slice::from_ref(&target),
Path::new("aise"),
Some(&manifest_path),
)
.unwrap();
execute_planned_transaction(&receipt, &mutations).unwrap();
fs::remove_file(root.join("references/message-classification.md")).unwrap();
let manifest = crate::skill_manifest::load_manifest(&manifest_path).unwrap();
let partial = status_skill_file(&target, &manifest).unwrap();
assert!(
partial.starts_with("incomplete: 1 of 5 managed files missing"),
"{partial}"
);
// Every file gone is a different fact from one file gone, and the words have to differ:
// `aise integrations status` printed `missing` for a package whose other four files were
// byte-current, which reads as "nothing is installed" about a working skill.
for file in target.package.files {
let path = root.join(file.relative_path);
if path.exists() {
fs::remove_file(path).unwrap();
}
}
assert_eq!(status_skill_file(&target, &manifest).unwrap(), "missing");
}
/// A truncated or future-schema manifest must leave every command RUNNABLE.
///
/// These are the commands you reach for when something is wrong, so refusing to run because
/// the diagnostic file is itself damaged would strand the user. It reports uncertainty
/// instead, and never claims a tree is untouched on evidence it cannot read.
#[test]
fn a_damaged_manifest_degrades_to_uncertainty_rather_than_failing_the_command() {
let dir = tempdir().unwrap();
let receipt = dir.path().join("receipt.json");
let manifest_path = crate::skill_manifest::manifest_path(&dir.path().join("config.toml"));
let root = dir.path().join("skills/corrections");
let target = skill_target_for_package(
CUSTOM_SKILL_TARGET_LABEL,
root.clone(),
&AI_SESSION_SEARCH_SKILL_PACKAGE,
vec![],
vec![],
);
let mutations = preflight_install(
&[],
&[],
std::slice::from_ref(&target),
Path::new("aise"),
Some(&manifest_path),
)
.unwrap();
execute_planned_transaction(&receipt, &mutations).unwrap();
fs::write(root.join("aise-capability.toml"), "edited\n").unwrap();
for damaged in [
"",
"{ truncated",
r#"{"schema_version": 99, "installations": []}"#,
] {
fs::write(&manifest_path, damaged).unwrap();
let manifest =
crate::skill_manifest::load_manifest(&manifest_path).unwrap_or_else(|error| {
panic!("reading a damaged manifest must not fail: {error:#}")
});
assert_eq!(
status_skill_file(&target, &manifest).unwrap(),
"legacy, ownership uncertain",
"with no readable record, whether the file was edited is unknowable: {damaged:?}"
);
// And uninstall must still be able to decide -- conservatively.
let plan = plan_remove_skill_file(&target, &manifest, false).unwrap();
assert!(
plan.mutations.is_empty() && !plan.preserved.is_empty(),
"an unreadable record must preserve the tree, not delete on a guess: {damaged:?}"
);
}
}
#[test]
fn damaged_manifest_blocks_install_and_skill_writes_before_mutation() {
for damaged in [
"",
"{ truncated",
r#"{"schema_version": 99, "installations": []}"#,
] {
let dir = tempdir().unwrap();
let config_path = dir.path().join("state/config.toml");
let manifest_path = crate::skill_manifest::manifest_path(&config_path);
fs::create_dir_all(manifest_path.parent().unwrap()).unwrap();
fs::write(&manifest_path, damaged).unwrap();
let root = dir.path().join("skills/ai-session-search");
let target = skill_target_for_package(
CUSTOM_SKILL_TARGET_LABEL,
root.clone(),
&AI_SESSION_SEARCH_SKILL_PACKAGE,
vec![],
vec![],
);
let error = preflight_install(
&[],
&[],
std::slice::from_ref(&target),
Path::new("aise"),
Some(&manifest_path),
)
.expect_err("install must refuse damaged ownership evidence");
assert!(format!("{error:#}").contains("repair or move"));
assert!(!root.exists(), "preflight must not publish package files");
assert_eq!(fs::read_to_string(&manifest_path).unwrap(), damaged);
let error = write_owned_skills(std::slice::from_ref(&root), &config_path, false, false)
.expect_err("update and restore must refuse damaged ownership evidence");
assert!(format!("{error:#}").contains("repair or move"));
assert!(
!root.exists(),
"write command must fail before package creation"
);
assert_eq!(fs::read_to_string(&manifest_path).unwrap(), damaged);
}
}
/// `--force-full-cleanup` must refuse every way of reaching it by accident.
#[test]
fn force_full_cleanup_refuses_anything_but_one_explicitly_named_owned_root() {
let named = skill_target(
CUSTOM_SKILL_TARGET_LABEL,
PathBuf::from("/tmp/skills/ai-session-search"),
vec![],
vec![],
);
let detected = skill_target(
"claude",
PathBuf::from("/home/test/.claude/skills/ai-session-search"),
vec![],
vec![],
);
let mut args = IntegrationUninstallArgs {
force_full_cleanup: true,
..Default::default()
};
// No --skill-root at all: the flag must not fan out to detected client directories.
let error =
validate_force_full_cleanup(&args, std::slice::from_ref(&detected)).unwrap_err();
assert!(
format!("{error:#}").contains("requires an exact --skill-root"),
"{error:#}"
);
// A root was named and a detected directory came along with it. That must NOT be an
// error: once a skill is installed, client directories are always detected, so refusing
// would leave a caller who named an exact root with no way to satisfy the check. The
// detected roots are dropped instead, which is the only safe reading of "this exact root".
args.targets.skill_roots = vec![PathBuf::from("/tmp/skills/ai-session-search")];
validate_force_full_cleanup(&args, &[named.clone(), detected]).unwrap();
// Contradictory intent.
args.keep_skill = true;
let error = validate_force_full_cleanup(&args, std::slice::from_ref(&named)).unwrap_err();
assert!(format!("{error:#}").contains("contradict"), "{error:#}");
args.keep_skill = false;
validate_force_full_cleanup(&args, &[named]).unwrap();
}
/// Force cleanup deletes the caller's own files -- that is its purpose -- but only inside the
/// named root, and it still refuses a directory that is provably not aise's.
#[test]
fn force_full_cleanup_removes_user_files_inside_the_root_and_nothing_outside_it() {
let dir = tempdir().unwrap();
let receipt = dir.path().join("state/receipt.json");
let absent = crate::skill_manifest::ManifestState::Absent;
let root = dir.path().join("skills/ai-session-search");
let target = skill_target(CUSTOM_SKILL_TARGET_LABEL, root.clone(), vec![], vec![]);
publish_planned_mutations(
&normalize_planned_mutations(plan_upsert_skill_file(&target).unwrap()).unwrap(),
)
.unwrap();
let user_file = root.join("references/my-notes.md");
fs::create_dir_all(user_file.parent().unwrap()).unwrap();
fs::write(&user_file, "mine").unwrap();
let sibling = dir.path().join("skills/unrelated.md");
fs::write(&sibling, "not part of the skill").unwrap();
let plan = plan_remove_skill_file(&target, &absent, true).unwrap();
assert!(
plan.preserved.is_empty(),
"force does not preserve; that is the point of the flag"
);
execute_planned_transaction(
&receipt,
&normalize_planned_mutations(plan.mutations).unwrap(),
)
.unwrap();
prune_emptied_skill_directories(&plan.prune);
assert!(
!user_file.exists(),
"force cleanup deletes the caller's own files"
);
assert!(!root.exists(), "the emptied skill root itself is pruned");
assert_eq!(
fs::read_to_string(&sibling).unwrap(),
"not part of the skill",
"nothing outside the named root may be touched"
);
// An arbitrary unowned directory is still refused, even with force.
let unowned = dir.path().join("skills/notaskill");
fs::create_dir_all(&unowned).unwrap();
fs::write(unowned.join("SKILL.md"), "---\nname: notaskill\n---\n").unwrap();
let error = plan_remove_skill_file(
&skill_target(CUSTOM_SKILL_TARGET_LABEL, unowned.clone(), vec![], vec![]),
&absent,
true,
)
.unwrap_err();
assert!(format!("{error:#}").contains("unmanaged"), "{error:#}");
assert!(unowned.join("SKILL.md").exists());
}
/// S13: the install record is what separates "you have an older aise" from "you edited this".
///
/// Byte comparison alone answers only "does this equal the current build", and a NO covers
/// both. Reporting the first as the second tells the user something FALSE about their own
/// filesystem -- uninstall then says "modified since install, your edits are kept" about a
/// file they never touched, and preserves a stale directory forever.
#[test]
fn the_install_record_separates_an_older_install_from_an_edited_one() {
let dir = tempdir().unwrap();
let receipt = dir.path().join("state/install-receipt.json");
let manifest_path = crate::skill_manifest::manifest_path(&dir.path().join("config.toml"));
let root = dir.path().join("skills/corrections");
let target = skill_target_for_package(
"test",
root.clone(),
&AI_SESSION_SEARCH_SKILL_PACKAGE,
vec![],
vec![],
);
let policy = root.join("aise-capability.toml");
let mutations = preflight_install(
&[],
&[],
std::slice::from_ref(&target),
Path::new("aise"),
Some(&manifest_path),
)
.unwrap();
execute_planned_transaction(&receipt, &mutations).unwrap();
let manifest = crate::skill_manifest::load_manifest(&manifest_path).unwrap();
assert_eq!(status_skill_file(&target, &manifest).unwrap(), "configured");
// Simulate an OLDER install: rewrite the file AND the record to the same older bytes, so
// disk matches the manifest but not what this build embeds. Nobody edited anything.
let older = "# an older shipped policy\nschema_version = 1\n";
fs::write(&policy, older).unwrap();
let mut recorded = manifest.writable_manifest(&manifest_path).unwrap();
recorded.record(
&root,
&target
.package
.files
.iter()
.map(|file| {
let content = if file.relative_path == "aise-capability.toml" {
older
} else {
file.content
};
(file.relative_path.to_string(), content)
})
.collect::<Vec<_>>(),
);
fs::write(&manifest_path, recorded.to_json().unwrap()).unwrap();
let manifest = crate::skill_manifest::load_manifest(&manifest_path).unwrap();
assert_eq!(
status_skill_file(&target, &manifest).unwrap(),
"outdated, untouched",
"disk equals the record but not this build: an older install nobody touched"
);
// Now the user really does edit it. Same "not current" byte comparison, opposite meaning.
fs::write(&policy, "# my own rules\nschema_version = 1\n").unwrap();
assert_eq!(
status_skill_file(&target, &manifest).unwrap(),
"modified or damaged",
"disk differs from the record: changed since install"
);
// Never "modified" vs "damaged": nothing on disk carries that intent.
assert!(
!status_skill_file(&target, &manifest)
.unwrap()
.contains("edited"),
"the report must not claim to know whether a change was intentional"
);
// Without any record, the same edited file can only be reported as uncertain -- which is
// exactly the state this whole mechanism exists to replace.
assert_eq!(
status_skill_file(&target, &crate::skill_manifest::ManifestState::Absent).unwrap(),
"legacy, ownership uncertain"
);
}
/// The manifest is published by the SAME transaction as the files it describes, and it
/// records only the roots this run touched.
///
/// Written afterwards, it could survive a rollback and claim an install that never happened;
/// written for every known root, one harness's uninstall would erase another's record.
#[test]
fn the_install_manifest_travels_with_the_files_it_describes() {
let dir = tempdir().unwrap();
let receipt = dir.path().join("state/install-receipt.json");
let manifest_path = crate::skill_manifest::manifest_path(&dir.path().join("config.toml"));
let claude = skill_target(
"claude",
dir.path().join("claude/ai-session-search"),
vec![],
vec![],
);
let codex = skill_target(
"codex",
dir.path().join("codex/ai-session-search"),
vec![],
vec![],
);
let mutations = preflight_install(
&[],
&[],
std::slice::from_ref(&claude),
Path::new("aise"),
Some(&manifest_path),
)
.unwrap();
assert_eq!(
mutations.len(),
claude.package.files.len() + 1,
"one mutation per managed file, plus the manifest, in one transaction"
);
execute_planned_transaction(&receipt, &mutations).unwrap();
let state = crate::skill_manifest::load_manifest(&manifest_path).unwrap();
let recorded = state
.installation(&claude.root)
.expect("the installed root is recorded");
assert_eq!(recorded.files.len(), claude.package.files.len());
for file in claude.package.files {
let entry = recorded
.file(file.relative_path)
.unwrap_or_else(|| panic!("{} is recorded", file.relative_path));
assert_eq!(entry.bytes, file.content.len());
assert_eq!(
entry.sha256,
crate::hashing::sha256(file.content.as_bytes()),
"the digest must describe the bytes actually written"
);
}
assert!(
!manifest_path.file_name().is_some_and(|name| claude
.package
.files
.iter()
.any(|file| file.relative_path == name)),
"the manifest is not itself a managed skill file"
);
// A second root joins the record without disturbing the first.
let second = preflight_install(
&[],
&[],
std::slice::from_ref(&codex),
Path::new("aise"),
Some(&manifest_path),
)
.unwrap();
execute_planned_transaction(&receipt, &second).unwrap();
let state = crate::skill_manifest::load_manifest(&manifest_path).unwrap();
assert!(state.installation(&claude.root).is_some());
assert!(state.installation(&codex.root).is_some());
// Uninstalling ONE root forgets only that root.
let plan = preflight_uninstall(
&[],
&[],
std::slice::from_ref(&claude),
Some(&manifest_path),
false,
)
.unwrap();
execute_planned_transaction(&receipt, &plan.mutations).unwrap();
let state = crate::skill_manifest::load_manifest(&manifest_path).unwrap();
assert!(
state.installation(&claude.root).is_none(),
"the removed root must lose its record"
);
assert!(
state.installation(&codex.root).is_some(),
"another harness's record must survive, or its own uninstall becomes ambiguous"
);
// Removing the last root leaves no manifest, so "absent" keeps meaning "nothing managed".
let plan = preflight_uninstall(
&[],
&[],
std::slice::from_ref(&codex),
Some(&manifest_path),
false,
)
.unwrap();
execute_planned_transaction(&receipt, &plan.mutations).unwrap();
assert!(!manifest_path.exists());
}
#[test]
fn managed_skill_lifecycle_refuses_unowned_files() {
let dir = tempdir().unwrap();
// No install record: these tests predate the manifest and exercise the
// byte-comparison path a legacy install still takes.
let absent = crate::skill_manifest::ManifestState::Absent;
let root = dir.path().join("skills/corrections");
let anchor = root.join(SKILL_ANCHOR_FILE);
let target = skill_target_for_package(
"test",
root.clone(),
&AI_SESSION_SEARCH_SKILL_PACKAGE,
Vec::new(),
Vec::new(),
);
publish_planned_mutations(
&normalize_planned_mutations(plan_upsert_skill_file(&target).unwrap()).unwrap(),
)
.unwrap();
assert_eq!(status_skill_file(&target, &absent).unwrap(), "configured");
let legacy_anchor = AI_SESSION_SEARCH_SKILL_FILES[0].content.replacen(
"# AI Session Search",
"# Older AI Session Search",
1,
);
fs::write(&anchor, legacy_anchor).unwrap();
assert_eq!(
status_skill_file(&target, &absent).unwrap(),
"legacy, ownership uncertain",
"without an install record, whether anyone edited this is unknowable"
);
publish_planned_mutations(
&normalize_planned_mutations(plan_upsert_skill_file(&target).unwrap()).unwrap(),
)
.unwrap();
// S12: a file the USER added makes the WHOLE directory ambiguous, so normal uninstall
// removes nothing and says why. Removing the managed files while leaving their notes
// would orphan that file behind a skill that no longer declares itself.
let user_file = root.join("references/my-notes.md");
fs::create_dir_all(user_file.parent().unwrap()).unwrap();
fs::write(&user_file, "mine").unwrap();
let plan = plan_remove_skill_file(&target, &absent, false).unwrap();
assert!(
plan.mutations.is_empty(),
"an unmanaged entry must stop the removal entirely"
);
assert_eq!(plan.preserved.len(), 1);
assert!(
plan.preserved[0].contains("my-notes.md")
&& plan.preserved[0].contains("not written by aise"),
"the reason must name the offending file: {:?}",
plan.preserved
);
for file in target.package.files {
assert!(root.join(file.relative_path).exists());
}
// With the user's file gone, the tree is unambiguously aise's and removal proceeds.
fs::remove_file(&user_file).unwrap();
let plan = plan_remove_skill_file(&target, &absent, false).unwrap();
assert!(plan.preserved.is_empty());
publish_planned_mutations(&normalize_planned_mutations(plan.mutations).unwrap()).unwrap();
for file in target.package.files {
assert!(
!root.join(file.relative_path).exists(),
"{} should have been removed",
file.relative_path
);
}
// S5: directories aise created and this removal emptied are pruned; nothing else.
prune_emptied_skill_directories(&plan.prune);
assert!(
!root.exists(),
"the emptied managed package root and its subdirectories must not be left behind"
);
fs::create_dir_all(&root).unwrap();
fs::write(&anchor, "---\nname: user-skill\n---\n").unwrap();
assert!(plan_upsert_skill_file(&target)
.unwrap_err()
.to_string()
.contains("unmanaged"));
assert!(plan_remove_skill_file(&target, &absent, false)
.unwrap_err()
.to_string()
.contains("unmanaged"));
assert_eq!(
status_skill_file(&target, &absent).unwrap(),
"modified or unmanaged"
);
}
#[test]
fn skill_targets_cover_supported_skill_harnesses() {
let layout = ClientLayout::new(
PathBuf::from("/home/test"),
PathBuf::from("/home/test/.config"),
ClientPlatform::Linux,
);
let claude = skill_targets_for_layout(McpClient::Claude, &layout);
let codex = skill_targets_for_layout(McpClient::Codex, &layout);
let gemini = skill_targets_for_layout(McpClient::Gemini, &layout);
let antigravity = skill_targets_for_layout(McpClient::Antigravity, &layout);
let pi = skill_targets_for_layout(McpClient::Pi, &layout);
let prime_agent = skill_targets_for_layout(McpClient::PrimeAgent, &layout);
let canonical = PathBuf::from("/home/test/.ai-session-search/skills/ai-session-search");
assert_eq!(
claude[0].root, canonical,
"the app owns one canonical package in its platform config namespace"
);
assert_eq!(codex[0].root, canonical);
assert_eq!(gemini[0].root, canonical);
assert_eq!(antigravity[0].root, canonical);
assert_eq!(pi[0].root, canonical);
assert_eq!(prime_agent[0].root, canonical);
assert_eq!(
claude[0].discovery_links[0],
PathBuf::from("/home/test/.claude/skills/ai-session-search")
);
assert_eq!(
codex[0].discovery_links[0],
PathBuf::from("/home/test/.agents/skills/ai-session-search")
);
assert_eq!(
gemini[0].discovery_links[0],
PathBuf::from("/home/test/.gemini/skills/ai-session-search")
);
assert_eq!(
antigravity[0].discovery_links[0],
PathBuf::from("/home/test/.gemini/config/skills/ai-session-search")
);
assert_eq!(
antigravity[0].discovery_links[1],
PathBuf::from("/home/test/.gemini/antigravity-cli/skills/ai-session-search"),
"Antigravity CLI has a distinct current global skill directory"
);
assert_eq!(
pi[0].discovery_links[0],
PathBuf::from("/home/test/.pi/agent/skills/ai-session-search")
);
assert_eq!(
prime_agent[0].discovery_links[0],
PathBuf::from("/home/test/.prime/agent/skills/ai-session-search")
);
assert!(skill_targets_for_layout(McpClient::Opencode, &layout).is_empty());
}
#[test]
fn uninstall_includes_the_retired_package_and_matching_discovery_links() {
let mut current = skill_target(
"app",
PathBuf::from("/home/test/.ai-session-search/skills/ai-session-search"),
vec![],
vec![],
);
current.discovery_links = vec![
PathBuf::from("/home/test/.claude/skills/ai-session-search"),
PathBuf::from("/home/test/.agents/skills/ai-session-search"),
];
let mut targets = vec![current];
append_retired_sibling_skill_targets(&mut targets).unwrap();
assert_eq!(targets.len(), 2);
assert_eq!(
targets[1].root,
PathBuf::from("/home/test/.ai-session-search/skills/corrections")
);
assert_eq!(targets[1].package.name, "corrections");
assert_eq!(
targets[1].discovery_links,
vec![
PathBuf::from("/home/test/.claude/skills/corrections"),
PathBuf::from("/home/test/.agents/skills/corrections"),
]
);
}
#[test]
fn canonical_skill_retention_inventory_includes_antigravity_cli() {
let layout = ClientLayout::new(
PathBuf::from("/home/test"),
PathBuf::from("/home/test/.config"),
ClientPlatform::Linux,
);
let links = all_standard_discovery_links(&layout, &AI_SESSION_SEARCH_SKILL_PACKAGE);
for expected in [
"/home/test/.gemini/antigravity-cli/skills/ai-session-search",
"/home/test/.pi/agent/skills/ai-session-search",
"/home/test/.prime/agent/skills/ai-session-search",
] {
assert!(
links.contains(&PathBuf::from(expected)),
"a surviving harness link must retain the canonical skill package: {links:?}"
);
}
}
#[test]
fn canonical_skill_retention_fails_closed_when_a_harness_link_cannot_be_inspected() {
let dir = tempdir().unwrap();
let home = dir.path().join("home");
fs::create_dir_all(&home).unwrap();
fs::write(home.join(".gemini"), "not a directory").unwrap();
let layout = ClientLayout::new(home, dir.path().join("config"), ClientPlatform::Linux);
let canonical = dir.path().join("state/skills/ai-session-search");
let error = another_owned_discovery_link(
&layout,
&AI_SESSION_SEARCH_SKILL_PACKAGE,
&[],
&canonical,
)
.expect_err("an unreadable inventory cannot authorize package removal")
.to_string();
assert!(error.contains("inspect skill discovery entry"), "{error}");
let normalized_error = error.replace('\\', "/");
assert!(
normalized_error.contains(".gemini/skills/ai-session-search"),
"{error}"
);
}
#[test]
fn selecting_multiple_harnesses_keeps_every_discovery_directory() {
let layout = ClientLayout::new(
PathBuf::from("/home/test"),
PathBuf::from("/home/test/.config"),
ClientPlatform::Linux,
);
let mut targets = [
McpClient::Claude,
McpClient::Codex,
McpClient::Gemini,
McpClient::Antigravity,
McpClient::Pi,
McpClient::PrimeAgent,
]
.into_iter()
.flat_map(|client| skill_targets_for_layout(client, &layout))
.collect::<Vec<_>>();
dedupe_skill_targets(&mut targets).unwrap();
assert_eq!(targets.len(), MANAGED_SKILL_PACKAGES.len());
for target in targets {
assert_eq!(
target.discovery_links.len(),
7,
"{} must remain discoverable from all selected harnesses",
target.package.name
);
}
}
#[test]
fn relocated_config_rehomes_only_automatic_canonical_skills() {
let package = &AI_SESSION_SEARCH_SKILL_PACKAGE;
let mut automatic =
skill_target_for_package("app", PathBuf::from("/old"), package, vec![], vec![]);
automatic
.discovery_links
.push(PathBuf::from("/home/test/.claude/skills/ai-session-search"));
let custom = skill_target_for_package(
CUSTOM_SKILL_TARGET_LABEL,
PathBuf::from("/custom/ai-session-search"),
package,
vec![],
vec![],
);
let mut targets = vec![automatic, custom];
rebase_automatic_skill_roots(
&mut targets,
Path::new("/portable/aise/.ai-session-search-mcp-transaction.json"),
);
assert_eq!(
targets[0].root,
PathBuf::from("/portable/aise/skills/ai-session-search")
);
assert_eq!(targets[1].root, PathBuf::from("/custom/ai-session-search"));
}
#[cfg(unix)]
#[test]
fn canonical_install_creates_real_skill_and_harness_symlink() {
let dir = tempdir().unwrap();
let canonical = dir.path().join("config/skills/ai-session-search");
let link = dir.path().join("harness/skills/ai-session-search");
let mut target = skill_target_for_package(
"app",
canonical.clone(),
&AI_SESSION_SEARCH_SKILL_PACKAGE,
vec![],
vec![],
);
target.discovery_links.push(link.clone());
let manifest = dir.path().join("config/skill-install-manifest.json");
let receipt = dir.path().join("config/transaction.json");
let mutations = preflight_install(
&[],
&[],
&[target.clone()],
Path::new("aise"),
Some(&manifest),
)
.unwrap();
let links = plan_install_skill_discovery_links(std::slice::from_ref(&target)).unwrap();
execute_planned_transaction_with_links(&receipt, &mutations, &links, |_, change| {
publish_install_skill_discovery_link(change, std::slice::from_ref(&target))
})
.unwrap();
assert!(fs::symlink_metadata(&link)
.unwrap()
.file_type()
.is_symlink());
assert_eq!(fs::read_link(&link).unwrap(), canonical);
assert!(link.join("SKILL.md").is_file());
}
#[cfg(unix)]
#[test]
fn install_retires_the_owned_sibling_package_and_its_discovery_symlink() {
let dir = tempdir().unwrap();
let canonical = dir.path().join("config/skills/ai-session-search");
let retired = canonical.parent().unwrap().join(RETIRED_SKILL_PACKAGE_NAME);
let link = dir.path().join("harness/skills/ai-session-search");
let retired_link = link.parent().unwrap().join(RETIRED_SKILL_PACKAGE_NAME);
let mut target = skill_target_for_package(
"app",
canonical.clone(),
&AI_SESSION_SEARCH_SKILL_PACKAGE,
vec![],
vec![],
);
target.discovery_links.push(link.clone());
let retired_skill = "---\nname: corrections\ndescription: retired\n---\n\
<!-- ai-session-search-managed-skill v1 -->\nbody\n";
let retired_capability = "schema_version = 1\n";
fs::create_dir_all(&retired).unwrap();
fs::write(retired.join("SKILL.md"), retired_skill).unwrap();
fs::write(retired.join("capability.toml"), retired_capability).unwrap();
fs::create_dir_all(retired_link.parent().unwrap()).unwrap();
create_directory_symlink(&retired, &retired_link).unwrap();
let manifest = dir.path().join("config/skill-install-manifest.json");
fs::create_dir_all(manifest.parent().unwrap()).unwrap();
let mut recorded = crate::skill_manifest::SkillInstallManifest::default();
recorded.record(
&retired,
&[
("SKILL.md".to_string(), retired_skill),
("capability.toml".to_string(), retired_capability),
],
);
fs::write(&manifest, recorded.to_json().unwrap()).unwrap();
let receipt = dir.path().join("config/transaction.json");
let mutations = preflight_install(
&[],
&[],
std::slice::from_ref(&target),
Path::new("aise"),
Some(&manifest),
)
.unwrap();
let retired_links =
plan_retire_sibling_discovery_links(std::slice::from_ref(&target)).unwrap();
let mut links = retired_links
.iter()
.map(|(path, expected)| SymlinkChange::remove(path.clone(), expected.clone()))
.collect::<Vec<_>>();
links.extend(plan_install_skill_discovery_links(std::slice::from_ref(&target)).unwrap());
execute_planned_transaction_with_links(&receipt, &mutations, &links, |_, change| {
publish_install_skill_discovery_link(change, std::slice::from_ref(&target))
})
.unwrap();
prune_retired_skill_directories(std::slice::from_ref(&retired));
assert!(!retired_link.exists());
assert!(!retired.join("SKILL.md").exists());
assert!(!retired.exists());
assert_eq!(fs::read_link(&link).unwrap(), canonical);
assert!(canonical
.join(crate::skill_catalog::CAPABILITY_FILE)
.is_file());
}
#[cfg(unix)]
#[test]
fn second_discovery_link_install_failure_restores_text_and_first_link() {
let dir = tempdir().unwrap();
let config = dir.path().join("config.json");
let receipt = dir.path().join("transaction.json");
let canonical = dir.path().join("skills/ai-session-search");
let first = dir.path().join("claude/skills/ai-session-search");
let second = dir.path().join("codex/skills/ai-session-search");
fs::write(&config, "before").unwrap();
fs::create_dir_all(&canonical).unwrap();
let mutations = [planned_write(
&config,
&Some("before".to_string()),
"after".to_string(),
)];
let links = [
SymlinkChange::install(first.clone(), canonical.clone()),
SymlinkChange::install(second.clone(), canonical),
];
let error = execute_planned_transaction_with_links(
&receipt,
&mutations,
&links,
|index, change| {
if index == 1 {
bail!("injected second discovery-link install failure");
}
publish_symlink_change(change)
},
)
.unwrap_err()
.to_string();
assert!(error.contains("restored 2 changed path"), "{error}");
assert_eq!(fs::read_to_string(config).unwrap(), "before");
assert!(!first.exists());
assert!(!second.exists());
assert!(!receipt.exists());
}
#[cfg(unix)]
#[test]
fn second_discovery_link_uninstall_failure_restores_text_and_first_link() {
let dir = tempdir().unwrap();
let config = dir.path().join("config.json");
let receipt = dir.path().join("transaction.json");
let canonical = dir.path().join("skills/ai-session-search");
let first = dir.path().join("claude/skills/ai-session-search");
let second = dir.path().join("codex/skills/ai-session-search");
fs::write(&config, "before").unwrap();
fs::create_dir_all(&canonical).unwrap();
fs::create_dir_all(first.parent().unwrap()).unwrap();
fs::create_dir_all(second.parent().unwrap()).unwrap();
create_directory_symlink(&canonical, &first).unwrap();
create_directory_symlink(&canonical, &second).unwrap();
let mutations = [planned_write(
&config,
&Some("before".to_string()),
"after".to_string(),
)];
let links = [
SymlinkChange::remove(first.clone(), canonical.clone()),
SymlinkChange::remove(second.clone(), canonical.clone()),
];
let error = execute_planned_transaction_with_links(
&receipt,
&mutations,
&links,
|index, change| {
if index == 1 {
bail!("injected second discovery-link uninstall failure");
}
publish_symlink_change(change)
},
)
.unwrap_err()
.to_string();
assert!(error.contains("restored 2 changed path"), "{error}");
assert_eq!(fs::read_to_string(config).unwrap(), "before");
assert_eq!(fs::read_link(first).unwrap(), canonical);
assert_eq!(fs::read_link(second).unwrap(), canonical);
assert!(!receipt.exists());
}
#[test]
fn migration_refuses_a_legacy_copy_with_user_files() {
let dir = tempdir().unwrap();
let canonical = dir.path().join("config/skills/ai-session-search");
let legacy = dir.path().join("harness/skills/ai-session-search");
let mut target = skill_target_for_package(
"app",
canonical,
&AI_SESSION_SEARCH_SKILL_PACKAGE,
vec![],
vec![],
);
target.discovery_links.push(legacy.clone());
for file in AI_SESSION_SEARCH_SKILL_PACKAGE.files {
let path = legacy.join(file.relative_path);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, file.content).unwrap();
}
fs::write(legacy.join("my-notes.md"), "keep me").unwrap();
let error = preflight_install(
&[],
&[],
&[target],
Path::new("aise"),
Some(&dir.path().join("manifest.json")),
)
.unwrap_err();
assert!(format!("{error:#}").contains("user changes"));
assert_eq!(
fs::read_to_string(legacy.join("my-notes.md")).unwrap(),
"keep me"
);
}
#[cfg(unix)]
#[test]
fn uninstall_transaction_removes_only_a_planned_owned_discovery_link() {
let dir = tempdir().unwrap();
let canonical = dir.path().join("config/skills/ai-session-search");
let owned = dir.path().join("claude/skills/ai-session-search");
let modified = dir.path().join("gemini/skills/ai-session-search");
let other = dir.path().join("other-skill");
fs::create_dir_all(&canonical).unwrap();
fs::create_dir_all(&other).unwrap();
fs::create_dir_all(owned.parent().unwrap()).unwrap();
fs::create_dir_all(modified.parent().unwrap()).unwrap();
create_directory_symlink(&canonical, &owned).unwrap();
create_directory_symlink(&other, &modified).unwrap();
execute_planned_transaction_with_links(
&dir.path().join("transaction.json"),
&[],
&[SymlinkChange::remove(owned.clone(), canonical)],
|_, change| publish_symlink_change(change),
)
.unwrap();
assert!(!owned.exists());
assert!(fs::symlink_metadata(&modified)
.unwrap()
.file_type()
.is_symlink());
assert_eq!(fs::read_link(modified).unwrap(), other);
}
#[test]
fn pi_family_targets_install_native_skills_and_instructions_without_stdio_mcp() {
use clap::ValueEnum;
assert_eq!(McpClient::from_str("pi", false), Ok(McpClient::Pi));
assert_eq!(
McpClient::from_str("prime-agent", false),
Ok(McpClient::PrimeAgent)
);
let layout = ClientLayout::new(
PathBuf::from("/home/test"),
PathBuf::from("/home/test/.config"),
ClientPlatform::Linux,
);
for (client, agent_dir, binary) in [
(McpClient::Pi, ".pi/agent", "pi"),
(McpClient::PrimeAgent, ".prime/agent", "prime-agent"),
] {
assert!(
targets_for_layout(client, &layout).is_empty(),
"Pi-family harnesses cannot consume aise's local stdio MCP server natively"
);
let instructions = instruction_targets_for_layout(client, &layout);
assert_eq!(instructions.len(), 1);
assert_eq!(
instructions[0].path,
PathBuf::from(format!("/home/test/{agent_dir}/AGENTS.md"))
);
// No MCP registration means the block must not send the agent to MCP tools. The
// MCP-first sentence did exactly that on Pi and Prime Agent, and the recorded fallout
// was agents probing `aise ... --help` repeatedly before any search ran.
assert!(matches!(
instructions[0].format,
InstructionFormat::InlineCliBlock
));
let block = instruction_block(instructions[0].format);
assert!(block.contains("<!-- aise-instructions v1 -->"), "{block}");
assert!(
block.contains(
"the `aise` command and the installed skill are the whole integration"
),
"{block}"
);
assert!(
block.contains("aise list --path <dir> --when 7d --limit 10"),
"{block}"
);
assert!(block.contains("aise search \""), "{block}");
assert!(block.contains("aise messages search \""), "{block}");
assert!(
block.contains("aise messages get <session-id> --seq"),
"{block}"
);
assert!(block.contains("`ai-session-search` skill"), "{block}");
assert!(
!block.contains("call the `ai-session-search` MCP"),
"pi-family block must not lead with the MCP tools: {block}"
);
assert_eq!(instructions[0].detect_binaries, vec![binary]);
let skills = skill_targets_for_layout(client, &layout);
assert_eq!(skills.len(), 1);
assert_eq!(
skills[0].discovery_links,
vec![PathBuf::from(format!(
"/home/test/{agent_dir}/skills/ai-session-search"
))]
);
}
}
#[test]
fn instruction_targets_cover_claude_codex_gemini_antigravity_pi_prime_and_opencode() {
let targets = instruction_targets_for(McpClient::All).unwrap();
let labels = targets
.iter()
.map(|target| target.label)
.collect::<Vec<_>>();
assert!(labels.contains(&"claude") || !dirs::home_dir().unwrap().join(".claude").exists());
assert!(instruction_targets_for(McpClient::Claude)
.unwrap()
.iter()
.any(|target| target.path.ends_with("CLAUDE.md")));
assert!(instruction_targets_for(McpClient::Codex)
.unwrap()
.iter()
.any(|target| target.path.ends_with("AGENTS.md")
&& matches!(target.format, InstructionFormat::InlineBlock)));
for client in [McpClient::Gemini, McpClient::Antigravity] {
let targets = instruction_targets_for(client).unwrap();
assert_eq!(targets.len(), 1);
assert!(targets[0].path.ends_with(".gemini/GEMINI.md"));
assert!(matches!(targets[0].format, InstructionFormat::InlineBlock));
}
for (client, suffix) in [
(McpClient::Pi, ".pi/agent/AGENTS.md"),
(McpClient::PrimeAgent, ".prime/agent/AGENTS.md"),
] {
let targets = instruction_targets_for(client).unwrap();
assert_eq!(targets.len(), 1);
assert!(targets[0].path.ends_with(suffix));
assert!(matches!(
targets[0].format,
InstructionFormat::InlineCliBlock
));
}
assert!(instruction_targets_for(McpClient::Opencode)
.unwrap()
.iter()
.any(|target| target.path.ends_with("AGENTS.md")
&& matches!(target.format, InstructionFormat::InlineBlock)));
}
#[test]
fn custom_targets_cover_json_vscode_and_codex_shapes() {
let targets = custom_targets(
&[PathBuf::from("~/json.json")],
&[PathBuf::from("~/vscode.json")],
&[PathBuf::from("~/zed.json")],
&[PathBuf::from("~/opencode.json")],
&[PathBuf::from("~/codex.toml")],
)
.unwrap();
assert_eq!(targets.len(), 5);
assert!(matches!(targets[0].format, ConfigFormat::JsonMcpServers));
assert!(matches!(targets[1].format, ConfigFormat::VscodeServers));
assert!(matches!(targets[2].format, ConfigFormat::ZedContextServers));
assert!(matches!(targets[3].format, ConfigFormat::OpenCode));
assert!(matches!(targets[4].format, ConfigFormat::CodexToml));
}
#[test]
fn custom_instruction_targets_cover_claude_and_agents() {
let targets = custom_instruction_targets(
&[PathBuf::from("~/CLAUDE.md")],
&[PathBuf::from("~/GEMINI.md")],
&[PathBuf::from("~/AGENTS.md")],
)
.unwrap();
assert_eq!(targets.len(), 3);
assert_eq!(targets[0].label, "custom-claude");
assert_eq!(targets[1].label, "custom-gemini");
assert_eq!(targets[2].label, "custom-agents");
}
#[test]
fn shared_target_selection_combines_custom_paths_and_honors_instruction_opt_out() {
let json = [PathBuf::from("~/json.json")];
let vscode = [PathBuf::from("~/vscode.json")];
let zed = [PathBuf::from("~/zed.json")];
let opencode = [PathBuf::from("~/opencode.json")];
let codex = [PathBuf::from("~/codex.toml")];
let claude = [PathBuf::from("~/CLAUDE.md")];
let gemini = [PathBuf::from("~/GEMINI.md")];
let agents = [PathBuf::from("~/AGENTS.md")];
let selection = McpTargetSelection {
clients: &[McpClient::Cursor],
excluded_clients: &[],
no_instructions: false,
json_mcp_configs: &json,
vscode_configs: &vscode,
zed_configs: &zed,
opencode_configs: &opencode,
codex_configs: &codex,
claude_md_paths: &claude,
gemini_md_paths: &gemini,
agents_md_paths: &agents,
no_skill: false,
skill_roots: &[],
};
let (targets, instructions, skills) = assemble_selected_targets(selection).unwrap();
assert_eq!(targets.len(), 6);
assert_eq!(instructions.len(), 3);
assert!(skills.is_empty());
let (targets_without_instructions, instructions, skills) =
assemble_selected_targets(McpTargetSelection {
no_instructions: true,
..selection
})
.unwrap();
assert_eq!(targets_without_instructions.len(), 6);
assert!(instructions.is_empty());
assert!(skills.is_empty());
}
#[test]
fn client_selection_supports_repeated_includes_excludes_and_detected_default() {
assert_eq!(
resolve_client_selection(&[McpClient::Antigravity, McpClient::Opencode], &[]).unwrap(),
(vec![McpClient::Antigravity, McpClient::Opencode], false)
);
let (selected, detected_only) =
resolve_client_selection(&[McpClient::All], &[McpClient::Opencode]).unwrap();
assert!(detected_only);
assert!(selected.contains(&McpClient::Antigravity));
assert!(!selected.contains(&McpClient::Opencode));
assert!(
resolve_client_selection(&[McpClient::All, McpClient::Claude], &[])
.unwrap_err()
.to_string()
.contains("cannot be combined")
);
assert!(
resolve_client_selection(&[McpClient::All], &[McpClient::All])
.unwrap_err()
.to_string()
.contains("invalid")
);
}
#[test]
fn same_path_targets_dedupe_identical_formats_and_reject_conflicting_formats() {
let path = PathBuf::from("/tmp/shared-client-config");
let mut identical = vec![
test_target(path.clone(), ConfigFormat::JsonMcpServers),
test_target(path.clone(), ConfigFormat::JsonMcpServers),
];
dedupe_config_targets(&mut identical).unwrap();
assert_eq!(identical.len(), 1);
let mut conflicting = vec![
test_target(path.clone(), ConfigFormat::JsonMcpServers),
test_target(path.clone(), ConfigFormat::OpenCode),
];
let error = dedupe_config_targets(&mut conflicting).unwrap_err();
assert!(error.to_string().contains("incompatible config formats"));
assert!(error.to_string().contains(path.to_str().unwrap()));
let instruction = |format| InstructionTarget {
label: "test",
path: path.clone(),
format,
detect_paths: Vec::new(),
detect_binaries: Vec::new(),
};
let mut conflicting = vec![
instruction(InstructionFormat::ClaudeImport),
instruction(InstructionFormat::InlineBlock),
];
assert!(dedupe_instruction_targets(&mut conflicting)
.unwrap_err()
.to_string()
.contains("incompatible Markdown ownership formats"));
}
#[test]
fn all_detection_does_not_treat_home_parent_as_installed_client() {
let dir = tempdir().unwrap();
let target = Target {
label: "home-level",
path: dir.path().join(".claude.json"),
format: ConfigFormat::JsonMcpServers,
detect_paths: Vec::new(),
detect_binaries: Vec::new(),
};
assert!(!target_detected(&target));
}
#[test]
fn all_detection_uses_explicit_client_dirs() {
let dir = tempdir().unwrap();
let client_dir = dir.path().join(".claude");
fs::create_dir_all(&client_dir).unwrap();
let target = Target {
label: "detected",
path: dir.path().join(".claude.json"),
format: ConfigFormat::JsonMcpServers,
detect_paths: vec![client_dir],
detect_binaries: Vec::new(),
};
assert!(target_detected(&target));
}
#[test]
fn claude_targets_include_code_and_desktop_configs() {
let targets = targets_for(McpClient::Claude).unwrap();
assert!(targets
.iter()
.any(|target| target.label == "claude code modern"));
assert!(targets
.iter()
.any(|target| target.label == "claude code legacy"));
assert!(targets
.iter()
.any(|target| target.label == "claude desktop"));
}
#[test]
fn codex_app_cli_and_ide_share_the_user_config() {
let targets = targets_for(McpClient::Codex).unwrap();
assert_eq!(targets.len(), 1);
assert!(targets[0].path.ends_with(".codex/config.toml"));
assert!(matches!(targets[0].format, ConfigFormat::CodexToml));
assert!(targets[0]
.detect_paths
.iter()
.any(|path| path.ends_with(".codex")));
}
#[test]
fn antigravity_targets_include_shared_current_and_legacy_configs() {
let targets = targets_for(McpClient::Antigravity).unwrap();
assert!(targets.iter().any(|target| {
target.label == "antigravity app/cli"
&& target.path.ends_with(".gemini/config/mcp_config.json")
&& matches!(target.format, ConfigFormat::JsonMcpServers)
}));
assert!(targets.iter().any(|target| {
target.label == "antigravity cli"
&& target
.path
.ends_with(".gemini/antigravity-cli/settings.json")
&& matches!(target.format, ConfigFormat::JsonMcpServers)
}));
assert!(targets.iter().any(|target| {
target.label == "antigravity legacy"
&& target.path.ends_with(".gemini/antigravity/mcp_config.json")
&& matches!(target.format, ConfigFormat::JsonMcpServers)
}));
}
#[test]
fn antigravity_and_opencode_integrations_preserve_unowned_content() {
let dir = tempdir().unwrap();
let binary = Path::new("/bin/aise");
let targets = [
Target {
label: "antigravity app/cli",
path: dir.path().join("antigravity-current-mcp.json"),
format: ConfigFormat::JsonMcpServers,
detect_paths: Vec::new(),
detect_binaries: Vec::new(),
},
Target {
label: "antigravity cli",
path: dir.path().join("antigravity-settings.json"),
format: ConfigFormat::JsonMcpServers,
detect_paths: Vec::new(),
detect_binaries: Vec::new(),
},
Target {
label: "antigravity legacy",
path: dir.path().join("antigravity-mcp.json"),
format: ConfigFormat::JsonMcpServers,
detect_paths: Vec::new(),
detect_binaries: Vec::new(),
},
Target {
label: "opencode",
path: dir.path().join("opencode.json"),
format: ConfigFormat::OpenCode,
detect_paths: Vec::new(),
detect_binaries: Vec::new(),
},
];
for target in &targets {
fs::write(&target.path, r#"{"keep":{"owner":"user"}}"#).unwrap();
upsert_target(target, binary).unwrap();
let installed = fs::read(&target.path).unwrap();
assert_eq!(status_target(target).unwrap(), "configured");
upsert_target(target, binary).unwrap();
assert_eq!(fs::read(&target.path).unwrap(), installed);
assert!(remove_target(target).unwrap());
assert_eq!(status_target(target).unwrap(), "not configured");
let remaining: Value =
serde_json::from_str(&fs::read_to_string(&target.path).unwrap()).unwrap();
assert_eq!(remaining["keep"]["owner"], "user");
}
for (label, file_name) in [
("gemini/antigravity", "GEMINI.md"),
("opencode", "AGENTS.md"),
] {
let target = InstructionTarget {
label,
path: dir.path().join(file_name),
format: InstructionFormat::InlineBlock,
detect_paths: Vec::new(),
detect_binaries: Vec::new(),
};
fs::write(&target.path, "# User instructions\n").unwrap();
upsert_instruction_file(&target).unwrap();
let installed = fs::read(&target.path).unwrap();
assert_eq!(status_instruction_file(&target).unwrap(), "configured");
upsert_instruction_file(&target).unwrap();
assert_eq!(fs::read(&target.path).unwrap(), installed);
assert!(remove_instruction_file(&target).unwrap());
assert_eq!(
fs::read_to_string(&target.path).unwrap(),
"# User instructions\n"
);
}
}
fn test_target(path: PathBuf, format: ConfigFormat) -> Target {
Target {
label: "test",
path,
format,
detect_paths: Vec::new(),
detect_binaries: Vec::new(),
}
}
#[test]
fn upsert_rejects_non_utf8_regular_file_without_replacing_bytes() {
let dir = tempdir().unwrap();
let path = dir.path().join("mcp.json");
let original = [0xff, 0xfe, 0xfd];
fs::write(&path, original).unwrap();
let error = upsert_json_mcp_server(&path, json!({"command": "aise"})).unwrap_err();
assert!(error.to_string().contains("not valid UTF-8"));
assert_eq!(fs::read(&path).unwrap(), original);
}
#[test]
fn upsert_rejects_non_regular_destination_without_replacing_it() {
let dir = tempdir().unwrap();
let path = dir.path().join("mcp.json");
fs::create_dir(&path).unwrap();
let error = upsert_json_mcp_server(&path, json!({"command": "aise"})).unwrap_err();
assert!(error.to_string().contains("not a regular file"));
assert!(path.is_dir());
}
#[cfg(unix)]
#[test]
fn upsert_rejects_symbolic_link_without_replacing_target_or_link() {
use std::os::unix::fs::symlink;
let dir = tempdir().unwrap();
let target = dir.path().join("real.json");
let link = dir.path().join("mcp.json");
fs::write(&target, "{\"keep\":true}\n").unwrap();
symlink(&target, &link).unwrap();
let error = upsert_json_mcp_server(&link, json!({"command": "aise"})).unwrap_err();
assert!(error.to_string().contains("not a regular file"));
assert!(fs::symlink_metadata(&link)
.unwrap()
.file_type()
.is_symlink());
assert_eq!(fs::read_to_string(&target).unwrap(), "{\"keep\":true}\n");
}
#[test]
fn install_preflight_error_on_later_target_leaves_every_target_unchanged() {
let dir = tempdir().unwrap();
let first = dir.path().join("first.json");
let second = dir.path().join("second.json");
let first_original = "{\"keep\":true}\n";
let second_original = "{not-json\n";
fs::write(&first, first_original).unwrap();
fs::write(&second, second_original).unwrap();
let targets = vec![
test_target(first.clone(), ConfigFormat::JsonMcpServers),
test_target(second.clone(), ConfigFormat::JsonMcpServers),
];
let error = preflight_install(&targets, &[], &[], Path::new("aise"), None).unwrap_err();
assert!(error.to_string().contains("failed to parse JSON"));
assert_eq!(fs::read_to_string(first).unwrap(), first_original);
assert_eq!(fs::read_to_string(second).unwrap(), second_original);
}
#[test]
fn uninstall_preflight_error_on_later_target_leaves_every_target_unchanged() {
let dir = tempdir().unwrap();
let first = dir.path().join("first.json");
let second = dir.path().join("second.json");
let first_original = "{\"mcpServers\":{\"aise\":{\"command\":\"aise\"}},\"keep\":true}\n";
let second_original = "[\"not-an-object\"]\n";
fs::write(&first, first_original).unwrap();
fs::write(&second, second_original).unwrap();
let targets = vec![
test_target(first.clone(), ConfigFormat::JsonMcpServers),
test_target(second.clone(), ConfigFormat::JsonMcpServers),
];
let error = preflight_uninstall(&targets, &[], &[], None, false).unwrap_err();
assert!(error.to_string().contains("must contain a JSON object"));
assert_eq!(fs::read_to_string(first).unwrap(), first_original);
assert_eq!(fs::read_to_string(second).unwrap(), second_original);
}
#[test]
fn instruction_preflight_error_does_not_publish_valid_config_change() {
let dir = tempdir().unwrap();
let config = dir.path().join("mcp.json");
let instructions = dir.path().join("AGENTS.md");
let config_original = "{\"keep\":true}\n";
let instructions_original = "before\n<!-- aise-instructions v1 -->\npartial\n";
fs::write(&config, config_original).unwrap();
fs::write(&instructions, instructions_original).unwrap();
let targets = vec![test_target(config.clone(), ConfigFormat::JsonMcpServers)];
let instruction_targets = vec![InstructionTarget {
label: "test",
path: instructions.clone(),
format: InstructionFormat::InlineBlock,
detect_paths: Vec::new(),
detect_binaries: Vec::new(),
}];
let error = preflight_install(&targets, &instruction_targets, &[], Path::new("aise"), None)
.unwrap_err();
assert!(error
.to_string()
.contains("no matching `<!-- /aise-instructions -->`"));
assert_eq!(fs::read_to_string(config).unwrap(), config_original);
assert_eq!(
fs::read_to_string(instructions).unwrap(),
instructions_original
);
}
}