feat: add main features

Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
This commit is contained in:
2026-06-06 01:27:01 +02:00
parent 55b3bee2c8
commit 307ae71857
273 changed files with 48740 additions and 0 deletions

View File

@ -0,0 +1,93 @@
//! Reference profile **catalogue** — the pre-filled, *editable* profiles offered
//! by the first-run wizard (CONTEXT §9, ARCHITECTURE §6 `ConfigureProfiles`).
//!
//! These are **data, not domain code**: the catalogue lives in the application
//! layer (a product decision about *which* AIs to suggest), built from the
//! domain's validating constructors. Nothing is imposed — the user picks, edits
//! the pre-filled commands, and may add custom profiles. The single
//! [`domain::ports::AgentRuntime`] adapter consumes whatever profiles result.
//!
//! Reference set (CONTEXT §9):
//! - **Claude Code** — `claude`, context via `CLAUDE.md` (convention file),
//! - **OpenAI Codex CLI** — `codex`, context via `AGENTS.md`,
//! - **Gemini CLI** — `gemini`, context via `GEMINI.md`,
//! - **Aider** — `aider`, context passed as an argument (`--message-file {path}`).
//!
//! The ids are **stable, deterministic UUIDs** (derived from a fixed namespace)
//! so re-deriving the catalogue yields the same id for "claude" every time,
//! making the reference profiles addressable across runs without a registry.
use domain::ids::ProfileId;
use domain::profile::{AgentProfile, ContextInjection};
/// A fixed UUID namespace used to derive stable ids for reference profiles.
/// (Random-looking but constant; only its stability matters.)
const REFERENCE_NAMESPACE: uuid::Uuid = uuid::uuid!("6f9b1d2a-7c34-4e58-9a1b-2c3d4e5f6a7b");
/// Derives a stable [`ProfileId`] for a reference profile from its slug.
#[must_use]
fn reference_id(slug: &str) -> ProfileId {
ProfileId::from_uuid(uuid::Uuid::new_v5(&REFERENCE_NAMESPACE, slug.as_bytes()))
}
/// Returns the stable id a reference profile slug maps to (exposed for tests and
/// callers that need to address a reference profile).
#[must_use]
pub fn reference_profile_id(slug: &str) -> ProfileId {
reference_id(slug)
}
/// Builds the pre-filled, editable reference profiles (CONTEXT §9).
///
/// # Panics
/// Never in practice: every literal here satisfies the domain invariants, so the
/// constructors cannot fail; the `expect`s document that.
#[must_use]
pub fn reference_profiles() -> Vec<AgentProfile> {
vec![
AgentProfile::new(
reference_id("claude"),
"Claude Code",
"claude",
Vec::new(),
ContextInjection::convention_file("CLAUDE.md")
.expect("CLAUDE.md is a valid convention target"),
Some("claude --version".to_owned()),
"{projectRoot}",
)
.expect("claude reference profile is valid"),
AgentProfile::new(
reference_id("codex"),
"OpenAI Codex CLI",
"codex",
Vec::new(),
ContextInjection::convention_file("AGENTS.md")
.expect("AGENTS.md is a valid convention target"),
Some("codex --version".to_owned()),
"{projectRoot}",
)
.expect("codex reference profile is valid"),
AgentProfile::new(
reference_id("gemini"),
"Gemini CLI",
"gemini",
Vec::new(),
ContextInjection::convention_file("GEMINI.md")
.expect("GEMINI.md is a valid convention target"),
Some("gemini --version".to_owned()),
"{projectRoot}",
)
.expect("gemini reference profile is valid"),
AgentProfile::new(
reference_id("aider"),
"Aider",
"aider",
Vec::new(),
ContextInjection::flag("--message-file {path}")
.expect("aider flag template is non-empty"),
Some("aider --version".to_owned()),
"{projectRoot}",
)
.expect("aider reference profile is valid"),
]
}

View File

@ -0,0 +1,538 @@
//! Agent lifecycle use cases (ARCHITECTURE §6, L6).
//!
//! These own the *project-agent* side (distinct from the profile side in
//! [`super::usecases`]): creating agents and their `.md` contexts under
//! `.ideai/`, listing/reading/updating them, and — the centrepiece —
//! [`LaunchAgent`], which resolves the agent's profile + context, applies the
//! profile's context-injection strategy, opens a PTY cell at the right `cwd` and
//! spawns the CLI.
//!
//! Every use case talks **only to ports** ([`AgentContextStore`], [`ProfileStore`],
//! [`AgentRuntime`], [`PtyPort`], [`FileSystem`], [`EventBus`]); none knows about
//! a concrete adapter or Tauri.
use std::sync::Arc;
use domain::ports::{
AgentContextStore, AgentRuntime, ContextInjectionPlan, EventBus, FileSystem, PreparedContext,
ProfileStore, PtyPort, RemotePath, SpawnSpec,
};
use domain::{
Agent, AgentId, AgentManifest, AgentOrigin, DomainEvent, ManifestEntry, MarkdownDoc, NodeId,
Project, ProfileId, ProjectPath, PtySize, SessionKind, SessionStatus, TerminalSession,
};
use crate::error::AppError;
use crate::terminal::TerminalSessions;
/// Directory (relative to `.ideai/`) under which agent contexts are written.
const AGENTS_SUBDIR: &str = "agents";
// ---------------------------------------------------------------------------
// CreateAgentFromScratch
// ---------------------------------------------------------------------------
/// Input for [`CreateAgentFromScratch::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateAgentInput {
/// The project that owns the agent.
pub project: Project,
/// Display name of the agent.
pub name: String,
/// Runtime profile the agent launches with.
pub profile_id: ProfileId,
/// Initial `.md` content (empty when `None`).
pub initial_content: Option<String>,
}
/// Output of [`CreateAgentFromScratch::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateAgentOutput {
/// The freshly-created agent.
pub agent: Agent,
}
/// Creates a project agent from scratch: mints an id, derives a unique `.md`
/// path, records the manifest entry, then writes the (possibly empty) context.
pub struct CreateAgentFromScratch {
contexts: Arc<dyn AgentContextStore>,
ids: Arc<dyn domain::ports::IdGenerator>,
events: Arc<dyn EventBus>,
}
impl CreateAgentFromScratch {
/// Builds the use case from its injected ports.
#[must_use]
pub fn new(
contexts: Arc<dyn AgentContextStore>,
ids: Arc<dyn domain::ports::IdGenerator>,
events: Arc<dyn EventBus>,
) -> Self {
Self {
contexts,
ids,
events,
}
}
/// Executes creation.
///
/// Ordering matters: the manifest entry is persisted **before** the context
/// is written, because [`AgentContextStore::write_context`] resolves the
/// on-disk path from the manifest.
///
/// # Errors
/// - [`AppError::Invalid`] if the name is empty or the manifest would become
/// inconsistent,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(&self, input: CreateAgentInput) -> Result<CreateAgentOutput, AppError> {
let manifest = self.contexts.load_manifest(&input.project).await?;
let id = AgentId::from_uuid(self.ids.new_uuid());
let md_path = unique_md_path(&input.name, &manifest);
let agent = Agent::new(
id,
input.name,
md_path,
input.profile_id,
AgentOrigin::Scratch,
false,
)
.map_err(|e| AppError::Invalid(e.to_string()))?;
// Append the entry and re-validate the whole manifest (unique md_paths).
let mut entries = manifest.entries;
entries.push(ManifestEntry::from_agent(&agent));
let manifest = AgentManifest::new(manifest.version, entries)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.contexts.save_manifest(&input.project, &manifest).await?;
// Now the path resolves: write the initial context.
let md = MarkdownDoc::new(input.initial_content.unwrap_or_default());
self.contexts
.write_context(&input.project, &agent.id, &md)
.await?;
self.events.publish(DomainEvent::LayoutChanged {
project_id: input.project.id,
});
Ok(CreateAgentOutput { agent })
}
}
// ---------------------------------------------------------------------------
// ListAgents
// ---------------------------------------------------------------------------
/// Input for [`ListAgents::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListAgentsInput {
/// The project whose agents to list.
pub project: Project,
}
/// Output of [`ListAgents::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListAgentsOutput {
/// The project's agents (reconstructed from the manifest).
pub agents: Vec<Agent>,
}
/// Lists a project's agents by reconstructing them from the manifest entries.
pub struct ListAgents {
contexts: Arc<dyn AgentContextStore>,
}
impl ListAgents {
/// Builds the use case from the [`AgentContextStore`] port.
#[must_use]
pub fn new(contexts: Arc<dyn AgentContextStore>) -> Self {
Self { contexts }
}
/// Loads the manifest and folds each entry back into an [`Agent`].
///
/// # Errors
/// - [`AppError::Store`] on persistence failure,
/// - [`AppError::Invalid`] if a persisted entry violates an agent invariant.
pub async fn execute(&self, input: ListAgentsInput) -> Result<ListAgentsOutput, AppError> {
let manifest = self.contexts.load_manifest(&input.project).await?;
let agents = manifest
.entries
.iter()
.map(|e| e.to_agent().map_err(|err| AppError::Invalid(err.to_string())))
.collect::<Result<Vec<_>, _>>()?;
Ok(ListAgentsOutput { agents })
}
}
// ---------------------------------------------------------------------------
// ReadAgentContext / UpdateAgentContext
// ---------------------------------------------------------------------------
/// Input for [`ReadAgentContext::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReadAgentContextInput {
/// The owning project.
pub project: Project,
/// The agent whose `.md` to read.
pub agent_id: AgentId,
}
/// Output of [`ReadAgentContext::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReadAgentContextOutput {
/// The agent's Markdown context.
pub content: MarkdownDoc,
}
/// Reads an agent's `.md` context.
pub struct ReadAgentContext {
contexts: Arc<dyn AgentContextStore>,
}
impl ReadAgentContext {
/// Builds the use case.
#[must_use]
pub fn new(contexts: Arc<dyn AgentContextStore>) -> Self {
Self { contexts }
}
/// Reads the context.
///
/// # Errors
/// - [`AppError::NotFound`] if the agent (or its `.md`) is unknown,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(
&self,
input: ReadAgentContextInput,
) -> Result<ReadAgentContextOutput, AppError> {
let content = self
.contexts
.read_context(&input.project, &input.agent_id)
.await?;
Ok(ReadAgentContextOutput { content })
}
}
/// Input for [`UpdateAgentContext::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateAgentContextInput {
/// The owning project.
pub project: Project,
/// The agent whose `.md` to overwrite.
pub agent_id: AgentId,
/// New Markdown content.
pub content: String,
}
/// Overwrites an agent's `.md` context.
pub struct UpdateAgentContext {
contexts: Arc<dyn AgentContextStore>,
}
impl UpdateAgentContext {
/// Builds the use case.
#[must_use]
pub fn new(contexts: Arc<dyn AgentContextStore>) -> Self {
Self { contexts }
}
/// Writes the new context.
///
/// # Errors
/// - [`AppError::NotFound`] if the agent is unknown,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(&self, input: UpdateAgentContextInput) -> Result<(), AppError> {
let md = MarkdownDoc::new(input.content);
self.contexts
.write_context(&input.project, &input.agent_id, &md)
.await?;
Ok(())
}
}
// ---------------------------------------------------------------------------
// DeleteAgent
// ---------------------------------------------------------------------------
/// Input for [`DeleteAgent::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteAgentInput {
/// The owning project.
pub project: Project,
/// The agent to remove.
pub agent_id: AgentId,
}
/// Removes an agent from the project manifest.
///
/// The orphaned `.md` file is left on disk: the [`FileSystem`] port exposes no
/// delete, and keeping the file is the safe default (the user may want to recover
/// the context). Re-creating an agent with the same name reuses a fresh path.
pub struct DeleteAgent {
contexts: Arc<dyn AgentContextStore>,
events: Arc<dyn EventBus>,
}
impl DeleteAgent {
/// Builds the use case.
#[must_use]
pub fn new(contexts: Arc<dyn AgentContextStore>, events: Arc<dyn EventBus>) -> Self {
Self { contexts, events }
}
/// Drops the manifest entry for the agent.
///
/// # Errors
/// - [`AppError::NotFound`] if the agent is not in the manifest,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(&self, input: DeleteAgentInput) -> Result<(), AppError> {
let manifest = self.contexts.load_manifest(&input.project).await?;
let before = manifest.entries.len();
let entries: Vec<ManifestEntry> = manifest
.entries
.into_iter()
.filter(|e| e.agent_id != input.agent_id)
.collect();
if entries.len() == before {
return Err(AppError::NotFound(format!("agent {}", input.agent_id)));
}
let manifest = AgentManifest::new(manifest.version, entries)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.contexts.save_manifest(&input.project, &manifest).await?;
self.events.publish(DomainEvent::LayoutChanged {
project_id: input.project.id,
});
Ok(())
}
}
// ---------------------------------------------------------------------------
// LaunchAgent
// ---------------------------------------------------------------------------
/// Input for [`LaunchAgent::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LaunchAgentInput {
/// The owning project.
pub project: Project,
/// The agent to launch.
pub agent_id: AgentId,
/// Initial terminal height in rows.
pub rows: u16,
/// Initial terminal width in columns.
pub cols: u16,
/// The layout leaf hosting the session (a fresh node when `None`).
pub node_id: Option<NodeId>,
}
/// Output of [`LaunchAgent::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LaunchAgentOutput {
/// The created agent terminal session.
pub session: TerminalSession,
}
/// Launches an agent: resolve profile + context, prepare the invocation, apply
/// the context-injection plan, open a PTY at the resolved `cwd`, spawn the CLI.
///
/// This is the orchestrating use case of L6 and therefore consumes several ports
/// — each only for the slice it needs (Interface Segregation): the context store
/// (agent `.md` + manifest), the profile store (resolve the runtime), the runtime
/// (build the [`SpawnSpec`]), the filesystem (materialise a `conventionFile`
/// context), and the PTY (spawn + optional stdin injection).
pub struct LaunchAgent {
contexts: Arc<dyn AgentContextStore>,
profiles: Arc<dyn ProfileStore>,
runtime: Arc<dyn AgentRuntime>,
fs: Arc<dyn FileSystem>,
pty: Arc<dyn PtyPort>,
sessions: Arc<TerminalSessions>,
events: Arc<dyn EventBus>,
}
impl LaunchAgent {
/// Builds the use case from its injected ports.
#[must_use]
#[allow(clippy::too_many_arguments)]
pub fn new(
contexts: Arc<dyn AgentContextStore>,
profiles: Arc<dyn ProfileStore>,
runtime: Arc<dyn AgentRuntime>,
fs: Arc<dyn FileSystem>,
pty: Arc<dyn PtyPort>,
sessions: Arc<TerminalSessions>,
events: Arc<dyn EventBus>,
) -> Self {
Self {
contexts,
profiles,
runtime,
fs,
pty,
sessions,
events,
}
}
/// Executes the launch.
///
/// Step order is contractually significant (and unit-tested): resolve the
/// agent + context, **`prepare_invocation`**, **apply the injection plan**
/// (write a `conventionFile` / set an env var), then **`pty.spawn`** at the
/// resolved `cwd`, and finally pipe the context on stdin for the `Stdin`
/// strategy.
///
/// # Errors
/// - [`AppError::NotFound`] if the agent or its profile is unknown,
/// - [`AppError::Invalid`] for a zero-sized terminal,
/// - [`AppError::Store`] / [`AppError::FileSystem`] / [`AppError::Process`] on
/// the respective port failures.
pub async fn execute(&self, input: LaunchAgentInput) -> Result<LaunchAgentOutput, AppError> {
let size =
PtySize::new(input.rows, input.cols).map_err(|e| AppError::Invalid(e.to_string()))?;
// 1. Resolve the agent from the manifest (name + profile + md_path).
let manifest = self.contexts.load_manifest(&input.project).await?;
let entry = manifest
.entries
.iter()
.find(|e| e.agent_id == input.agent_id)
.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
let agent = entry
.to_agent()
.map_err(|e| AppError::Invalid(e.to_string()))?;
// 2. Read its context and resolve its profile.
let content = self
.contexts
.read_context(&input.project, &agent.id)
.await?;
let profile = self
.profiles
.list()
.await?
.into_iter()
.find(|p| p.id == agent.profile_id)
.ok_or_else(|| {
AppError::NotFound(format!("profile {} for agent", agent.profile_id))
})?;
// 3. Prepare the invocation (pure): command + args + injection plan + cwd.
let prepared = PreparedContext {
content: content.clone(),
relative_path: agent.context_path.clone(),
};
let mut spec = self
.runtime
.prepare_invocation(&profile, &prepared, &input.project.root)?;
// 4. Apply the injection plan side effects *before* spawning.
self.apply_injection(&input.project, &agent.context_path, &content, &mut spec)
.await?;
// 5. Spawn the PTY at the resolved cwd; adopt its session id everywhere.
let handle = self.pty.spawn(spec.clone(), size).await?;
let session_id = handle.session_id;
// 6. For the Stdin strategy, pipe the context once the PTY is live.
if matches!(spec.context_plan, Some(ContextInjectionPlan::Stdin)) {
self.pty.write(&handle, content.as_str().as_bytes())?;
}
let node_id = input.node_id.unwrap_or_else(NodeId::new_random);
let mut session = TerminalSession::starting(
session_id,
node_id,
spec.cwd.clone(),
SessionKind::Agent {
agent_id: agent.id,
},
size,
);
session.status = SessionStatus::Running;
self.sessions.insert(handle, session.clone());
self.events.publish(DomainEvent::AgentLaunched {
agent_id: agent.id,
session_id,
});
Ok(LaunchAgentOutput { session })
}
/// Applies the context-injection plan that must happen *before* spawn:
/// materialising a `conventionFile` context (write the `.md` to `<cwd>/target`)
/// or attaching the on-disk context path to an environment variable. `Args` is
/// already folded into the spec by the runtime; `Stdin` is handled post-spawn.
async fn apply_injection(
&self,
project: &Project,
context_rel_path: &str,
content: &MarkdownDoc,
spec: &mut SpawnSpec,
) -> Result<(), AppError> {
match spec.context_plan.clone() {
Some(ContextInjectionPlan::File { target }) => {
// conventionFile spike (ARCHITECTURE §13.6): copy the context to the
// conventional file (e.g. CLAUDE.md), overwriting any existing one.
// A copy (not a symlink) is the portable choice — Windows symlinks
// need privileges and SFTP/WSL symlink semantics differ.
let path = RemotePath::new(join(&spec.cwd, &target));
self.fs.write(&path, content.as_str().as_bytes()).await?;
}
Some(ContextInjectionPlan::Env { var }) => {
// Hand the CLI the absolute path of the agent's `.md` (which lives at
// `<root>/.ideai/<context_rel_path>`) via the environment variable.
let abspath = join(&project.root, &format!(".ideai/{context_rel_path}"));
spec.env.push((var, abspath));
}
// Args were folded into spec.args by prepare_invocation; Stdin is
// applied after the PTY is live.
Some(ContextInjectionPlan::Args { .. }) | Some(ContextInjectionPlan::Stdin) | None => {}
}
Ok(())
}
}
/// Builds an absolute path string by joining a [`ProjectPath`] with a relative
/// segment using a POSIX separator.
fn join(base: &ProjectPath, rel: &str) -> String {
let b = base.as_str().trim_end_matches(['/', '\\']);
format!("{b}/{rel}")
}
/// Derives a unique, filesystem-safe `md_path` (`agents/<slug>.md`) for a new
/// agent, disambiguating against the manifest's existing paths with a numeric
/// suffix when needed. Shared with the template-driven agent creation (L7).
pub(crate) fn unique_md_path(name: &str, manifest: &AgentManifest) -> String {
let slug = slugify(name);
let base = if slug.is_empty() { "agent".to_owned() } else { slug };
let mut candidate = format!("{AGENTS_SUBDIR}/{base}.md");
let mut n = 2;
while manifest.entries.iter().any(|e| e.md_path == candidate) {
candidate = format!("{AGENTS_SUBDIR}/{base}-{n}.md");
n += 1;
}
candidate
}
/// Lowercases and slugifies a display name into a safe file stem
/// (`[a-z0-9-]`), collapsing runs of separators.
fn slugify(name: &str) -> String {
let mut out = String::with_capacity(name.len());
let mut prev_dash = false;
for ch in name.trim().chars() {
if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_lowercase());
prev_dash = false;
} else if !prev_dash {
out.push('-');
prev_dash = true;
}
}
out.trim_matches('-').to_owned()
}

View File

@ -0,0 +1,27 @@
//! Agent-profile use cases & reference catalogue (ARCHITECTURE §6, L5).
//!
//! This module owns the *profile* side of the AI runtime: detecting which CLIs
//! are installed, persisting the chosen/edited/custom profiles, and exposing the
//! pre-filled reference catalogue that seeds the first-run wizard. It talks only
//! to the [`domain::ports::AgentRuntime`] and [`domain::ports::ProfileStore`]
//! ports. Launching an agent (PTY + injection) is L6.
mod catalogue;
mod lifecycle;
mod usecases;
pub(crate) use lifecycle::unique_md_path;
pub use catalogue::{reference_profile_id, reference_profiles};
pub use lifecycle::{
CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput,
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput,
ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, UpdateAgentContext,
UpdateAgentContextInput,
};
pub use usecases::{
ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, DeleteProfile,
DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState,
FirstRunStateOutput, ListProfiles, ListProfilesOutput, ProfileAvailability, ReferenceProfiles,
ReferenceProfilesOutput, SaveProfile, SaveProfileInput, SaveProfileOutput,
};

View File

@ -0,0 +1,323 @@
//! Profile use cases (ARCHITECTURE §6, L5). Each is a single-responsibility
//! struct carrying its ports as `Arc<dyn Port>` and exposing one `execute`.
//!
//! - [`DetectProfiles`] — probe a set of candidate profiles via [`AgentRuntime`]
//! and report which CLIs are installed (first-run availability ✓/✗).
//! - [`ListProfiles`] / [`SaveProfile`] / [`DeleteProfile`] — CRUD over the
//! persisted profiles through the [`ProfileStore`].
//! - [`ConfigureProfiles`] — persist a batch of chosen/edited/custom profiles
//! (closes the first-run wizard).
//! - [`ReferenceProfiles`] — expose the pre-filled, editable catalogue.
//! - [`FirstRunState`] — tell the UI whether the first-run wizard should show
//! (no `profiles.json` yet) and hand it the reference catalogue.
use std::sync::Arc;
use domain::ports::{AgentRuntime, ProfileStore};
use domain::profile::AgentProfile;
use crate::error::AppError;
use super::catalogue::reference_profiles;
// ---------------------------------------------------------------------------
// DetectProfiles
// ---------------------------------------------------------------------------
/// Input for [`DetectProfiles::execute`]: the candidate profiles to probe.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DetectProfilesInput {
/// Profiles whose `detect` command should be run.
pub candidates: Vec<AgentProfile>,
}
/// Availability of a single candidate after detection.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProfileAvailability {
/// The probed profile.
pub profile: AgentProfile,
/// Whether its CLI was detected as installed (exit code 0).
pub available: bool,
}
/// Output of [`DetectProfiles::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DetectProfilesOutput {
/// One entry per candidate (same order), with its availability.
pub results: Vec<ProfileAvailability>,
}
/// Probes candidate profiles' detection commands and reports availability.
pub struct DetectProfiles {
runtime: Arc<dyn AgentRuntime>,
}
impl DetectProfiles {
/// Builds the use case from the [`AgentRuntime`] port. The runtime itself
/// holds the [`domain::ports::ProcessSpawner`] used for detection.
#[must_use]
pub fn new(runtime: Arc<dyn AgentRuntime>) -> Self {
Self { runtime }
}
/// Runs detection for each candidate. A detection *error* (e.g. the command
/// could not even be launched) is reported as `available: false`, not a
/// hard failure — the wizard just shows ✗ and the user can still keep the
/// profile.
///
/// # Errors
/// Currently never returns `Err` (failures degrade to `available: false`);
/// the `Result` keeps the signature uniform with the other use cases.
pub async fn execute(
&self,
input: DetectProfilesInput,
) -> Result<DetectProfilesOutput, AppError> {
let results = input
.candidates
.into_iter()
.map(|profile| {
let available = self.runtime.detect(&profile).unwrap_or(false);
ProfileAvailability { profile, available }
})
.collect();
Ok(DetectProfilesOutput { results })
}
}
// ---------------------------------------------------------------------------
// ListProfiles
// ---------------------------------------------------------------------------
/// Output of [`ListProfiles::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListProfilesOutput {
/// All configured profiles.
pub profiles: Vec<AgentProfile>,
}
/// Lists the configured profiles from the store.
pub struct ListProfiles {
store: Arc<dyn ProfileStore>,
}
impl ListProfiles {
/// Builds the use case from the [`ProfileStore`] port.
#[must_use]
pub fn new(store: Arc<dyn ProfileStore>) -> Self {
Self { store }
}
/// Lists configured profiles.
///
/// # Errors
/// [`AppError::Store`] on persistence failure.
pub async fn execute(&self) -> Result<ListProfilesOutput, AppError> {
Ok(ListProfilesOutput {
profiles: self.store.list().await?,
})
}
}
// ---------------------------------------------------------------------------
// SaveProfile
// ---------------------------------------------------------------------------
/// Input for [`SaveProfile::execute`]: the profile to upsert.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SaveProfileInput {
/// The profile to create or replace (by id).
pub profile: AgentProfile,
}
/// Output of [`SaveProfile::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SaveProfileOutput {
/// The saved profile (echoed back).
pub profile: AgentProfile,
}
/// Persists (creates or replaces) a single profile.
pub struct SaveProfile {
store: Arc<dyn ProfileStore>,
}
impl SaveProfile {
/// Builds the use case from the [`ProfileStore`] port.
#[must_use]
pub fn new(store: Arc<dyn ProfileStore>) -> Self {
Self { store }
}
/// Saves the profile.
///
/// # Errors
/// [`AppError::Store`] on persistence failure.
pub async fn execute(&self, input: SaveProfileInput) -> Result<SaveProfileOutput, AppError> {
self.store.save(&input.profile).await?;
Ok(SaveProfileOutput {
profile: input.profile,
})
}
}
// ---------------------------------------------------------------------------
// DeleteProfile
// ---------------------------------------------------------------------------
/// Input for [`DeleteProfile::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteProfileInput {
/// Id of the profile to delete.
pub id: domain::ids::ProfileId,
}
/// Deletes a profile by id.
pub struct DeleteProfile {
store: Arc<dyn ProfileStore>,
}
impl DeleteProfile {
/// Builds the use case from the [`ProfileStore`] port.
#[must_use]
pub fn new(store: Arc<dyn ProfileStore>) -> Self {
Self { store }
}
/// Deletes the profile.
///
/// # Errors
/// [`AppError::NotFound`] if the id is unknown, [`AppError::Store`] on
/// persistence failure.
pub async fn execute(&self, input: DeleteProfileInput) -> Result<(), AppError> {
self.store.delete(input.id).await?;
Ok(())
}
}
// ---------------------------------------------------------------------------
// ConfigureProfiles
// ---------------------------------------------------------------------------
/// Input for [`ConfigureProfiles::execute`]: the chosen/edited/custom profiles.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigureProfilesInput {
/// All profiles the user decided to keep (closes the first run).
pub profiles: Vec<AgentProfile>,
}
/// Output of [`ConfigureProfiles::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigureProfilesOutput {
/// The persisted profiles.
pub profiles: Vec<AgentProfile>,
}
/// Persists the batch of profiles chosen at the end of the first-run wizard.
///
/// Saving even an empty list creates `profiles.json`, which marks the first run
/// as done (so the wizard does not reappear).
pub struct ConfigureProfiles {
store: Arc<dyn ProfileStore>,
}
impl ConfigureProfiles {
/// Builds the use case from the [`ProfileStore`] port.
#[must_use]
pub fn new(store: Arc<dyn ProfileStore>) -> Self {
Self { store }
}
/// Persists each chosen profile.
///
/// # Errors
/// [`AppError::Store`] on persistence failure.
pub async fn execute(
&self,
input: ConfigureProfilesInput,
) -> Result<ConfigureProfilesOutput, AppError> {
for profile in &input.profiles {
self.store.save(profile).await?;
}
// Ensure `profiles.json` exists even when the user kept nothing, so the
// first run is recorded as complete.
if input.profiles.is_empty() {
self.store.mark_configured().await?;
}
Ok(ConfigureProfilesOutput {
profiles: input.profiles,
})
}
}
// ---------------------------------------------------------------------------
// ReferenceProfiles (catalogue accessor)
// ---------------------------------------------------------------------------
/// Output of [`ReferenceProfiles::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReferenceProfilesOutput {
/// The pre-filled, editable reference catalogue.
pub profiles: Vec<AgentProfile>,
}
/// Exposes the pre-filled reference catalogue (Claude/Codex/Gemini/Aider).
#[derive(Default)]
pub struct ReferenceProfiles;
impl ReferenceProfiles {
/// Builds the (stateless) use case.
#[must_use]
pub fn new() -> Self {
Self
}
/// Returns the reference catalogue. Infallible.
///
/// # Errors
/// Never; the `Result` keeps the call site uniform.
#[allow(clippy::unused_async)]
pub async fn execute(&self) -> Result<ReferenceProfilesOutput, AppError> {
Ok(ReferenceProfilesOutput {
profiles: reference_profiles(),
})
}
}
// ---------------------------------------------------------------------------
// FirstRunState
// ---------------------------------------------------------------------------
/// Output of [`FirstRunState::execute`]: whether to show the wizard + catalogue.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FirstRunStateOutput {
/// `true` when no `profiles.json` exists yet ⇒ show the first-run wizard.
pub is_first_run: bool,
/// The pre-filled reference catalogue to seed the wizard.
pub reference_profiles: Vec<AgentProfile>,
}
/// Reports whether the IDE is on its first run (no profiles configured yet) and
/// provides the reference catalogue to seed the wizard.
pub struct FirstRunState {
store: Arc<dyn ProfileStore>,
}
impl FirstRunState {
/// Builds the use case from the [`ProfileStore`] port.
#[must_use]
pub fn new(store: Arc<dyn ProfileStore>) -> Self {
Self { store }
}
/// Computes the first-run state.
///
/// # Errors
/// [`AppError::Store`] on persistence failure.
pub async fn execute(&self) -> Result<FirstRunStateOutput, AppError> {
let configured = self.store.is_configured().await?;
Ok(FirstRunStateOutput {
is_first_run: !configured,
reference_profiles: reference_profiles(),
})
}
}