Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
255 changes: 255 additions & 0 deletions cmd/engram/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import (
"syscall"
"time"

"github.com/Gentleman-Programming/engram/internal/agents"
"github.com/Gentleman-Programming/engram/internal/claudecode"
"github.com/Gentleman-Programming/engram/internal/mcp"
"github.com/Gentleman-Programming/engram/internal/obsidian"
"github.com/Gentleman-Programming/engram/internal/project"
Expand Down Expand Up @@ -169,8 +171,16 @@ func main() {
cmdSync(cfg)
case "obsidian-export":
cmdObsidianExport(cfg)
case "claude-code-export":
cmdClaudeCodeExport(cfg)
case "claude-code-import":
cmdClaudeCodeImport(cfg)
case "claude-code-sync":
cmdClaudeCodeSync(cfg)
case "projects":
cmdProjects(cfg)
case "agents":
cmdAgents()
case "setup":
cmdSetup()
case "version", "--version", "-v":
Expand Down Expand Up @@ -918,6 +928,158 @@ func cmdObsidianExport(cfg store.Config) {
}
}

// ─── Claude Code Memory Sync ─────────────────────────────────────────────────

func cmdClaudeCodeExport(cfg store.Config) {
claudeProjectsDir, project, dryRun := parseClaudeCodeFlags()

if claudeProjectsDir == "" {
home, err := userHomeDir()
if err != nil {
fatal(fmt.Errorf("could not determine home directory: %w", err))
}
claudeProjectsDir = filepath.Join(home, ".claude", "projects")
}

s, err := storeNew(cfg)
if err != nil {
fatal(err)
}
defer s.Close()

exp := claudecode.NewExporter(s, claudecode.ExportConfig{
ClaudeProjectsDir: claudeProjectsDir,
Project: project,
DryRun: dryRun,
})

result, err := exp.Export()
if err != nil {
fatal(err)
}

fmt.Printf("Claude Code export complete\n")
fmt.Printf(" Created: %d\n", result.Created)
fmt.Printf(" Updated: %d\n", result.Updated)
fmt.Printf(" Skipped: %d\n", result.Skipped)
if len(result.Errors) > 0 {
fmt.Fprintf(os.Stderr, " Errors: %d\n", len(result.Errors))
for _, e := range result.Errors {
fmt.Fprintf(os.Stderr, " - %v\n", e)
}
}
}

func cmdClaudeCodeImport(cfg store.Config) {
claudeProjectsDir, project, dryRun := parseClaudeCodeFlags()

if claudeProjectsDir == "" {
home, err := userHomeDir()
if err != nil {
fatal(fmt.Errorf("could not determine home directory: %w", err))
}
claudeProjectsDir = filepath.Join(home, ".claude", "projects")
}

s, err := storeNew(cfg)
if err != nil {
fatal(err)
}
defer s.Close()

imp := claudecode.NewImporter(s, claudecode.ImportConfig{
ClaudeProjectsDir: claudeProjectsDir,
Project: project,
DryRun: dryRun,
})

result, err := imp.Import()
if err != nil {
fatal(err)
}

fmt.Printf("Claude Code import complete\n")
fmt.Printf(" Imported: %d\n", result.Imported)
fmt.Printf(" Skipped: %d\n", result.Skipped)
if len(result.Errors) > 0 {
fmt.Fprintf(os.Stderr, " Errors: %d\n", len(result.Errors))
for _, e := range result.Errors {
fmt.Fprintf(os.Stderr, " - %v\n", e)
}
}
}

func cmdClaudeCodeSync(cfg store.Config) {
claudeProjectsDir, project, dryRun := parseClaudeCodeFlags()

if claudeProjectsDir == "" {
home, err := userHomeDir()
if err != nil {
fatal(fmt.Errorf("could not determine home directory: %w", err))
}
claudeProjectsDir = filepath.Join(home, ".claude", "projects")
}

s, err := storeNew(cfg)
if err != nil {
fatal(err)
}
defer s.Close()

syncer := claudecode.NewSyncer(s, claudecode.SyncConfig{
ClaudeProjectsDir: claudeProjectsDir,
Project: project,
DryRun: dryRun,
})

result, err := syncer.FullSync()
if err != nil {
fatal(err)
}

fmt.Printf("Claude Code sync complete\n")
if result.ExportResult != nil {
fmt.Printf(" Export: created=%d updated=%d skipped=%d\n",
result.ExportResult.Created,
result.ExportResult.Updated,
result.ExportResult.Skipped)
}
if result.ImportResult != nil {
fmt.Printf(" Import: imported=%d skipped=%d\n",
result.ImportResult.Imported,
result.ImportResult.Skipped)
}
if len(result.Errors) > 0 {
fmt.Fprintf(os.Stderr, " Errors: %d\n", len(result.Errors))
for _, e := range result.Errors {
fmt.Fprintf(os.Stderr, " - %v\n", e)
}
}
}

// parseClaudeCodeFlags parses common flags for Claude Code sync commands.
func parseClaudeCodeFlags() (claudeProjectsDir, project string, dryRun bool) {
for i := 2; i < len(os.Args); i++ {
switch os.Args[i] {
case "--claude-projects-dir":
if i+1 < len(os.Args) {
claudeProjectsDir = os.Args[i+1]
i++
}
case "--project":
if i+1 < len(os.Args) {
project = os.Args[i+1]
i++
}
case "--dry-run":
dryRun = true
default:
// Unknown flag, ignore
}
}
return
}

func cmdProjects(cfg store.Config) {
// Route: engram projects list | engram projects consolidate [--all] [--dry-run]
subCmd := "list"
Expand Down Expand Up @@ -976,6 +1138,84 @@ func cmdProjectsList(cfg store.Config) {
}
}

// cmdAgents shows AI agent usage statistics across different agents.
func cmdAgents() {
home, err := os.UserHomeDir()
if err != nil {
fatal(fmt.Errorf("could not determine home directory: %w", err))
}

jsonOutput := false
if len(os.Args) > 2 && os.Args[2] == "--json" {
jsonOutput = true
}

stats, err := agents.DetectAgents(home)
if err != nil {
fatal(fmt.Errorf("failed to detect agents: %w", err))
}

if jsonOutput {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(stats); err != nil {
fatal(fmt.Errorf("json encode: %w", err))
}
return
}

// Text output
if len(stats.Agents) == 0 {
fmt.Println("No AI agents detected on this system.")
return
}

// Find max sessions for bar scaling
maxSessions := 0
totalSessions := 0
for _, a := range stats.Agents {
if a.Sessions > maxSessions {
maxSessions = a.Sessions
}
totalSessions += a.Sessions
}

fmt.Println()
fmt.Println(" AI Agent Usage Statistics")
fmt.Println(" ─────────────────────────")
fmt.Println()

for _, a := range stats.Agents {
// Calculate percentage
pct := 0
if totalSessions > 0 {
pct = (a.Sessions * 100) / totalSessions
}

// Draw bar
barLen := 20
barFill := 0
if maxSessions > 0 {
barFill = (a.Sessions * barLen) / maxSessions
}
bar := strings.Repeat("█", barFill) + strings.Repeat("░", barLen-barFill)

fmt.Printf(" %-12s %s %3d%% (%d sessions)\n", a.Agent, bar, pct, a.Sessions)
if a.Projects > 0 {
fmt.Printf(" %d projects\n", a.Projects)
}
if a.FirstSeen != "" && a.FirstSeen != a.LastSeen {
fmt.Printf(" %s → %s\n", a.FirstSeen, a.LastSeen)
} else if a.FirstSeen != "" {
fmt.Printf(" Active since %s\n", a.FirstSeen)
}
fmt.Println()
}

fmt.Printf(" Total: %d sessions across %d agents\n", totalSessions, len(stats.Agents))
fmt.Println()
}

// projectGroup represents a set of project names that should be merged.
type projectGroup struct {
Names []string
Expand Down Expand Up @@ -1546,6 +1786,8 @@ Commands:
projects list List all projects with observation, session, and prompt counts
projects consolidate [--all] [--dry-run]
Merge similar project names into one canonical name
agents [--json] Show AI agent usage statistics (reads Claude Code, Gemini, etc.)
Merge similar project names into one canonical name
--all Scan ALL projects for similar name groups
--dry-run Preview what would be merged (no changes)
setup [agent] Install/setup agent integration (opencode, claude-code, gemini-cli, codex)
Expand All @@ -1563,6 +1805,19 @@ Commands:
--graph-config Graph layout mode: preserve|force|skip (default: preserve)
--watch Enable auto-sync mode (runs on interval until Ctrl+C)
--interval Sync interval for --watch mode (default: 10m, minimum: 1m)
claude-code-export Export memories to Claude Code's native memory folder
--claude-projects-dir Path to Claude projects dir (default: ~/.claude/projects)
--project Filter export to a single project (optional)
--dry-run Preview what would be exported (no files written)
claude-code-import Import memories from Claude Code's native memory folder
--claude-projects-dir Path to Claude projects dir (default: ~/.claude/projects)
--project Filter import to a single project (optional)
--dry-run Preview what would be imported (no records written)
claude-code-sync Bidirectional sync between Engram and Claude Code memory
--claude-projects-dir Path to Claude projects dir (default: ~/.claude/projects)
--project Filter sync to a single project (optional)
--dry-run Preview what would be synced (no changes made)
Exports Engram memories to Claude Code, then imports new Claude Code memories.

version Print version
help Show this help
Expand Down
Loading