diff --git a/.gitignore b/.gitignore index 65016f4..abc1cdf 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ # Dependency directories vendor/ +node_modules/ # IDE and editor clutter .vscode/ @@ -17,7 +18,13 @@ Thumbs.db # Temporary files *.tmp *.swp +*.log # Environment files .env .env.* + +# Build outputs +dist/ +build/ +coverage/ diff --git a/AGENTS.md b/AGENTS.md index a8f847f..4617b31 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,8 @@ Auto-generated from all feature plans. Last updated: 2025-10-29 - Go 1.24 + `spf13/cobra`, `spf13/pflag`, internal traversal/history/output packages (005-add-insert-command) - Go 1.24 + `spf13/cobra`, `spf13/pflag`, Go `regexp` (RE2 engine), internal traversal/history/output packages (006-add-regex-command) - Local filesystem and `.renamer` ledger files (006-add-regex-command) +- Go 1.24 (CLI), Node.js 20 + TypeScript (Google Genkit workflow) + `spf13/cobra`, internal traversal/history/output packages, Google Genkit SDK, OpenAI-compatible HTTP client for fallbacks (008-ai-rename-prompt) +- Local filesystem plus `.renamer` append-only ledger (008-ai-rename-prompt) ## Project Structure @@ -43,9 +45,9 @@ tests/ - Smoke: `scripts/smoke-test-replace.sh`, `scripts/smoke-test-remove.sh` ## Recent Changes +- 008-ai-rename-prompt: Added Go 1.24 (CLI), Node.js 20 + TypeScript (Google Genkit workflow) + `spf13/cobra`, internal traversal/history/output packages, Google Genkit SDK, OpenAI-compatible HTTP client for fallbacks - 001-sequence-numbering: Added Go 1.24 + `spf13/cobra`, `spf13/pflag`, internal traversal/history/output packages - 006-add-regex-command: Added Go 1.24 + `spf13/cobra`, `spf13/pflag`, Go `regexp` (RE2 engine), internal traversal/history/output packages -- 005-add-insert-command: Added Go 1.24 + `spf13/cobra`, `spf13/pflag`, internal traversal/history/output packages diff --git a/cmd/ai.go b/cmd/ai.go new file mode 100644 index 0000000..f648f01 --- /dev/null +++ b/cmd/ai.go @@ -0,0 +1,555 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "io" + "path/filepath" + "sort" + "strings" + + "github.com/spf13/cobra" + + "github.com/rogeecn/renamer/internal/ai/genkit" + "github.com/rogeecn/renamer/internal/ai/plan" + "github.com/rogeecn/renamer/internal/ai/prompt" + "github.com/rogeecn/renamer/internal/listing" + "github.com/rogeecn/renamer/internal/output" +) + +type aiCommandOptions struct { + Model string + Debug bool + ExportPath string + ImportPath string + Casing string + Prefix string + AllowSpaces bool + KeepOriginalOrder bool + BannedTokens []string +} + +func newAICommand() *cobra.Command { + ops := &aiCommandOptions{} + + cmd := &cobra.Command{ + Use: "ai", + Short: "Generate rename plans using the AI workflow", + Long: "Invoke the embedded AI workflow to generate, validate, and optionally apply rename plans.", + Example: strings.TrimSpace(` # Preview an AI plan and export the raw response for edits + renamer ai --path ./photos --dry-run --export-plan plan.json + + # Import an edited plan and validate it without applying changes + renamer ai --path ./photos --dry-run --import-plan plan.json + + # Apply an edited plan after validation passes + renamer ai --path ./photos --import-plan plan.json --yes`), + RunE: func(cmd *cobra.Command, args []string) error { + options := collectAIOptions(cmd, ops) + return runAICommand(cmd.Context(), cmd, options) + }, + } + + bindAIFlags(cmd, ops) + + return cmd +} + +func bindAIFlags(cmd *cobra.Command, opts *aiCommandOptions) { + cmd.Flags().StringVar(&opts.Model, "genkit-model", genkit.DefaultModelName, fmt.Sprintf("OpenAI-compatible model identifier (default %s)", genkit.DefaultModelName)) + cmd.Flags().BoolVar(&opts.Debug, "debug-genkit", false, "Write Genkit prompt/response traces to the debug log") + cmd.Flags().StringVar(&opts.ExportPath, "export-plan", "", "Export the raw AI plan JSON to the provided file path") + cmd.Flags().StringVar(&opts.ImportPath, "import-plan", "", "Import an edited AI plan JSON for validation or apply") + cmd.Flags().StringVar(&opts.Casing, "naming-casing", "kebab", "Casing style for AI-generated filenames (kebab, snake, camel, pascal, title)") + cmd.Flags().StringVar(&opts.Prefix, "naming-prefix", "", "Static prefix AI proposals must include (alias: --prefix)") + cmd.Flags().StringVar(&opts.Prefix, "prefix", "", "Alias for --naming-prefix") + cmd.Flags().BoolVar(&opts.AllowSpaces, "naming-allow-spaces", false, "Permit spaces in AI-generated filenames") + cmd.Flags().BoolVar(&opts.KeepOriginalOrder, "naming-keep-order", false, "Instruct AI to preserve original ordering of descriptive terms") + cmd.Flags().StringSliceVar(&opts.BannedTokens, "banned", nil, "Comma-separated list of additional banned tokens (repeat flag to add more)") +} + +func collectAIOptions(cmd *cobra.Command, defaults *aiCommandOptions) aiCommandOptions { + result := aiCommandOptions{ + Model: genkit.DefaultModelName, + Debug: false, + ExportPath: "", + Casing: "kebab", + } + + if defaults != nil { + if defaults.Model != "" { + result.Model = defaults.Model + } + result.Debug = defaults.Debug + result.ExportPath = defaults.ExportPath + if defaults.Casing != "" { + result.Casing = defaults.Casing + } + result.Prefix = defaults.Prefix + result.AllowSpaces = defaults.AllowSpaces + result.KeepOriginalOrder = defaults.KeepOriginalOrder + if len(defaults.BannedTokens) > 0 { + result.BannedTokens = append([]string(nil), defaults.BannedTokens...) + } + } + + if flag := cmd.Flags().Lookup("genkit-model"); flag != nil { + if value, err := cmd.Flags().GetString("genkit-model"); err == nil && value != "" { + result.Model = value + } + } + + if flag := cmd.Flags().Lookup("debug-genkit"); flag != nil { + if value, err := cmd.Flags().GetBool("debug-genkit"); err == nil { + result.Debug = value + } + } + + if flag := cmd.Flags().Lookup("export-plan"); flag != nil { + if value, err := cmd.Flags().GetString("export-plan"); err == nil && value != "" { + result.ExportPath = value + } + } + + if flag := cmd.Flags().Lookup("import-plan"); flag != nil { + if value, err := cmd.Flags().GetString("import-plan"); err == nil && value != "" { + result.ImportPath = value + } + } + + if flag := cmd.Flags().Lookup("naming-casing"); flag != nil { + if value, err := cmd.Flags().GetString("naming-casing"); err == nil && value != "" { + result.Casing = value + } + } + + if flag := cmd.Flags().Lookup("naming-prefix"); flag != nil { + if value, err := cmd.Flags().GetString("naming-prefix"); err == nil { + result.Prefix = value + } + } + if flag := cmd.Flags().Lookup("prefix"); flag != nil && flag.Changed { + if value, err := cmd.Flags().GetString("prefix"); err == nil { + result.Prefix = value + } + } + + if flag := cmd.Flags().Lookup("naming-allow-spaces"); flag != nil { + if value, err := cmd.Flags().GetBool("naming-allow-spaces"); err == nil { + result.AllowSpaces = value + } + } + + if flag := cmd.Flags().Lookup("naming-keep-order"); flag != nil { + if value, err := cmd.Flags().GetBool("naming-keep-order"); err == nil { + result.KeepOriginalOrder = value + } + } + + if flag := cmd.Flags().Lookup("banned"); flag != nil { + if value, err := cmd.Flags().GetStringSlice("banned"); err == nil && len(value) > 0 { + result.BannedTokens = append([]string(nil), value...) + } + } + + return result +} + +func runAICommand(ctx context.Context, cmd *cobra.Command, options aiCommandOptions) error { + scope, err := listing.ScopeFromCmd(cmd) + if err != nil { + return err + } + + applyRequested, err := getBool(cmd, "yes") + if err != nil { + return err + } + + options.ImportPath = strings.TrimSpace(options.ImportPath) + + casing, err := normalizeCasing(options.Casing) + if err != nil { + return err + } + options.Casing = casing + prefix := strings.TrimSpace(options.Prefix) + userBanned := sanitizeTokenSlice(options.BannedTokens) + bannedTerms := mergeBannedTerms(defaultBannedTerms(), userBanned) + + candidates, err := plan.CollectCandidates(ctx, scope) + if err != nil { + return err + } + ignoreSet := buildIgnoreSet(scope.WorkingDir, options.ExportPath, options.ImportPath) + if len(ignoreSet) > 0 { + candidates = filterIgnoredCandidates(candidates, ignoreSet) + } + if len(candidates) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No candidates found") + return nil + } + + samples := make([]prompt.SampleCandidate, 0, len(candidates)) + for _, candidate := range candidates { + samples = append(samples, prompt.SampleCandidate{ + RelativePath: candidate.OriginalPath, + SizeBytes: candidate.SizeBytes, + Depth: candidate.Depth, + }) + } + + sequence := prompt.SequenceRule{ + Style: "prefix", + Width: 3, + Start: 1, + Separator: "_", + } + + policies := prompt.PolicyConfig{ + Prefix: prefix, + Casing: options.Casing, + AllowSpaces: options.AllowSpaces, + KeepOriginalOrder: options.KeepOriginalOrder, + ForbiddenTokens: append([]string(nil), userBanned...), + } + validatorPolicy := prompt.NamingPolicyConfig{ + Prefix: policies.Prefix, + Casing: policies.Casing, + AllowSpaces: policies.AllowSpaces, + KeepOriginalOrder: policies.KeepOriginalOrder, + ForbiddenTokens: append([]string(nil), policies.ForbiddenTokens...), + } + + var response prompt.RenameResponse + var promptHash string + var model string + + if options.ImportPath != "" { + resp, err := plan.LoadResponse(options.ImportPath) + if err != nil { + return err + } + response = resp + promptHash = strings.TrimSpace(resp.PromptHash) + model = strings.TrimSpace(resp.Model) + if model == "" { + model = options.Model + } + } else { + builder := prompt.NewBuilder() + promptPayload, err := builder.Build(prompt.BuildInput{ + WorkingDir: scope.WorkingDir, + Samples: samples, + TotalCount: len(candidates), + Sequence: sequence, + Policies: policies, + BannedTerms: bannedTerms, + Metadata: map[string]string{ + "cliVersion": "dev", + }, + }) + if err != nil { + return err + } + + instructions := composeInstructions(sequence, policies, bannedTerms) + client := genkit.NewClient(genkit.ClientOptions{Model: options.Model}) + invocationResult, err := client.Invoke(ctx, genkit.Invocation{ + Instructions: instructions, + Prompt: promptPayload, + Model: options.Model, + }) + if err != nil { + return err + } + response = invocationResult.Response + promptHash = invocationResult.PromptHash + model = invocationResult.Response.Model + + if options.ExportPath != "" { + if err := plan.SaveResponse(options.ExportPath, response); err != nil { + return err + } + fmt.Fprintf(cmd.ErrOrStderr(), "AI plan exported to %s\n", options.ExportPath) + } + } + + if promptHash == "" { + if hash, err := plan.ResponseDigest(response); err == nil { + promptHash = hash + } + } + if model == "" { + model = options.Model + } + response.PromptHash = promptHash + response.Model = model + + originals := make([]string, 0, len(candidates)) + for _, candidate := range candidates { + originals = append(originals, candidate.OriginalPath) + } + + validator := plan.NewValidator(originals, validatorPolicy, bannedTerms) + validationResult, err := validator.Validate(response) + if err != nil { + var vErr *plan.ValidationError + if errors.As(err, &vErr) { + errorWriter := cmd.ErrOrStderr() + if len(vErr.PolicyViolations) > 0 { + messages := make([]output.PolicyViolationMessage, 0, len(vErr.PolicyViolations)) + for _, violation := range vErr.PolicyViolations { + messages = append(messages, output.PolicyViolationMessage{ + Original: violation.Original, + Proposed: violation.Proposed, + Rule: violation.Rule, + Message: violation.Message, + }) + } + output.WritePolicyViolations(errorWriter, messages) + } + } + return err + } + + previewPlan, err := plan.MapResponse(plan.MapInput{ + Candidates: candidates, + SequenceWidth: sequence.Width, + }, validationResult) + if err != nil { + return err + } + previewPlan.PromptHash = promptHash + if previewPlan.Model == "" { + previewPlan.Model = model + } + + if err := renderAIPlan(cmd.OutOrStdout(), previewPlan); err != nil { + return err + } + + errorWriter := cmd.ErrOrStderr() + if len(previewPlan.Conflicts) > 0 { + for _, conflict := range previewPlan.Conflicts { + fmt.Fprintf(errorWriter, "Conflict (%s): %s %s\n", conflict.Issue, conflict.OriginalPath, conflict.Details) + } + } + + if options.Debug { + output.WriteAIPlanDebug(errorWriter, promptHash, previewPlan.Warnings) + } else if len(previewPlan.Warnings) > 0 { + output.WriteAIPlanDebug(errorWriter, "", previewPlan.Warnings) + } + + if options.ImportPath == "" && options.ExportPath != "" { + // Plan already exported earlier. + } else if options.ImportPath != "" && options.ExportPath != "" { + if err := plan.SaveResponse(options.ExportPath, response); err != nil { + return err + } + fmt.Fprintf(errorWriter, "AI plan exported to %s\n", options.ExportPath) + } + + if !applyRequested { + return nil + } + + if len(previewPlan.Conflicts) > 0 { + return fmt.Errorf("cannot apply AI plan while conflicts remain") + } + + applyEntry, err := plan.Apply(ctx, plan.ApplyOptions{ + WorkingDir: scope.WorkingDir, + Candidates: candidates, + Response: response, + Policies: validatorPolicy, + PromptHash: promptHash, + }) + if err != nil { + var conflictErr plan.ApplyConflictError + if errors.As(err, &conflictErr) { + for _, conflict := range conflictErr.Conflicts { + fmt.Fprintf(errorWriter, "Apply conflict (%s): %s %s\n", conflict.Issue, conflict.OriginalPath, conflict.Details) + } + } + return err + } + + fmt.Fprintf(cmd.OutOrStdout(), "Applied %d renames. Ledger updated.\n", len(applyEntry.Operations)) + return nil +} + +func renderAIPlan(w io.Writer, preview plan.PreviewPlan) error { + table := output.NewAIPlanTable() + if err := table.Begin(w); err != nil { + return err + } + for _, entry := range preview.Entries { + sanitized := "-" + if len(entry.SanitizedSegments) > 0 { + joined := strings.Join(entry.SanitizedSegments, " ") + sanitized = "removed: " + joined + } + if entry.Notes != "" { + if sanitized == "-" { + sanitized = entry.Notes + } else { + sanitized = fmt.Sprintf("%s (%s)", sanitized, entry.Notes) + } + } + row := output.AIPlanRow{ + Sequence: entry.SequenceLabel, + Original: entry.OriginalPath, + Proposed: entry.ProposedPath, + Sanitized: sanitized, + } + if err := table.WriteRow(row); err != nil { + return err + } + } + return table.End(w) +} + +func composeInstructions(sequence prompt.SequenceRule, policies prompt.PolicyConfig, bannedTerms []string) string { + lines := []string{ + "You are an AI assistant that proposes safe file rename plans.", + "Return JSON matching this schema: {\"items\":[{\"original\":string,\"proposed\":string,\"sequence\":number,\"notes\"?:string}],\"warnings\"?:[string]}.", + fmt.Sprintf("Use %s numbering with width %d starting at %d and separator %q.", sequence.Style, sequence.Width, sequence.Start, sequence.Separator), + "Preserve original file extensions exactly as provided.", + fmt.Sprintf("Apply %s casing to filename stems and avoid promotional or banned terms.", policies.Casing), + "Ensure proposed names are unique and sequences remain contiguous.", + } + if policies.Prefix != "" { + lines = append(lines, fmt.Sprintf("Every proposed filename must begin with the prefix %q immediately before descriptive text.", policies.Prefix)) + } + if policies.AllowSpaces { + lines = append(lines, "Spaces in filenames are permitted when they improve clarity.") + } else { + lines = append(lines, "Do not include spaces in filenames; use separators consistent with the requested casing style.") + } + if policies.KeepOriginalOrder { + lines = append(lines, "Preserve the original ordering of meaningful words when generating new stems.") + } + if len(bannedTerms) > 0 { + lines = append(lines, fmt.Sprintf("Never include these banned tokens (case-insensitive) in any proposed filename: %s.", strings.Join(bannedTerms, ", "))) + } + return strings.Join(lines, "\n") +} + +func normalizeCasing(value string) (string, error) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "kebab", nil + } + lower := strings.ToLower(trimmed) + supported := map[string]string{ + "kebab": "kebab", + "snake": "snake", + "camel": "camel", + "pascal": "pascal", + "title": "title", + } + if normalized, ok := supported[lower]; ok { + return normalized, nil + } + return "", fmt.Errorf("unsupported naming casing %q (allowed: kebab, snake, camel, pascal, title)", value) +} + +func sanitizeTokenSlice(values []string) []string { + unique := make(map[string]struct{}) + for _, raw := range values { + for _, part := range strings.Split(raw, ",") { + trimmed := strings.TrimSpace(part) + if trimmed == "" { + continue + } + lower := strings.ToLower(trimmed) + if lower == "" { + continue + } + unique[lower] = struct{}{} + } + } + if len(unique) == 0 { + return nil + } + tokens := make([]string, 0, len(unique)) + for token := range unique { + tokens = append(tokens, token) + } + sort.Strings(tokens) + return tokens +} + +func mergeBannedTerms(base, extra []string) []string { + unique := make(map[string]struct{}) + for _, token := range base { + lower := strings.ToLower(strings.TrimSpace(token)) + if lower == "" { + continue + } + unique[lower] = struct{}{} + } + for _, token := range extra { + lower := strings.ToLower(strings.TrimSpace(token)) + if lower == "" { + continue + } + unique[lower] = struct{}{} + } + result := make([]string, 0, len(unique)) + for token := range unique { + result = append(result, token) + } + sort.Strings(result) + return result +} + +func buildIgnoreSet(workingDir string, paths ...string) map[string]struct{} { + ignore := make(map[string]struct{}) + for _, path := range paths { + trimmed := strings.TrimSpace(path) + if trimmed == "" { + continue + } + abs, err := filepath.Abs(trimmed) + if err != nil { + continue + } + rel, err := filepath.Rel(workingDir, abs) + if err != nil { + continue + } + if strings.HasPrefix(rel, "..") { + continue + } + ignore[strings.ToLower(filepath.ToSlash(rel))] = struct{}{} + } + return ignore +} + +func filterIgnoredCandidates(candidates []plan.Candidate, ignore map[string]struct{}) []plan.Candidate { + if len(ignore) == 0 { + return candidates + } + filtered := make([]plan.Candidate, 0, len(candidates)) + for _, cand := range candidates { + if _, skip := ignore[strings.ToLower(cand.OriginalPath)]; skip { + continue + } + filtered = append(filtered, cand) + } + return filtered +} + +func defaultBannedTerms() []string { + terms := []string{"promo", "sale", "free", "clickbait", "sponsored"} + sort.Strings(terms) + return terms +} + +func init() { + rootCmd.AddCommand(newAICommand()) +} diff --git a/cmd/root.go b/cmd/root.go index 1c9fc57..f7fc846 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -15,8 +15,9 @@ var rootCmd = &cobra.Command{ Use: "renamer", Short: "Safe, scriptable batch renaming utility", Long: `Renamer provides preview-first, undoable rename operations for files and directories. -Use subcommands like "preview", "rename", and "list" with shared scope flags to target exactly -the paths you intend to change.`, +Use subcommands like "list", "replace", "ai", and "undo" with shared scope flags to target +the paths you intend to change. Each command supports --dry-run previews and ledger-backed undo +workflows so you can safely iterate before applying changes.`, } // Execute adds all child commands to the root command and sets flags appropriately. @@ -52,6 +53,7 @@ func NewRootCommand() *cobra.Command { cmd.AddCommand(newRegexCommand()) cmd.AddCommand(newSequenceCommand()) cmd.AddCommand(newUndoCommand()) + cmd.AddCommand(newAICommand()) return cmd } diff --git a/cmd/undo.go b/cmd/undo.go index 179972a..ef70ff3 100644 --- a/cmd/undo.go +++ b/cmd/undo.go @@ -56,6 +56,9 @@ func newUndoCommand() *cobra.Command { fmt.Fprintf(out, "Template restored to %q\n", template) } } + if aiMeta, ok := entry.AIMetadata(); ok { + fmt.Fprintf(out, "AI batch restored (model=%s, promptHash=%s, files=%d)\n", aiMeta.Model, aiMeta.PromptHash, aiMeta.BatchSize) + } } return nil diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1fb09d0..3b98507 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,3 +9,4 @@ - Document quoting guidance, `--dry-run` / `--yes` behavior, and automation scenarios for replace command. - Add `renamer list` subcommand with shared scope flags and plain/table output formats. - Document global scope flags and hidden-file behavior. +- Add `renamer ai` subcommand with export/import workflow, policy enforcement flags, prompt hash telemetry, and ledger metadata for applied plans. diff --git a/docs/cli-flags.md b/docs/cli-flags.md index 1d0bbb7..d6793bc 100644 --- a/docs/cli-flags.md +++ b/docs/cli-flags.md @@ -120,3 +120,20 @@ renamer extension [flags] - Preview normalization: `renamer extension .jpeg .JPG .jpg --dry-run` - Apply case-folded extension updates: `renamer extension .yaml .yml .yml --yes --path ./configs` - Include hidden assets recursively: `renamer extension .TMP .tmp --recursive --hidden` + +## AI Command Secrets + +- AI model authentication tokens are loaded from `$HOME/.config/.renamer/_MODEL_AUTH_TOKEN`. The default model token file is `default_MODEL_AUTH_TOKEN`, but any `--genkit-model` override maps to the same naming scheme. +- Token files must contain only the raw API key with no extra whitespace; restrictive permissions (owner read/write) are recommended to keep credentials private. + +### AI Command Flags + +- `--genkit-model ` overrides the default OpenAI-compatible model used by the embedded Genkit workflow. When omitted, `gpt-4o-mini` is used. +- `--debug-genkit` streams prompt/response telemetry (including prompt hashes and warnings) to stderr so you can archive the exchange for auditing. +- `--export-plan ` writes the exact AI response (prompt hash, model, warnings, and proposed items) to a JSON file. The same file can be edited and re-imported to tweak filenames before applying. +- `--import-plan ` loads a previously exported or manually curated JSON plan. The CLI re-validates all entries before previewing or applying changes. +- `--naming-casing