use super::{render, SessionMessage, MessageRole, ShareError, SharedSession};
use anyhow::Result;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Default)]
pub struct ImportOptions {
pub project_path: Option<PathBuf>,
pub dest_root_override: Option<PathBuf>,
}
pub trait SessionImporter: Send + Sync {
fn name(&self) -> &str;
fn import(&self, session: &SharedSession, opts: &ImportOptions) -> Result<String, ShareError>;
}
pub fn importers() -> Vec<Box<dyn SessionImporter>> {
vec![
Box::new(ClaudeCodeImporter::default()),
Box::new(AiderImporter::default()),
Box::new(super::goose::GooseImporter::default()),
Box::new(super::codex::CodexImporter::default()),
Box::new(super::continue_dev::ContinueImporter::default()),
Box::new(super::generic_openai::GenericOpenAIImporter::default()),
Box::new(ClipboardImporter::default()),
]
}
pub fn find_importer(name: &str) -> Option<Box<dyn SessionImporter>> {
importers().into_iter().find(|i| i.name() == name)
}
pub async fn load_shared(input: &str) -> Result<SharedSession, ShareError> {
let raw = if input == "-" {
use std::io::Read;
let mut s = String::new();
std::io::stdin()
.read_to_string(&mut s)
.map_err(ShareError::Io)?;
s
} else if input.starts_with("http://") || input.starts_with("https://") {
let resp = reqwest::get(input)
.await
.map_err(|e| ShareError::Parse(format!("fetch {}: {}", input, e)))?;
let status = resp.status();
if !status.is_success() {
return Err(ShareError::Parse(format!(
"fetch {}: HTTP {}",
input, status
)));
}
resp.text()
.await
.map_err(|e| ShareError::Parse(format!("read body: {}", e)))?
} else {
std::fs::read_to_string(input).map_err(ShareError::Io)?
};
serde_json::from_str(&raw).map_err(|e| {
ShareError::Parse(format!(
"input is not valid JSON ({}). Use `share export --format json` to produce a portable file.",
e
))
})
}
#[derive(Default)]
pub struct ClaudeCodeImporter;
impl SessionImporter for ClaudeCodeImporter {
fn name(&self) -> &str {
"claude-code"
}
fn import(&self, session: &SharedSession, opts: &ImportOptions) -> Result<String, ShareError> {
let root = opts
.dest_root_override
.clone()
.or_else(|| dirs::home_dir().map(|h| h.join(".claude").join("projects")))
.ok_or_else(|| ShareError::Parse("no home directory".into()))?;
let cwd = opts
.project_path
.clone()
.or_else(|| session.project_path.clone())
.or_else(|| std::env::current_dir().ok())
.ok_or_else(|| ShareError::Parse("could not determine target cwd".into()))?;
let dir_name = encode_claude_project_dir(&cwd);
let project_dir = root.join(dir_name);
std::fs::create_dir_all(&project_dir).map_err(ShareError::Io)?;
let new_session_id = uuid::Uuid::new_v4().to_string();
let file_path = project_dir.join(format!("{}.jsonl", new_session_id));
let mut out = String::new();
let now = chrono::Utc::now().to_rfc3339();
let provenance = serde_json::json!({
"type": "user",
"message": {
"role": "user",
"content": format!(
"[i-self import] Continued from {} session {}. {} prior messages follow.",
session.provider,
session.id,
session.messages.len()
)
},
"uuid": uuid::Uuid::new_v4().to_string(),
"sessionId": new_session_id,
"timestamp": now,
"cwd": cwd.to_string_lossy(),
"imported_from": {
"provider": session.provider,
"id": session.id,
}
});
out.push_str(&provenance.to_string());
out.push('\n');
for msg in &session.messages {
let envelope = encode_envelope(msg, &cwd, &new_session_id);
out.push_str(&envelope.to_string());
out.push('\n');
}
std::fs::write(&file_path, out).map_err(ShareError::Io)?;
Ok(format!(
"Wrote {} messages to {} (session {}). Run `claude` from {} and pick this session.",
session.messages.len() + 1,
file_path.display(),
new_session_id,
cwd.display()
))
}
}
fn encode_claude_project_dir(p: &Path) -> String {
let s = p.to_string_lossy();
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
if ch.is_ascii_alphanumeric() {
out.push(ch);
} else {
out.push('-');
}
}
out
}
fn encode_envelope(msg: &SessionMessage, cwd: &Path, session_id: &str) -> serde_json::Value {
let timestamp = msg
.timestamp
.map(|t| t.to_rfc3339())
.unwrap_or_else(|| chrono::Utc::now().to_rfc3339());
let uuid = uuid::Uuid::new_v4().to_string();
match msg.role {
MessageRole::User | MessageRole::ToolResult => serde_json::json!({
"type": "user",
"message": {"role": "user", "content": msg.content},
"uuid": uuid,
"sessionId": session_id,
"timestamp": timestamp,
"cwd": cwd.to_string_lossy(),
}),
MessageRole::Assistant | MessageRole::System | MessageRole::ToolUse => {
let model = msg
.metadata
.get("model")
.cloned()
.unwrap_or_else(|| "imported".to_string());
let mut content = vec![serde_json::json!({"type": "text", "text": &msg.content})];
if msg.role == MessageRole::ToolUse {
if let Some(name) = msg.metadata.get("tool_name") {
content.insert(
0,
serde_json::json!({
"type": "imported_tool_use",
"name": name,
"summary": &msg.content,
}),
);
}
}
serde_json::json!({
"type": "assistant",
"message": {
"role": "assistant",
"model": model,
"content": content,
},
"uuid": uuid,
"sessionId": session_id,
"timestamp": timestamp,
})
}
}
}
#[derive(Default)]
pub struct AiderImporter;
impl SessionImporter for AiderImporter {
fn name(&self) -> &str {
"aider"
}
fn import(&self, session: &SharedSession, opts: &ImportOptions) -> Result<String, ShareError> {
let project = opts
.project_path
.clone()
.or_else(|| session.project_path.clone())
.or_else(|| std::env::current_dir().ok())
.ok_or_else(|| ShareError::Parse("could not determine project path".into()))?;
std::fs::create_dir_all(&project).map_err(ShareError::Io)?;
let history = project.join(".aider.chat.history.md");
let mut buf = String::new();
buf.push_str(&format!(
"\n# aider chat started at {}\n\n",
chrono::Local::now().format("%Y-%m-%d %H:%M:%S")
));
buf.push_str(&format!(
"> [i-self import] Continued from {} session {}. {} prior messages follow.\n\n",
session.provider,
session.id,
session.messages.len()
));
for msg in &session.messages {
match msg.role {
MessageRole::User | MessageRole::ToolResult => {
for line in msg.content.lines() {
buf.push_str("> ");
buf.push_str(line);
buf.push('\n');
}
buf.push('\n');
}
MessageRole::Assistant | MessageRole::System => {
buf.push_str(&msg.content);
if !msg.content.ends_with('\n') {
buf.push('\n');
}
buf.push('\n');
}
MessageRole::ToolUse => {
let name = msg
.metadata
.get("tool_name")
.map(|s| s.as_str())
.unwrap_or("tool");
buf.push_str(&format!("```{}\n{}\n```\n\n", name, msg.content));
}
}
}
use std::fs::OpenOptions;
use std::io::Write;
let mut f = OpenOptions::new()
.create(true)
.append(true)
.open(&history)
.map_err(ShareError::Io)?;
f.write_all(buf.as_bytes()).map_err(ShareError::Io)?;
Ok(format!(
"Appended {} messages to {}. Run `aider --restore-chat-history` from {} to continue.",
session.messages.len(),
history.display(),
project.display()
))
}
}
#[derive(Default)]
pub struct ClipboardImporter;
impl SessionImporter for ClipboardImporter {
fn name(&self) -> &str {
"clipboard"
}
fn import(&self, session: &SharedSession, _opts: &ImportOptions) -> Result<String, ShareError> {
let md = render::render(session, render::RenderFormat::Markdown)
.map_err(|e| ShareError::Parse(format!("render: {}", e)))?;
print!("{}", md);
Ok(format!(
"Wrote {} messages as Markdown to stdout. Pipe to `pbcopy` / `xclip -selection clipboard` / `Set-Clipboard` and paste into your agent.",
session.messages.len()
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn fixture() -> SharedSession {
SharedSession {
provider: "claude-code".into(),
id: "src-session".into(),
project_path: Some(PathBuf::from("/Users/foo/proj")),
started_at: Some(chrono::Utc::now()),
messages: vec![
SessionMessage {
role: MessageRole::User,
content: "fix the auth bug".into(),
timestamp: None,
metadata: HashMap::new(),
},
SessionMessage {
role: MessageRole::Assistant,
content: "Looking at auth.rs…".into(),
timestamp: None,
metadata: HashMap::from([("model".into(), "claude".into())]),
},
SessionMessage {
role: MessageRole::ToolUse,
content: "Read({\"path\":\"src/auth.rs\"})".into(),
timestamp: None,
metadata: HashMap::from([("tool_name".into(), "Read".into())]),
},
],
}
}
#[test]
fn claude_code_importer_writes_jsonl_with_provenance() {
let tmp = tempfile::tempdir().unwrap();
let opts = ImportOptions {
project_path: Some(PathBuf::from("/test/dir")),
dest_root_override: Some(tmp.path().to_path_buf()),
};
let importer = ClaudeCodeImporter;
importer.import(&fixture(), &opts).unwrap();
let project_dirs: Vec<_> = std::fs::read_dir(tmp.path()).unwrap().collect();
assert_eq!(project_dirs.len(), 1);
let project_dir = project_dirs.into_iter().next().unwrap().unwrap().path();
assert!(project_dir.file_name().unwrap().to_str().unwrap().starts_with('-'));
let jsonl_files: Vec<_> = std::fs::read_dir(&project_dir)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().map(|x| x == "jsonl").unwrap_or(false))
.collect();
assert_eq!(jsonl_files.len(), 1);
let content = std::fs::read_to_string(jsonl_files[0].path()).unwrap();
assert_eq!(content.lines().count(), 4);
assert!(content.contains("[i-self import]"));
assert!(content.contains("imported_from"));
assert!(content.contains("\"role\":\"user\""));
assert!(content.contains("\"role\":\"assistant\""));
}
#[test]
fn aider_importer_appends_session_to_history() {
let tmp = tempfile::tempdir().unwrap();
let opts = ImportOptions {
project_path: Some(tmp.path().to_path_buf()),
..ImportOptions::default()
};
let importer = AiderImporter;
importer.import(&fixture(), &opts).unwrap();
let history = tmp.path().join(".aider.chat.history.md");
let content = std::fs::read_to_string(&history).unwrap();
assert!(content.contains("# aider chat started at"));
assert!(content.contains("[i-self import]"));
assert!(content.contains("> fix the auth bug"));
assert!(content.contains("Looking at auth.rs"));
assert!(content.contains("```Read"));
}
#[test]
fn aider_importer_appends_rather_than_overwrites() {
let tmp = tempfile::tempdir().unwrap();
let opts = ImportOptions {
project_path: Some(tmp.path().to_path_buf()),
..ImportOptions::default()
};
std::fs::write(
tmp.path().join(".aider.chat.history.md"),
"# aider chat started at 2026-01-01 00:00:00\n\n> earlier work\n\nyes\n",
)
.unwrap();
AiderImporter.import(&fixture(), &opts).unwrap();
let content =
std::fs::read_to_string(tmp.path().join(".aider.chat.history.md")).unwrap();
assert!(content.contains("earlier work"), "preserves prior content");
assert!(content.contains("[i-self import]"), "adds new content");
}
#[test]
fn find_importer_returns_known_targets() {
assert!(find_importer("claude-code").is_some());
assert!(find_importer("aider").is_some());
assert!(find_importer("clipboard").is_some());
assert!(find_importer("nope").is_none());
}
#[tokio::test]
async fn load_shared_reads_local_json() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("session.json");
std::fs::write(&path, serde_json::to_string(&fixture()).unwrap()).unwrap();
let s = load_shared(path.to_str().unwrap()).await.unwrap();
assert_eq!(s.id, "src-session");
assert_eq!(s.messages.len(), 3);
}
#[tokio::test]
async fn load_shared_rejects_markdown() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("session.md");
std::fs::write(&path, "# header\n\nnot json").unwrap();
let err = load_shared(path.to_str().unwrap()).await.unwrap_err();
assert!(matches!(err, ShareError::Parse(_)));
}
}