From 732889ecdb0378c261bd92d78b187fab646aefad Mon Sep 17 00:00:00 2001 From: AtropinolTT <103040896+AtropinolTT@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:07:14 -0400 Subject: [PATCH] Add claude-import skill Import Claude Code sessions, configuration, agents, skills, hooks, and project memory into Qoder CLI. --- README.md | 37 +- claude-import/README.md | 57 ++ claude-import/SKILL.md | 111 +++ claude-import/scripts/claude_import.py | 1143 ++++++++++++++++++++++++ 4 files changed, 1347 insertions(+), 1 deletion(-) create mode 100644 claude-import/README.md create mode 100644 claude-import/SKILL.md create mode 100755 claude-import/scripts/claude_import.py diff --git a/README.md b/README.md index 0ed9fe1..998456d 100644 --- a/README.md +++ b/README.md @@ -1 +1,36 @@ -# skills \ No newline at end of file +# Qoder Skills + +Official skill repository for [Qoder CLI](https://docs.qoder.com/). + +## Available Skills + +| Skill | Description | +|-------|-------------| +| [claude-import](claude-import/) | Import Claude Code sessions, config, agents, skills, hooks, and project memory into Qoder CLI | + +## Installation + +```bash +# Clone the repo +git clone https://github.com/QoderAI/skills.git + +# Install a skill (e.g. claude-import) +cp -r skills/claude-import ~/.qoder/skills/ +``` + +Or symlink for live updates: + +```bash +ln -s $(pwd)/claude-import ~/.qoder/skills/ +``` + +## Contributing + +Submit a PR with your skill following the standard format: + +``` +skill-name/ +├── SKILL.md # Skill definition with YAML frontmatter +├── README.md # Documentation +└── scripts/ # Supporting scripts +``` diff --git a/claude-import/README.md b/claude-import/README.md new file mode 100644 index 0000000..5bdc774 --- /dev/null +++ b/claude-import/README.md @@ -0,0 +1,57 @@ +# claude-import + +Import Claude Code sessions, configuration, agents, skills, hooks, and +project memory into **Qoder CLI**. + +Sessions become first-class Qoder sessions resumeable via `qodercli --resume `. +Configuration is merged into your existing Qoder setup without overwriting. + +## Quick Start + +```bash +# Clone or copy into your skills directory +cp -r claude-import ~/.qoder/skills/ + +# Detect what's available from Claude Code +python scripts/claude_import.py --detect + +# Full migration +python scripts/claude_import.py --import-all +``` + +## Requirements + +- Python 3.8+ +- Qoder CLI installed and configured +- Optional: Claude Code (`~/.claude/` with sessions, config, or agents) + +## Commands + +| Command | Description | +|---------|-------------| +| `--detect` | Scan Claude Code and report available imports | +| `--import-all` | Full migration: config + recent sessions | +| `--import-config` | Import settings, agents, hooks, skills, instructions | +| `--import-sessions` | Import recent Claude Code sessions (last 30 days) | +| `--dry-run` | Preview changes without making any | + +## Installation + +### As a Qoder skill + +```bash +cp -r claude-import ~/.qoder/skills/ +``` + +Then activate with `/claude-import` in Qoder CLI. + +### Standalone + +```bash +python scripts/claude_import.py --detect +python scripts/claude_import.py --import-all +``` + +## License + +See [LICENSE](../LICENSE) at repo root. diff --git a/claude-import/SKILL.md b/claude-import/SKILL.md new file mode 100644 index 0000000..e9a88d8 --- /dev/null +++ b/claude-import/SKILL.md @@ -0,0 +1,111 @@ +--- +name: claude-import +description: > + Import Claude Code sessions into Qoder CLI. Use when the user wants to + bring conversation history from Claude Code into Qoder, migrate sessions, + or run the claude-import script. +argument-hint: "[--detect|--import-all|--import-config|--import-sessions|]" +user-invocable: true +version: "1.1.0" +--- + +# claude-import + +Import Claude Code sessions, configuration, agents, skills, hooks, and +project memory into Qoder CLI — following the same approach as Codex CLI's +`/import` command. Sessions become first-class Qoder sessions resumeable +via `qodercli --resume `. Configuration is merged into your existing +Qoder setup without overwriting. + +## How It Works + +The script scans `~/.claude/` and maps each item to the Qoder equivalent: + +| Claude Code item | Qoder destination | +|---|---| +| Session history (JSONL) | `~/.qoder/projects//*.jsonl` + `state.json` | +| `hooks` in `settings.json` | Merged into `~/.qoder/settings.json` | +| `enabledPlugins` in `settings.json` | Merged into `~/.qoder/settings.json` | +| `~/.claude/agents/*.md` | Copied to `~/.qoder/agents/` | +| `~/.claude/skills/` | Symlinked into `~/.qoder/skills/` | +| `CLAUDE.md` (project root) | Copied to `AGENTS.md` (same dir) | +| `memory/MEMORY.md` (per project) | Copied to Qoder project dir | + +## Usage + +Always use the script — do not attempt to convert manually. + +``` +# Step 1: Detect what's available +python scripts/claude_import.py --detect + +# Step 2: Full migration (config + recent sessions) +python scripts/claude_import.py --import-all + +# Or import config only (settings, agents, skills, hooks, instructions, memories) +python scripts/claude_import.py --import-config + +# Or import sessions only (last 30 days) +python scripts/claude_import.py --import-sessions + +# Preview changes without making any +python scripts/claude_import.py --import-config --dry-run + +# Original: list and import specific sessions +python scripts/claude_import.py --list +python scripts/claude_import.py +python scripts/claude_import.py --fork +``` + +## Config Import Details + +### Settings (hooks + plugins) +- Reads `~/.claude/settings.json` +- Merges the `hooks` section into your Qoder `settings.json` (same JSON format) +- Merges `enabledPlugins` so you keep existing Qoder plugins plus gain Claude's + +### Agents +- Copies `.md` agent files from `~/.claude/agents/` to `~/.qoder/agents/` +- Skips files that are identical (content hash check) + +### Skills +- Symlinks skill directories from `~/.claude/skills/` into `~/.qoder/skills/` +- Qoder and Claude Code share the same skill format, so no conversion needed + +### Project Instructions +- Finds `CLAUDE.md` in project workspaces and copies to `AGENTS.md` +- Skips if `AGENTS.md` already exists (does not overwrite) + +### Project Memories +- Copies `memory/MEMORY.md` from Claude projects to matching Qoder project dirs + +### Import Tracking +- Session imports are recorded in `~/.qoder/external_agent_session_imports.json` + (same format as Codex CLI's tracking file) +- Config import completion is recorded to avoid re-importing + +## Scripts + +| Script | Purpose | +|--------|---------| +| `scripts/claude_import.py` | Main import script — detect, config, sessions | + +## Session Conversion (Original) + +| Claude Code entry | Qoder entry | +|-------------------|-------------| +| `type: "user"` (non-meta) | `type: "user"` with `message.content` | +| `type: "assistant"` | `type: "assistant"` with thinking/text/tool_use blocks | +| `type: "system"` (local_command) | `type: "user"` with `tool_result` content | +| `custom-title` → session title | Used as Qoder session name | +| `mode`, `file-history-snapshot`, etc. | Skipped (metadata only) | + +## Output + +After import, sessions appear in `qodercli --list-sessions` and can be +resumed or forked like any native Qoder session. + +``` +Resume with: qodercli --resume +Fork with: qodercli --fork-session --resume +``` diff --git a/claude-import/scripts/claude_import.py b/claude-import/scripts/claude_import.py new file mode 100755 index 0000000..5599ef3 --- /dev/null +++ b/claude-import/scripts/claude_import.py @@ -0,0 +1,1143 @@ +#!/usr/bin/env python3 +"""Import Claude Code sessions, config, and memory into Qoder CLI. + +Usage: + # Detect available imports + python scripts/claude_import.py --detect + + # Import everything (config + last 30 days sessions) + python scripts/claude_import.py --import-all + + # Import config only (settings, agents, hooks, skills, plugins) + python scripts/claude_import.py --import-config + + # Import sessions only (last 30 days) + python scripts/claude_import.py --import-sessions + + # List and import specific sessions (original behavior) + python scripts/claude_import.py # list + python scripts/claude_import.py # import one + python scripts/claude_import.py --fork # import + fork + python scripts/claude_import.py # from file path +""" + +import json +import os +import shutil +import sys +import uuid +import subprocess +import hashlib +from datetime import datetime, timezone, timedelta +from pathlib import Path + + +# ── Paths ────────────────────────────────────────────────────────────────── + +QODER_DIR = Path.home() / ".qoder" +QODER_SETTINGS = QODER_DIR / "settings.json" +QODER_PROJECTS = QODER_DIR / "projects" +QODER_AGENTS = QODER_DIR / "agents" +QODER_SKILLS = QODER_DIR / "skills" +QODER_HOOKS_DIR = QODER_DIR / "hooks" + +CLAUDE_DIR = Path.home() / ".claude" +CLAUDE_SETTINGS = CLAUDE_DIR / "settings.json" +CLAUDE_HOOKS = CLAUDE_DIR / "hooks.json" +CLAUDE_PROJECTS = CLAUDE_DIR / "projects" +CLAUDE_AGENTS = CLAUDE_DIR / "agents" +CLAUDE_SKILLS = CLAUDE_DIR / "skills" + +IMPORT_TRACKER = QODER_DIR / "claude-import-state.json" +SESSION_IMPORT_LOG = QODER_DIR / "external_agent_session_imports.json" + + +# ── Helpers ───────────────────────────────────────────────────────────────── + +def load_json(path): + """Load JSON file, returning None if missing or invalid.""" + try: + with open(path) as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return None + + +def save_json(path, data): + """Save JSON to file, creating parent dirs.""" + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + + +def file_hash(path): + """SHA-256 hash of a file's contents.""" + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(65536), b""): + h.update(chunk) + return h.hexdigest() + + +def merge_settings(base, overlay): + """Deep-merge overlay dict into base dict (mutates base).""" + for k, v in overlay.items(): + if k in base and isinstance(base[k], dict) and isinstance(v, dict): + merge_settings(base[k], v) + elif k in base and isinstance(base[k], list) and isinstance(v, list): + # Merge lists by appending unique items (by dict comparison) + existing_strs = {json.dumps(item, sort_keys=True) for item in base[k]} + for item in v: + if json.dumps(item, sort_keys=True) not in existing_strs: + base[k].append(item) + else: + base[k] = v + return base + + +def print_header(label): + print() + print(f" {'─' * 50}") + print(f" {label}") + print(f" {'─' * 50}") + + +def print_item(label, status, detail=""): + icon = {"ok": "✓", "found": "●", "skip": "○", "done": "✓", "error": "✗", "new": "+", "exists": "="} + m = icon.get(status, "?") + d = f" — {detail}" if detail else "" + print(f" {m} {label}{d}") + + +# ── Detection ─────────────────────────────────────────────────────────────── + +def detect_items(): + """Scan Claude Code and report what's available for import.""" + items = {} + + # 1. Settings + claude_cfg = load_json(CLAUDE_SETTINGS) + if claude_cfg: + importable = {} + if claude_cfg.get("hooks"): + importable["hooks"] = list(claude_cfg["hooks"].keys()) + if claude_cfg.get("enabledPlugins"): + importable["plugins"] = list(claude_cfg["enabledPlugins"].keys()) + items["settings"] = { + "available": True, + "source": str(CLAUDE_SETTINGS), + "details": importable, + } + else: + items["settings"] = {"available": False} + + # 2. Hooks (standalone hooks.json) + hooks_file = load_json(CLAUDE_HOOKS) + items["hooks"] = { + "available": hooks_file is not None, + "source": str(CLAUDE_HOOKS) if hooks_file else None, + } + + # 3. Agents + if CLAUDE_AGENTS.is_dir(): + agent_files = sorted(CLAUDE_AGENTS.glob("*.md")) + items["agents"] = { + "available": len(agent_files) > 0, + "count": len(agent_files), + "files": [f.name for f in agent_files], + } + else: + items["agents"] = {"available": False} + + # 4. Skills + if CLAUDE_SKILLS.is_dir(): + skill_items = [p.name for p in CLAUDE_SKILLS.iterdir() if p.is_dir() or p.is_symlink()] + items["skills"] = { + "available": len(skill_items) > 0, + "count": len(skill_items), + "names": skill_items, + } + else: + items["skills"] = {"available": False} + + # 5. Instructions (CLAUDE.md per project) + instructions = [] + for proj_dir in sorted(CLAUDE_PROJECTS.iterdir()): + if not proj_dir.is_dir(): + continue + # Project name derives from the directory name + project_name = proj_dir.name.lstrip("-") + # Check common CLAUDE.md locations + for candidate in [ + proj_dir.parent.parent / "CLAUDE.md", # guess from project slug + Path.home() / project_name / "CLAUDE.md", + ]: + if candidate.exists(): + instructions.append({"project": project_name, "path": str(candidate)}) + break + # Also check cwd + cwd_claude = Path.cwd() / "CLAUDE.md" + if cwd_claude.exists(): + if not any(i["path"] == str(cwd_claude) for i in instructions): + instructions.append({"project": Path.cwd().name, "path": str(cwd_claude)}) + + items["instructions"] = { + "available": len(instructions) > 0, + "count": len(instructions), + "files": instructions, + } + + # 6. Sessions + session_count = 0 + session_projects = 0 + if CLAUDE_PROJECTS.is_dir(): + for proj_dir in CLAUDE_PROJECTS.iterdir(): + if not proj_dir.is_dir(): + continue + files = list(proj_dir.glob("*.jsonl")) + if files: + session_projects += 1 + session_count += len(files) + + items["sessions"] = { + "available": session_count > 0, + "count": session_count, + "projects": session_projects, + "note": "last 30 days by default" if session_count > 0 else "", + } + + # 7. Memories (per-project MEMORY.md files) + memory_count = 0 + if CLAUDE_PROJECTS.is_dir(): + for proj_dir in CLAUDE_PROJECTS.iterdir(): + memory_file = proj_dir / "memory" / "MEMORY.md" + if memory_file.exists(): + memory_count += 1 + items["memories"] = { + "available": memory_count > 0, + "count": memory_count, + } + + return items + + +def print_detection(items): + """Pretty-print detection results.""" + print() + print(f" {'=' * 54}") + print(f" Claude Code Import Detection") + print(f" {'=' * 54}") + print(f" Scanning: {CLAUDE_DIR}") + print() + + categories = [ + ("instructions", "Project Instructions (CLAUDE.md)"), + ("settings", "Global Settings (settings.json)"), + ("agents", "Custom Agents"), + ("hooks", "Hooks"), + ("skills", "Skills"), + ("plugins", "Plugins / MCP Servers"), + ("memories", "Project Memories"), + ("sessions", "Recent Chat Sessions"), + ] + + for key, label in categories: + item = items.get(key, {}) + if not item.get("available"): + print(f" ○ {label} — nothing found") + continue + + detail = "" + if key == "instructions": + detail = f"{item['count']} file(s)" + elif key == "settings": + parts = item.get("details", {}) + detail_parts = [] + if parts.get("hooks"): + detail_parts.append(f"{len(parts['hooks'])} hook event(s)") + if parts.get("plugins"): + detail_parts.append(f"{len(parts['plugins'])} plugin(s)") + detail = ", ".join(detail_parts) if detail_parts else "available" + elif key == "agents": + detail = f"{item['count']} agent(s): {', '.join(item['files'])}" + elif key == "hooks": + detail = "standalone hooks.json" + elif key == "skills": + detail = f"{item['count']} skill(s): {', '.join(item['names'][:5])}" + if item['count'] > 5: + detail += f" +{item['count'] - 5} more" + elif key == "sessions": + detail = f"{item['count']} session(s) across {item['projects']} project(s)" + elif key == "memories": + detail = f"{item['count']} project(s) with memory files" + + print(f" ● {label}") + print(f" {detail}") + + print() + print(f" Run with --import-all to import everything") + print(f" Or use --import-config / --import-sessions for individual steps") + print(f" {'=' * 54}") + print() + + +# ── Config Import ─────────────────────────────────────────────────────────── + +def import_settings(dry_run=False): + """Merge Claude Code settings into Qoder settings. + + Migrates: hooks, enabledPlugins from Claude settings.json -> Qoder settings.json. + """ + claude_cfg = load_json(CLAUDE_SETTINGS) + if not claude_cfg: + print_item("Global Settings (settings.json)", "skip", "Claude settings.json not found") + return {"status": "skipped", "reason": "source not found"} + + qoder_cfg = load_json(QODER_SETTINGS) or {} + + changes = {} + + # Migrate hooks + if "hooks" in claude_cfg: + qoder_hooks = qoder_cfg.get("hooks", {}) + claude_hooks = claude_cfg["hooks"] + # Merge event by event + for event_name, event_groups in claude_hooks.items(): + if event_name not in qoder_hooks: + qoder_hooks[event_name] = [] + # Extend with unique groups + existing = [json.dumps(g, sort_keys=True) for g in qoder_hooks[event_name]] + for group in event_groups: + if json.dumps(group, sort_keys=True) not in existing: + qoder_hooks[event_name].append(group) + if qoder_hooks: + changes["hooks"] = qoder_hooks + + # Migrate enabledPlugins (merge, don't overwrite) + if "enabledPlugins" in claude_cfg: + qoder_plugins = qoder_cfg.get("enabledPlugins", {}) + claude_plugins = claude_cfg["enabledPlugins"] + merged = {**qoder_plugins} + for plugin_name, enabled in claude_plugins.items(): + if plugin_name not in merged: + merged[plugin_name] = enabled + if merged != qoder_plugins: + changes["enabledPlugins"] = merged + + if not changes: + print_item("Global Settings", "skip", "nothing new to import") + return {"status": "skipped", "reason": "no new items"} + + if dry_run: + print_item("Global Settings", "found", f"would merge: {', '.join(changes.keys())}") + return {"status": "would_merge", "changes": list(changes.keys())} + + # Apply changes + if "hooks" in changes: + qoder_cfg["hooks"] = changes["hooks"] + if "enabledPlugins" in changes: + qoder_cfg["enabledPlugins"] = changes["enabledPlugins"] + + save_json(QODER_SETTINGS, qoder_cfg) + print_item("Global Settings", "done", f"merged hooks + {len(changes.get('enabledPlugins', {}))} plugin(s)") + return {"status": "imported", "changes": list(changes.keys())} + + +def import_hooks(dry_run=False): + """Import standalone hooks.json into Qoder settings. + + Note: Claude Code stores hooks in both settings.json and hooks.json. + Prefer settings.json import; this handles the standalone file case. + """ + hooks = load_json(CLAUDE_HOOKS) + if not hooks: + print_item("Hooks", "skip", "hooks.json not found") + return {"status": "skipped"} + + qoder_cfg = load_json(QODER_SETTINGS) or {} + existing_hooks = qoder_cfg.get("hooks", {}) + + added = 0 + for event_name, event_groups in hooks.items(): + if event_name not in existing_hooks: + existing_hooks[event_name] = [] + seen = {json.dumps(g, sort_keys=True) for g in existing_hooks[event_name]} + for group in event_groups: + key = json.dumps(group, sort_keys=True) + if key not in seen: + existing_hooks[event_name].append(group) + seen.add(key) + added += 1 + + if added == 0: + print_item("Hooks", "skip", "nothing new") + return {"status": "skipped"} + + if dry_run: + print_item("Hooks", "found", f"would add {added} hook group(s)") + return {"status": "would_merge", "count": added} + + qoder_cfg["hooks"] = existing_hooks + save_json(QODER_SETTINGS, qoder_cfg) + print_item("Hooks", "done", f"added {added} hook group(s)") + return {"status": "imported", "count": added} + + +def import_agents(dry_run=False): + """Copy Claude Code agents to Qoder agents directory.""" + if not CLAUDE_AGENTS.is_dir(): + print_item("Custom Agents", "skip", "agents directory not found") + return {"status": "skipped"} + + agent_files = sorted(CLAUDE_AGENTS.glob("*.md")) + if not agent_files: + print_item("Custom Agents", "skip", "no agent files found") + return {"status": "skipped"} + + QODER_AGENTS.mkdir(parents=True, exist_ok=True) + imported = [] + skipped = [] + + for src in agent_files: + dst = QODER_AGENTS / src.name + if dst.exists(): + # Compare content to decide skip vs update + if src.read_bytes() == dst.read_bytes(): + skipped.append(src.name) + continue + if dry_run: + imported.append(src.name) + continue + shutil.copy2(src, dst) + imported.append(src.name) + + if dry_run: + print_item("Custom Agents", "found", f"would import {len(imported)} agent(s)") + return {"status": "would_import", "agents": imported} + + if imported: + print_item("Custom Agents", "done", f"imported {len(imported)} agent(s): {', '.join(imported)}") + if skipped: + print_item("Custom Agents", "exists", f"{len(skipped)} unchanged, skipped") + return {"status": "imported", "imported": imported, "skipped": skipped} + + +def import_skills(dry_run=False): + """Symlink Claude Code skills to Qoder skills directory.""" + if not CLAUDE_SKILLS.is_dir(): + print_item("Skills", "skip", "skills directory not found") + return {"status": "skipped"} + + skill_dirs = sorted([p for p in CLAUDE_SKILLS.iterdir() if p.is_dir() or p.is_symlink()]) + if not skill_dirs: + print_item("Skills", "skip", "no skills found") + return {"status": "skipped"} + + QODER_SKILLS.mkdir(parents=True, exist_ok=True) + linked = [] + skipped = [] + + for src in skill_dirs: + dst = QODER_SKILLS / src.name + if dst.exists() or dst.is_symlink(): + if dst.resolve() == src.resolve(): + skipped.append(src.name) + continue + if dry_run: + linked.append(src.name) + continue + # Remove stale link/dir if exists + if dst.exists() or dst.is_symlink(): + if dst.is_symlink() or dst.is_dir(): + dst.unlink() if dst.is_symlink() else dst.rmdir() + try: + dst.symlink_to(src, target_is_directory=True) + linked.append(src.name) + except OSError as e: + print_item(f"Skills/{src.name}", "error", str(e)) + + if dry_run: + print_item("Skills", "found", f"would link {len(linked)} skill(s)") + return {"status": "would_link", "skills": linked} + + if linked: + print_item("Skills", "done", f"linked {len(linked)} skill(s): {', '.join(linked)}") + if skipped: + print_item("Skills", "exists", f"{len(skipped)} already linked") + return {"status": "imported", "linked": linked, "skipped": skipped} + + +def import_instructions(dry_run=False): + """Import CLAUDE.md as AGENTS.md for matching projects. + + Scans Claude project directories and looks for CLAUDE.md in the + corresponding workspace directory. + """ + if not CLAUDE_PROJECTS.is_dir(): + print_item("Project Instructions", "skip", "no Claude projects found") + return {"status": "skipped"} + + imported = [] + skipped = [] + + for proj_dir in sorted(CLAUDE_PROJECTS.iterdir()): + if not proj_dir.is_dir(): + continue + # Derive project path from slug (e.g. -home-user-project) + slug = proj_dir.name + parts = slug.strip("-").split("-") + # Build plausible paths to look for CLAUDE.md + candidates = [] + for i in range(1, len(parts) + 1): + candidate = Path("/") / "/".join(parts[:i]) + candidates.append(candidate) + + found = None + for cand in candidates: + claude_md = cand / "CLAUDE.md" + if claude_md.exists(): + found = claude_md + break + + if not found: + continue + + agents_md = found.parent / "AGENTS.md" + if agents_md.exists(): + # If AGENTS.md already exists and differs, skip (don't overwrite) + skipped.append(str(found.parent.name)) + continue + + if dry_run: + imported.append(str(claude_md) + " → " + str(agents_md)) + continue + + shutil.copy2(found, agents_md) + imported.append(str(agents_md)) + + if dry_run: + if imported: + print_item("Project Instructions", "found", f"would copy {len(imported)} file(s)") + return {"status": "would_import", "files": imported} + else: + print_item("Project Instructions", "skip", "nothing to import") + return {"status": "skipped"} + + if imported: + print_item("Project Instructions", "done", f"imported {len(imported)}: {', '.join(imported)}") + if skipped: + print_item("Project Instructions", "exists", f"{len(skipped)} already have AGENTS.md") + if not imported and not skipped: + print_item("Project Instructions", "skip", "no CLAUDE.md found in workspace dirs") + return {"status": "imported", "imported": imported, "skipped": skipped} + + +def import_memories(dry_run=False): + """Import per-project memories from Claude Code. + + Copies MEMORY.md from each project's memory/ directory into + a Qoder-equivalent location (e.g., .qoder/projects//memory/). + """ + if not CLAUDE_PROJECTS.is_dir(): + print_item("Project Memories", "skip", "no Claude projects") + return {"status": "skipped"} + + imported = [] + for proj_dir in sorted(CLAUDE_PROJECTS.iterdir()): + memory_file = proj_dir / "memory" / "MEMORY.md" + if not memory_file.exists(): + continue + + # Find matching Qoder project directory + qoder_proj = QODER_PROJECTS / proj_dir.name + if not qoder_proj.exists(): + continue + + memory_dst = qoder_proj / "memory" / "MEMORY.md" + if memory_dst.exists(): + continue + + if dry_run: + imported.append(str(memory_file)) + continue + + memory_dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(memory_file, memory_dst) + imported.append(str(memory_dst)) + + if dry_run: + if imported: + print_item("Project Memories", "found", f"would import {len(imported)} memory file(s)") + return {"status": "would_import", "count": len(imported)} + else: + print_item("Project Memories", "skip", "nothing to import") + return {"status": "skipped"} + + if imported: + print_item("Project Memories", "done", f"imported {len(imported)} memory file(s)") + else: + print_item("Project Memories", "skip", "nothing new to import") + return {"status": "imported", "count": len(imported)} + + +# ── Session Import ────────────────────────────────────────────────────────── + +def get_qoder_project_dir(): + """Get the current Qoder project directory based on cwd.""" + cwd = os.getcwd() + project_slug = "-" + cwd.lstrip("/").replace("/", "-") + for p in QODER_PROJECTS.iterdir(): + if p.name.endswith(".jsonl") or p.is_file(): + continue + if p.name == project_slug or project_slug.endswith(p.name) or p.name.endswith(project_slug): + return p + # Fallback: try matching by common path segments + for p in QODER_PROJECTS.iterdir(): + if p.name.endswith(".jsonl") or p.is_file(): + continue + cwd_parts = set(cwd.rstrip("/").split("/")) + slug_parts = set(p.name.strip("-").split("-")) + if cwd_parts & slug_parts: + return p + # Last resort: create + candidate = QODER_PROJECTS / project_slug + candidate.mkdir(parents=True, exist_ok=True) + return candidate + + +def get_qoder_project_dir_for_session(session_path): + """Map a Claude session file path to its original Qoder project directory. + + Claude session files live under ~/.claude/projects//.jsonl. + Extracts the project name and maps it to ~/.qoder/projects//. + Falls back to get_qoder_project_dir() if the path is not under CLAUDE_PROJECTS. + """ + session_file = Path(session_path).resolve() + claude_proj_dir = session_file.parent + if str(claude_proj_dir.parent.resolve()) == str(CLAUDE_PROJECTS.resolve()): + project_name = claude_proj_dir.name + qoder_proj = QODER_PROJECTS / project_name + qoder_proj.mkdir(parents=True, exist_ok=True) + return qoder_proj + return get_qoder_project_dir() + + +def find_claude_sessions(): + """List all available Claude Code sessions.""" + sessions = [] + for proj_dir in sorted(CLAUDE_PROJECTS.iterdir()): + if not proj_dir.is_dir(): + continue + for f in sorted(proj_dir.glob("*.jsonl"), key=os.path.getmtime, reverse=True): + session_id = f.stem + try: + with open(f) as fh: + first = json.loads(fh.readline()) + title = first.get("customTitle", session_id[:8]) + except (json.JSONDecodeError, StopIteration): + title = session_id[:8] + msg_count = 0 + try: + with open(f) as fh: + for line in fh: + try: + entry = json.loads(line) + if entry.get("type") == "user" and not entry.get("isMeta"): + msg_count += 1 + except json.JSONDecodeError: + pass + except Exception: + pass + sessions.append({ + "id": session_id, + "title": title, + "messages": msg_count, + "path": str(f), + "project": proj_dir.name, + "modified": os.path.getmtime(f), + }) + return sessions + + +def find_recent_claude_sessions(days=30): + """Find Claude Code sessions modified within the last N days.""" + cutoff = datetime.now().timestamp() - (days * 86400) + all_sessions = find_claude_sessions() + return [s for s in all_sessions if s["modified"] >= cutoff] + + +def import_sessions(session_paths, fork=False, resume=False, quiet=False): + """Import a list of Claude session paths into Qoder format.""" + if not quiet: + print_header(f"Importing {len(session_paths)} session(s)") + + results = [] + for path in session_paths: + if not quiet: + print_item(f"Session: {Path(path).stem[:16]}...", "found") + session_id, title, entries = convert_claude_to_qoder(path) + project_dir = get_qoder_project_dir_for_session(path) + jsonl_path = write_qoder_session(project_dir, session_id, title, entries) + results.append({ + "session_id": session_id, + "title": title, + "path": str(jsonl_path), + "entries": len(entries), + }) + # Track in import log (like Codex's external_agent_session_imports.json) + _track_session_import(path, session_id) + + if not quiet: + for r in results: + print_item(f" → {r['title'][:40]:40s}", "done", f"id={r['session_id']}") + + # Fork or resume last session if requested + if results: + last = results[-1] + if fork: + subprocess.run(["qodercli", "--fork-session", "--resume", last["session_id"]], check=False) + elif resume: + subprocess.run(["qodercli", "--resume", last["session_id"]], check=False) + + return results + + +def _track_session_import(source_path, imported_thread_id): + """Track session import like Codex's external_agent_session_imports.json.""" + records = load_json(SESSION_IMPORT_LOG) or {"records": []} + # Check if already tracked + for rec in records.get("records", []): + if rec.get("source_path") == source_path: + return # already tracked + records.setdefault("records", []).append({ + "source_path": source_path, + "content_sha256": file_hash(source_path), + "imported_thread_id": imported_thread_id, + "imported_at": int(datetime.now().timestamp()), + "source_modified_at": int(os.path.getmtime(source_path) * 1_000_000_000), + }) + save_json(SESSION_IMPORT_LOG, records) + + +def convert_claude_to_qoder(claude_path): + """Convert a Claude Code session JSONL to Qoder format entries.""" + qoder_entries = [] + session_id = str(uuid.uuid4()) + parent_uuid = None + assistant_uuids = [] + + with open(claude_path) as f: + lines = f.readlines() + + # Extract title + title = "Imported Session" + for line in lines: + try: + entry = json.loads(line) + if entry.get("type") == "custom-title": + ct = entry.get("customTitle", "") + if ct: + title = ct + break + except json.JSONDecodeError: + pass + + now = datetime.now(timezone.utc) + ts = int(now.timestamp() * 1000) + qoder_entries.append({ + "type": "runtime-config", + "sessionId": session_id, + "model": "claude-imported", + "reasoningEffort": None, + "contextWindow": None, + "timestamp": ts, + }) + + for line in lines: + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + + entry_type = entry.get("type") + + if entry_type == "user": + msg = entry.get("message", {}) + content = msg.get("content", "") + is_meta = entry.get("isMeta", False) + if is_meta: + continue + + user_uuid = f"user:{session_id}####import-{uuid.uuid4().hex[:8]}" + ts_str = entry.get("timestamp", now.isoformat()) + + user_entry = { + "type": "user", + "uuid": user_uuid, + "timestamp": ts_str, + "message": {"role": "user", "content": content}, + "permissionMode": "default", + "origin": {"kind": "human"}, + "promptId": entry.get("promptId", str(uuid.uuid4())), + "parentUuid": parent_uuid, + "isSidechain": False, + "cwd": entry.get("cwd", os.getcwd()), + "sessionId": session_id, + "userType": "external", + "entrypoint": "cli", + "version": "1.0.44", + } + + if isinstance(content, list): + user_entry["message"]["content"] = content + + qoder_entries.append(user_entry) + parent_uuid = user_uuid + + elif entry_type == "assistant": + msg = entry.get("message", {}) + content_blocks = msg.get("content", []) + if not content_blocks: + continue + + assistant_uuid = str(uuid.uuid4()) + assistant_uuids.append(assistant_uuid) + ts_str = entry.get("timestamp", now.isoformat()) + + processed_blocks = [] + for block in content_blocks: + if isinstance(block, dict): + btype = block.get("type", "") + if btype == "thinking": + processed_blocks.append({ + "type": "thinking", + "thinking": block.get("thinking", ""), + "signature": block.get("signature", ""), + }) + elif btype == "text": + processed_blocks.append({ + "type": "text", + "text": block.get("text", ""), + }) + elif btype == "tool_use": + processed_blocks.append({ + "type": "tool_use", + "id": block.get("id", block.get("tool_use_id", f"call_import_{uuid.uuid4().hex[:8]}")), + "name": block.get("name", ""), + "input": block.get("input", {}), + }) + else: + processed_blocks.append(block) + else: + processed_blocks.append({"type": "text", "text": str(block)}) + + stop_reason = msg.get("stop_reason") + if stop_reason is None: + has_tool = any(b.get("type") == "tool_use" for b in processed_blocks if isinstance(b, dict)) + stop_reason = "tool_use" if has_tool else "end_turn" + + msg_id = str(uuid.uuid4()) + assistant_entry = { + "type": "assistant", + "uuid": assistant_uuid, + "timestamp": ts_str, + "message": { + "id": msg_id, + "type": "message", + "role": "assistant", + "model": "claude-imported", + "stop_reason": stop_reason, + "stop_sequence": None, + "content": processed_blocks, + }, + "parentUuid": parent_uuid, + "isSidechain": False, + "cwd": entry.get("cwd", os.getcwd()), + "sessionId": session_id, + "userType": "external", + "entrypoint": "cli", + "version": "1.0.44", + } + + qoder_entries.append(assistant_entry) + parent_uuid = assistant_uuid + + elif entry_type == "system": + subtype = entry.get("subtype", "") + if subtype == "local_command": + content_str = entry.get("content", "") + source_uuid = assistant_uuids[-1] if assistant_uuids else None + if source_uuid: + system_uuid = str(uuid.uuid4()) + ts_str = entry.get("timestamp", now.isoformat()) + qoder_entries.append({ + "type": "user", + "uuid": system_uuid, + "timestamp": ts_str, + "message": { + "role": "user", + "content": [{ + "type": "tool_result", + "tool_use_id": f"call_import_{uuid.uuid4().hex[:8]}", + "content": content_str, + "is_error": False, + }], + }, + "sourceToolAssistantUUID": source_uuid, + "promptId": str(uuid.uuid4()), + "parentUuid": parent_uuid, + "isSidechain": False, + "cwd": entry.get("cwd", os.getcwd()), + "sessionId": session_id, + "userType": "external", + "entrypoint": "cli", + "version": "1.0.44", + }) + parent_uuid = system_uuid + + qoder_entries.append({ + "type": "last-prompt", + "sessionId": session_id, + "lastPrompt": title, + }) + qoder_entries.append({ + "type": "runtime-config", + "sessionId": session_id, + "model": "claude-imported", + "reasoningEffort": None, + "contextWindow": None, + "timestamp": int(datetime.now(timezone.utc).timestamp() * 1000), + }) + + return session_id, title, qoder_entries + + +def write_qoder_session(project_dir, session_id, title, entries): + """Write converted entries as a Qoder session.""" + jsonl_path = project_dir / f"{session_id}.jsonl" + with open(jsonl_path, "w") as f: + for entry in entries: + f.write(json.dumps(entry, ensure_ascii=False) + "\n") + + state_dir = project_dir / session_id + state_dir.mkdir(exist_ok=True) + state = { + "sessionId": session_id, + "revision": 1, + "createdAt": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.") + "000Z", + "updatedAt": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.") + "000Z", + "data": {}, + "items": {}, + "workspaceDirectories": [os.getcwd()], + } + with open(state_dir / "state.json", "w") as f: + json.dump(state, f, indent=2) + return jsonl_path + + +def resolve_session(session_str): + """Resolve session ID, index, or file path to a file path.""" + if os.path.isfile(session_str): + return session_str + + if session_str.isdigit(): + sessions = find_claude_sessions() + idx = int(session_str) - 1 + if 0 <= idx < len(sessions): + return sessions[idx]["path"] + print(f"Index out of range: {session_str} (1-{len(sessions)})") + sys.exit(1) + + candidates = [] + for proj_dir in CLAUDE_PROJECTS.iterdir(): + for f in proj_dir.glob(f"{session_str}*.jsonl"): + candidates.append(f) + if len(candidates) == 1: + return str(candidates[0]) + if len(candidates) > 1: + print(f"Multiple sessions match '{session_str}':") + for c in candidates: + print(f" {c.stem}") + sys.exit(1) + + for proj_dir in CLAUDE_PROJECTS.iterdir(): + candidate = proj_dir / f"{session_str}.jsonl" + if candidate.exists(): + return str(candidate) + + print(f"Session not found: {session_str}") + sessions = find_claude_sessions() + for s in sessions[:10]: + print(f" {s['id']} ({s['title']})") + if len(sessions) > 10: + print(f" ... and {len(sessions) - 10} more") + sys.exit(1) + + +# ── Main ──────────────────────────────────────────────────────────────────── + + +def cmd_detect(): + """Run detection and print results.""" + items = detect_items() + print_detection(items) + return items + + +def cmd_import_config(dry_run=False): + """Import all config items from Claude Code to Qoder.""" + print_header("Importing Claude Code Config → Qoder CLI") + + results = {} + results["settings"] = import_settings(dry_run=dry_run) + results["hooks"] = import_hooks(dry_run=dry_run) + results["agents"] = import_agents(dry_run=dry_run) + results["skills"] = import_skills(dry_run=dry_run) + results["instructions"] = import_instructions(dry_run=dry_run) + results["memories"] = import_memories(dry_run=dry_run) + + action_taken = [k for k, v in results.items() if v.get("status") in ( + "imported", "would_merge", "would_import", "would_link", "done", + )] + skipped = [k for k, v in results.items() if v.get("status") == "skipped"] + + print() + print(f" {'─' * 50}") + if dry_run: + print(f" Would import: {', '.join(action_taken)}" if action_taken else " Nothing to import.") + else: + print(f" Imported: {', '.join(action_taken)}" if action_taken else " Nothing new to import.") + if skipped: + print(f" Skipped: {', '.join(skipped)}") + print() + + return results + + +def cmd_import_sessions(days=30, fork=False, resume=False): + """Import recent Claude Code sessions.""" + sessions = find_recent_claude_sessions(days=days) + + if not sessions: + print(f"No Claude Code sessions found in the last {days} days.") + print(f"Looked in: {CLAUDE_PROJECTS}") + return [] + + print(f"Found {len(sessions)} session(s) from the last {days} days.") + paths = [s["path"] for s in sessions] + return import_sessions(paths, fork=fork, resume=resume) + + +def cmd_import_single(session_str, fork=False, resume=False): + """Import a single session by ID/index/path.""" + session_path = resolve_session(session_str) + print(f"Importing: {session_path}") + return import_sessions([session_path], fork=fork, resume=resume) + + +def cmd_list_sessions(): + """List available Claude Code sessions.""" + sessions = find_claude_sessions() + if not sessions: + print("No Claude Code sessions found.") + print(f"Looked in: {CLAUDE_PROJECTS}") + return + + print(f"Found {len(sessions)} Claude Code session(s):") + print() + for i, s in enumerate(sessions, 1): + age = datetime.fromtimestamp(s["modified"]).strftime("%Y-%m-%d %H:%M") + print(f" [{i}] {s['id']}") + print(f" Title: {s['title']}") + print(f" Messages: {s['messages']}") + print(f" Project: {s['project']}") + print(f" Modified: {age}") + print() + + print(f"Import with: python scripts/claude_import.py ") + print(f"Import all recent with: python scripts/claude_import.py --import-sessions") + print(f"Full migration: python scripts/claude_import.py --import-all") + + +def main(): + import argparse + + parser = argparse.ArgumentParser( + description="Import from Claude Code into Qoder CLI (sessions + config)", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + "Examples:\n" + " %(prog)s --detect # scan Claude Code for importable items\n" + " %(prog)s --import-all # full migration: config + recent sessions\n" + " %(prog)s --import-config # settings, agents, hooks, skills, instructions\n" + " %(prog)s --import-sessions # last 30 days of sessions\n" + " %(prog)s 87 # import session #87 (original behavior)\n" + " %(prog)s 87 --fork # import session #87 and fork immediately\n" + ) + ) + + # Mode flags + parser.add_argument("--detect", action="store_true", help="Scan Claude Code and report available imports") + parser.add_argument("--import-all", action="store_true", help="Full migration: config + recent sessions") + parser.add_argument("--import-config", action="store_true", help="Import settings, agents, hooks, skills, instructions") + parser.add_argument("--import-sessions", action="store_true", help="Import recent Claude Code sessions") + parser.add_argument("--dry-run", action="store_true", help="Show what would be done without making changes") + + # Session selection (overrides defaults) + parser.add_argument("session", nargs="?", help="Session ID, index number, or path to JSONL file") + parser.add_argument("--resume", "-r", action="store_true", help="Resume the imported session") + parser.add_argument("--fork", "-f", action="store_true", help="Fork the imported session into a new session") + parser.add_argument("--list", "-l", action="store_true", help="List available Claude Code sessions") + parser.add_argument("--days", type=int, default=30, help="Number of days of session history to import (default: 30)") + + args = parser.parse_args() + + # ── Mode dispatch ────────────────────────────────────────────────────── + + # --detect + if args.detect: + cmd_detect() + return + + # --import-all: config + sessions + if args.import_all: + cmd_detect() + print() + print("Starting full import (config + recent sessions)...") + print() + cmd_import_config(dry_run=args.dry_run) + if not args.dry_run: + cmd_import_sessions(days=args.days, fork=args.fork, resume=args.resume) + return + + # --import-config + if args.import_config: + cmd_import_config(dry_run=args.dry_run) + return + + # --import-sessions + if args.import_sessions: + if args.dry_run: + sessions = find_recent_claude_sessions(days=args.days) + print(f"Would import {len(sessions)} session(s) from the last {args.days} days.") + return + cmd_import_sessions(days=args.days, fork=args.fork, resume=args.resume) + return + + # --list (original behavior) + if args.list or (not args.session and sys.stdin.isatty() and not any([ + args.detect, args.import_all, args.import_config, args.import_sessions + ])): + cmd_list_sessions() + return + + # Single session import (original behavior) + if args.session: + cmd_import_single(args.session, fork=args.fork, resume=args.resume) + return + + parser.print_help() + + +if __name__ == "__main__": + main()