package cli import ( "bufio" "context" "fmt" "io" "os" "strings" "github.com/spf13/cobra" "github.com/aoagents/agent-orchestrator/backend/internal/config" "github.com/aoagents/agent-orchestrator/backend/internal/legacyimport" "github.com/aoagents/agent-orchestrator/backend/internal/runfile" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" ) type importOptions struct { from string dryRun bool yes bool json bool } func newImportCommand(ctx *commandContext) *cobra.Command { var opts importOptions cmd := &cobra.Command{ Use: "import", Short: "Import projects from a legacy AO install", Long: "Import reads the legacy Agent Orchestrator flat-file store " + "(~/.agent-orchestrator) read-only and ports its projects and per-project " + "settings into the rewrite database. Legacy files are never modified, and " + "a re-run skips rows that already exist, so it is safe to run more than once.\n\n" + "The daemon must be stopped: it is the sole writer of the database.", Args: noArgs, RunE: func(cmd *cobra.Command, args []string) error { return ctx.runImport(cmd, opts) }, } cmd.Flags().StringVar(&opts.from, "from", "", "Legacy AO root to read (default ~/.agent-orchestrator)") cmd.Flags().BoolVar(&opts.dryRun, "dry-run", false, "Parse and report the planned import without writing") cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false, "Skip the confirmation prompt (for non-interactive use)") cmd.Flags().BoolVar(&opts.json, "json", false, "Output the import report as JSON") return cmd } func (c *commandContext) runImport(cmd *cobra.Command, opts importOptions) error { cfg, err := config.Load() if err != nil { return err } // The daemon is the sole writer; refuse to open the store underneath a live // one. A stale run-file (dead PID) is treated as safe. if live, err := runfile.CheckStale(cfg.RunFilePath); err != nil { return fmt.Errorf("inspect run-file: %w", err) } else if live != nil { return usageError{fmt.Errorf("the AO daemon is running (pid %d); stop it first with `ao stop` before importing", live.PID)} } root := opts.from if root == "" { root = legacyimport.DefaultLegacyRootDir() } if !legacyimport.HasLegacyData(root) { _, err := fmt.Fprintf(cmd.OutOrStdout(), "No legacy AO projects found at %s. Nothing to import.\n", root) return err } if !opts.dryRun && !opts.yes { ok, err := confirm(c.deps.In, cmd.OutOrStdout(), fmt.Sprintf("Import projects from %s?", root), true) if err != nil { return err } if !ok { _, err := fmt.Fprintln(cmd.OutOrStdout(), "Import cancelled.") return err } } rep, err := c.executeImport(cmd.Context(), cfg, legacyimport.Options{ Root: root, DryRun: opts.dryRun, }) if err != nil { return err } if opts.json { return writeJSON(cmd.OutOrStdout(), rep) } return writeImportSummary(cmd.OutOrStdout(), rep) } // executeImport opens the rewrite store, runs the import, and closes the store. // It is the one CLI path that opens the database directly: the import is a // one-time bootstrap that must run with the daemon stopped (guarded by the // caller), so it cannot go through the daemon's loopback API. func (c *commandContext) executeImport(ctx context.Context, cfg config.Config, opts legacyimport.Options) (legacyimport.Report, error) { store, err := sqlite.Open(cfg.DataDir) if err != nil { return legacyimport.Report{}, fmt.Errorf("open store: %w", err) } defer func() { _ = store.Close() }() return legacyimport.Run(ctx, store, opts) } func writeImportSummary(w io.Writer, rep legacyimport.Report) error { var b strings.Builder if rep.DryRun { b.WriteString("Dry run -- no changes written.\n") } fmt.Fprintf(&b, "Projects: %d imported, %d already present\n", rep.ProjectsImported, rep.ProjectsSkipped) if len(rep.Notes) > 0 { b.WriteString("\nNotes:\n") for _, n := range rep.Notes { fmt.Fprintf(&b, " - %s\n", n) } } _, err := io.WriteString(w, b.String()) return err } // confirm prompts for a yes/no answer. When stdin is not an interactive // terminal it returns the default without prompting, so headless invocations // behave deterministically. func confirm(in io.Reader, out io.Writer, prompt string, defaultYes bool) (bool, error) { suffix := " [Y/n] " if !defaultYes { suffix = " [y/N] " } if !stdinIsInteractive(in) { return defaultYes, nil } if _, err := io.WriteString(out, prompt+suffix); err != nil { return false, err } line, err := bufio.NewReader(in).ReadString('\n') if err != nil && line == "" { // EOF with no input: fall back to the default. return defaultYes, nil } switch strings.ToLower(strings.TrimSpace(line)) { case "": return defaultYes, nil case "y", "yes": return true, nil case "n", "no": return false, nil default: return false, nil } } // stdinIsInteractive reports whether in is an interactive terminal. It only // treats the real os.Stdin as potentially interactive; a piped reader or test // buffer is non-interactive. func stdinIsInteractive(in io.Reader) bool { f, ok := in.(*os.File) if !ok { return false } info, err := f.Stat() if err != nil { return false } return info.Mode()&os.ModeCharDevice != 0 }