- Added data model for AI-assisted renaming including structures for prompts, responses, and policies. - Created implementation plan detailing the integration of Google Genkit into the CLI for renaming tasks. - Developed quickstart guide for setting up and using the new AI rename functionality. - Documented research decisions regarding Genkit orchestration and prompt composition. - Established tasks for phased implementation, including setup, foundational work, and user stories. - Implemented contract tests to ensure AI rename policies and ledger metadata are correctly applied. - Developed integration tests for validating AI rename flows, including preview, apply, and undo functionalities. - Added tooling to pin Genkit dependency for consistent builds.
556 lines
16 KiB
Go
556 lines
16 KiB
Go
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())
|
|
}
|