use super::{parse_tai_timestamp, Archive, ImportSource, ImportSummary};
use crate::archive_collection::ArchiveImportWriter;
use crate::out::Out;
use crate::{
archive_agy, archive_chatgpt, archive_claude_code, archive_claude_web, archive_codex,
archive_copilot, archive_gemini,
};
use anyhow::{anyhow, bail, Context, Result};
use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
use std::path::{Path, PathBuf};
use std::sync::Once;
use tracing_subscriber::fmt::format::FmtSpan;
use tracing_subscriber::EnvFilter;
#[derive(Clone, Copy)]
struct ArchiveStorage<'a> {
pile: &'a Path,
key: Option<&'a Path>,
}
#[derive(Parser)]
#[command(
version = crate::GIT_VERSION,
name = "archive",
about = "Import and query the canonical Archive block DAG"
)]
pub struct Cli {
#[arg(long, env = "PILE")]
pile: PathBuf,
#[arg(long, env = "TRIBLESPACE_KEY")]
key: Option<PathBuf>,
#[arg(long)]
trace: bool,
#[arg(long)]
trace_filter: Option<String>,
#[command(subcommand)]
pub(super) command: Option<Command>,
}
#[derive(Subcommand)]
pub(super) enum Command {
Import {
#[arg(required = true, num_args = 1..)]
path: Vec<PathBuf>,
#[arg(long, value_enum, default_value = "claude-code")]
source: CliImportSource,
},
List {
#[arg(long, default_value_t = 50)]
limit: usize,
},
Show { id: String },
Thread {
id: String,
#[arg(long, default_value_t = 100)]
limit: usize,
},
Search {
#[arg(help = "Query text. Use @path for file input or @- for stdin.")]
text: String,
#[arg(long, default_value_t = 50)]
limit: usize,
},
Index,
Replay {
#[arg(value_name = "ACTION")]
action: Vec<String>,
#[arg(long, default_value_t = 20)]
limit: usize,
#[arg(long)]
with_tools: bool,
#[arg(long, env = "PERSONA")]
persona: Option<String>,
},
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
pub(super) enum CliImportSource {
Agy,
#[value(name = "chatgpt")]
ChatGpt,
ClaudeCode,
ClaudeWeb,
Codex,
Copilot,
Gemini,
}
impl From<CliImportSource> for ImportSource {
fn from(value: CliImportSource) -> Self {
match value {
CliImportSource::Agy => Self::Agy,
CliImportSource::ChatGpt => Self::ChatGpt,
CliImportSource::ClaudeCode => Self::ClaudeCode,
CliImportSource::ClaudeWeb => Self::ClaudeWeb,
CliImportSource::Codex => Self::Codex,
CliImportSource::Copilot => Self::Copilot,
CliImportSource::Gemini => Self::Gemini,
}
}
}
fn init_tracing(enabled: bool, filter: Option<&str>) {
static TRACE_INIT: Once = Once::new();
if !enabled {
return;
}
TRACE_INIT.call_once(|| {
let env_filter = filter
.map(EnvFilter::new)
.or_else(|| {
std::env::var("PLAYGROUND_ARCHIVE_TRACE_FILTER")
.ok()
.map(EnvFilter::new)
})
.unwrap_or_else(|| EnvFilter::new("info"));
let _ = tracing_subscriber::fmt()
.with_target(false)
.without_time()
.with_env_filter(env_filter)
.with_span_events(FmtSpan::CLOSE)
.try_init();
});
}
fn run_import_all(
storage: ArchiveStorage<'_>,
paths: &[PathBuf],
source: CliImportSource,
out: &mut Out<'_>,
) -> Result<()> {
if paths.len() == 1 {
return run_import(storage, &paths[0], source, out);
}
let total = paths.len();
if source == CliImportSource::Codex {
let mut writer = ArchiveImportWriter::open(storage.pile, storage.key)?;
let result = (|| {
for (index, path) in paths.iter().enumerate() {
eprintln!("[{}/{total}] {}", index + 1, path.display());
run_codex_into(&mut writer, path, out)
.with_context(|| format!("import {}", path.display()))?;
}
Ok(())
})();
return writer.close(result);
}
for (index, path) in paths.iter().enumerate() {
eprintln!("[{}/{total}] {}", index + 1, path.display());
run_import(storage, path, source, out)
.with_context(|| format!("import {}", path.display()))?;
}
Ok(())
}
fn run_import(
storage: ArchiveStorage<'_>,
path: &Path,
source: CliImportSource,
out: &mut Out<'_>,
) -> Result<()> {
match source {
CliImportSource::Agy => run_agy_import(storage, path, out),
CliImportSource::ChatGpt => run_chatgpt_import(storage, path, out),
CliImportSource::ClaudeCode => run_claude_code_import(storage, path, out),
CliImportSource::ClaudeWeb => run_claude_web_import(storage, path, out),
CliImportSource::Codex => run_codex_import(storage, path, out),
CliImportSource::Copilot => run_copilot_import(storage, path, out),
CliImportSource::Gemini => run_gemini_import(storage, path, out),
}
}
fn run_agy_import(storage: ArchiveStorage<'_>, path: &Path, out: &mut Out<'_>) -> Result<()> {
let mut writer = ArchiveImportWriter::open(storage.pile, storage.key)?;
let projection = archive_agy::project_path(path, |projected| {
writer
.stage_fragment(projected.fragment)
.with_context(|| format!("stage {}", projected.source_path.display()))
});
let (summary, commit) = writer.finish(projection)?;
ImportSummary::Agy(summary).write(commit.is_some(), out)?;
Ok(())
}
fn run_chatgpt_import(storage: ArchiveStorage<'_>, path: &Path, out: &mut Out<'_>) -> Result<()> {
let mut writer = ArchiveImportWriter::open(storage.pile, storage.key)?;
let projection = archive_chatgpt::project_path(path, |projected| {
writer
.stage_fragment(projected.fragment)
.with_context(|| format!("stage {}", projected.source_path.display()))
});
let (summary, commit) = writer.finish(projection)?;
ImportSummary::ChatGpt(summary).write(commit.is_some(), out)?;
Ok(())
}
fn run_claude_code_import(
storage: ArchiveStorage<'_>,
path: &Path,
out: &mut Out<'_>,
) -> Result<()> {
let mut writer = ArchiveImportWriter::open(storage.pile, storage.key)?;
let projection = archive_claude_code::project_path(path, |projected| {
writer
.stage_fragment(projected.fragment)
.with_context(|| format!("stage {}", projected.source_path.display()))
});
let (summary, commit) = writer.finish(projection)?;
ImportSummary::ClaudeCode(summary).write(commit.is_some(), out)?;
Ok(())
}
fn run_codex_import(storage: ArchiveStorage<'_>, path: &Path, out: &mut Out<'_>) -> Result<()> {
let mut writer = ArchiveImportWriter::open(storage.pile, storage.key)?;
let result = run_codex_into(&mut writer, path, out);
writer.close(result)
}
fn run_codex_into(writer: &mut ArchiveImportWriter, path: &Path, out: &mut Out<'_>) -> Result<()> {
let projection = archive_codex::project_path(path, |projected| {
writer
.stage_fragment(projected.fragment)
.with_context(|| format!("stage {}", projected.source_path.display()))
});
let summary = projection?;
let commit = writer.commit_unit()?;
ImportSummary::Codex(summary).write(commit.is_some(), out)?;
Ok(())
}
fn run_claude_web_import(
storage: ArchiveStorage<'_>,
path: &Path,
out: &mut Out<'_>,
) -> Result<()> {
let mut writer = ArchiveImportWriter::open(storage.pile, storage.key)?;
let projection = archive_claude_web::project_path(path, |projected| {
writer
.stage_fragment(projected.fragment)
.with_context(|| format!("stage {}", projected.source_path.display()))
});
let (summary, commit) = writer.finish(projection)?;
ImportSummary::ClaudeWeb(summary).write(commit.is_some(), out)?;
Ok(())
}
fn run_copilot_import(storage: ArchiveStorage<'_>, path: &Path, out: &mut Out<'_>) -> Result<()> {
let mut writer = ArchiveImportWriter::open(storage.pile, storage.key)?;
let projection = archive_copilot::project_path(path, |projected| {
writer
.stage_fragment(projected.fragment)
.with_context(|| format!("stage {}", projected.source_path.display()))
});
let (summary, commit) = writer.finish(projection)?;
ImportSummary::Copilot(summary).write(commit.is_some(), out)?;
Ok(())
}
fn run_gemini_import(storage: ArchiveStorage<'_>, path: &Path, out: &mut Out<'_>) -> Result<()> {
let mut writer = ArchiveImportWriter::open(storage.pile, storage.key)?;
let projection = archive_gemini::project_path(path, |projected| {
writer
.stage_fragment(projected.fragment)
.with_context(|| format!("stage {}", projected.source_path.display()))
});
let (summary, commit) = writer.finish(projection)?;
ImportSummary::Gemini(summary).write(commit.is_some(), out)?;
Ok(())
}
pub(super) fn import_paths(
pile: &Path,
key: Option<&Path>,
paths: &[PathBuf],
source: CliImportSource,
out: &mut Out<'_>,
) -> Result<()> {
run_import_all(ArchiveStorage { pile, key }, paths, source, out)
}
pub fn execute(cli: Cli, out: &mut Out<'_>) -> Result<()> {
let Some(command) = cli.command else {
out.line(Cli::command().render_help().to_string())?;
return Ok(());
};
let archive = Archive::new(cli.pile.clone(), cli.key.clone());
match command {
Command::Import { path, source } => {
import_paths(&cli.pile, cli.key.as_deref(), &path, source, out)
}
Command::List { limit } => archive.list(limit, out),
Command::Show { id } => archive.show(&id, out),
Command::Thread { id, limit } => archive.thread(&id, limit, out),
Command::Search { text, limit } => {
archive.search(&crate::text_arg(&text, "search text")?, limit, out)
}
Command::Index => archive.index(out),
Command::Replay {
action,
limit,
with_tools,
persona,
} => {
let persona=persona.as_deref().ok_or_else(||anyhow!("no persona: set $PERSONA or pass --persona; replay cursors are session bookkeeping"))?;
if limit == 0 {
bail!("replay limit must be at least 1");
}
match action.first().map(String::as_str) {
Some("start") => {
if action.len() != 2 {
bail!("usage: archive replay start <YYYY-MM-DDTHH:MM:SS>");
}
let raw = &action[1];
archive.replay_start(persona, parse_tai_timestamp(raw)?)?;
out.line(format!("replay started at {raw} (persona {persona})"))
}
Some("stop") => {
if action.len() != 1 {
bail!("usage: archive replay stop");
}
archive.replay_stop(persona)?;
out.line(format!("replay stopped (persona {persona})"))
}
Some(other) => bail!("unknown replay action `{other}` (start/stop or nothing)"),
None => archive.replay(persona, limit, with_tools, out),
}
}
}
}
pub fn run() -> Result<()> {
let cli = Cli::parse();
init_tracing(cli.trace, cli.trace_filter.as_deref());
if cli.command.is_none() {
Cli::command().print_help()?;
println!();
return Ok(());
}
crate::cli::with_output("archive", |out| execute(cli, out))
}