add ai feature

This commit is contained in:
2025-11-05 16:06:09 +08:00
parent 42bc9aff42
commit 13ca7ddbed
33 changed files with 2194 additions and 30 deletions

304
cmd/ai.go Normal file
View File

@@ -0,0 +1,304 @@
package cmd
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"io/fs"
"path/filepath"
"sort"
"strings"
"github.com/spf13/cobra"
"github.com/rogeecn/renamer/internal/ai"
"github.com/rogeecn/renamer/internal/listing"
"github.com/rogeecn/renamer/internal/traversal"
)
const maxAIFileCount = 200
func newAICommand() *cobra.Command {
var prompt string
var sequenceSeparator string
cmd := &cobra.Command{
Use: "ai",
Short: "Generate AI-assisted rename suggestions",
Long: "Preview rename suggestions proposed by the integrated AI assistant before applying changes.",
RunE: func(cmd *cobra.Command, args []string) error {
scope, err := listing.ScopeFromCmd(cmd)
if err != nil {
return err
}
autoApply, err := lookupBool(cmd, "yes")
if err != nil {
return err
}
dryRun, err := lookupBool(cmd, "dry-run")
if err != nil {
return err
}
if dryRun && autoApply {
return errors.New("--dry-run cannot be combined with --yes; remove one of them")
}
files, err := collectScopeEntries(cmd.Context(), scope)
if err != nil {
return err
}
if len(files) == 0 {
fmt.Fprintln(cmd.OutOrStdout(), "No files matched the current scope.")
return nil
}
if len(files) > maxAIFileCount {
return fmt.Errorf("scope contains %d files; reduce to %d or fewer before running ai preview", len(files), maxAIFileCount)
}
if sequenceSeparator == "" {
sequenceSeparator = "."
}
client := ai.NewClient()
session := ai.NewSession(files, prompt, sequenceSeparator, client)
reader := bufio.NewReader(cmd.InOrStdin())
out := cmd.OutOrStdout()
for {
output, validation, err := session.Generate(cmd.Context())
if err != nil {
return err
}
if err := ai.PrintPreview(out, output.Suggestions, validation); err != nil {
return err
}
printSessionSummary(out, session)
if len(validation.Conflicts) > 0 {
fmt.Fprintln(out, "Conflicts detected. Adjust guidance or scope before proceeding.")
}
if autoApply {
if len(validation.Conflicts) > 0 {
return errors.New("preview contains conflicts; refine the prompt or scope before using --yes")
}
session.RecordAcceptance()
entry, err := ai.Apply(cmd.Context(), scope.WorkingDir, output.Suggestions, validation, ai.ApplyMetadata{
Prompt: session.CurrentPrompt(),
PromptHistory: session.PromptHistory(),
Notes: session.Notes(),
Model: session.Model(),
SequenceSeparator: session.SequenceSeparator(),
}, out)
if err != nil {
return err
}
fmt.Fprintf(out, "Applied %d rename(s). Ledger updated.\n", len(entry.Operations))
return nil
}
action, err := readSessionAction(reader, out, len(validation.Conflicts) == 0)
if err != nil {
return err
}
switch action {
case actionQuit:
fmt.Fprintln(out, "Session ended without applying changes.")
return nil
case actionAccept:
if len(validation.Conflicts) > 0 {
fmt.Fprintln(out, "Cannot accept preview while conflicts remain. Resolve them first.")
continue
}
if dryRun {
fmt.Fprintln(out, "Dry-run mode active; no changes were applied.")
return nil
}
applyNow, err := confirmApply(reader, out)
if err != nil {
return err
}
if !applyNow {
fmt.Fprintln(out, "Preview accepted without applying changes.")
return nil
}
session.RecordAcceptance()
entry, err := ai.Apply(cmd.Context(), scope.WorkingDir, output.Suggestions, validation, ai.ApplyMetadata{
Prompt: session.CurrentPrompt(),
PromptHistory: session.PromptHistory(),
Notes: session.Notes(),
Model: session.Model(),
SequenceSeparator: session.SequenceSeparator(),
}, out)
if err != nil {
return err
}
fmt.Fprintf(out, "Applied %d rename(s). Ledger updated.\n", len(entry.Operations))
return nil
case actionRegenerate:
session.RecordRegeneration()
continue
case actionEdit:
newPrompt, err := readPrompt(reader, out, session.CurrentPrompt())
if err != nil {
return err
}
session.UpdatePrompt(newPrompt)
continue
}
}
},
}
cmd.Flags().StringVar(&prompt, "prompt", "", "Optional guidance for the AI suggestion engine")
cmd.Flags().StringVar(&sequenceSeparator, "sequence-separator", ".", "Separator inserted between sequence number and generated name")
return cmd
}
func collectScopeEntries(ctx context.Context, req *listing.ListingRequest) ([]string, error) {
walker := traversal.NewWalker()
extensions := make(map[string]struct{}, len(req.Extensions))
for _, ext := range req.Extensions {
extensions[strings.ToLower(ext)] = struct{}{}
}
var files []string
err := walker.Walk(req.WorkingDir, req.Recursive, req.IncludeDirectories, req.IncludeHidden, req.MaxDepth, func(relPath string, entry fs.DirEntry, depth int) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if entry.IsDir() {
if !req.IncludeDirectories {
return nil
}
} else {
if len(extensions) > 0 {
ext := strings.ToLower(filepath.Ext(entry.Name()))
if _, ok := extensions[ext]; !ok {
return nil
}
}
}
relSlash := filepath.ToSlash(relPath)
if relSlash == "." {
return nil
}
files = append(files, relSlash)
return nil
})
if err != nil {
return nil, err
}
sort.Strings(files)
return files, nil
}
const (
actionAccept = "accept"
actionRegenerate = "regenerate"
actionEdit = "edit"
actionQuit = "quit"
)
func readSessionAction(reader *bufio.Reader, out io.Writer, canAccept bool) (string, error) {
prompt := "Choose action: [Enter] finish, (e) edit prompt, (r) regenerate, (q) quit: "
if !canAccept {
prompt = "Choose action: (e) edit prompt, (r) regenerate, (q) quit: "
}
fmt.Fprint(out, prompt)
line, err := reader.ReadString('\n')
if err != nil {
return "", err
}
choice := strings.TrimSpace(strings.ToLower(line))
if choice == "" {
if !canAccept {
return actionRegenerate, nil
}
return actionAccept, nil
}
switch choice {
case "e", "edit":
return actionEdit, nil
case "r", "regen", "regenerate":
return actionRegenerate, nil
case "q", "quit", "exit":
return actionQuit, nil
case "accept", "a":
if canAccept {
return actionAccept, nil
}
}
fmt.Fprintln(out, "Unrecognised choice; please try again.")
return readSessionAction(reader, out, canAccept)
}
func readPrompt(reader *bufio.Reader, out io.Writer, current string) (string, error) {
fmt.Fprintf(out, "Enter new prompt (leave blank to keep %q): ", current)
line, err := reader.ReadString('\n')
if err != nil {
return "", err
}
trimmed := strings.TrimSpace(line)
if trimmed == "" {
return current, nil
}
return trimmed, nil
}
func printSessionSummary(w io.Writer, session *ai.Session) {
history := session.PromptHistory()
fmt.Fprintf(w, "Current prompt: %q\n", session.CurrentPrompt())
if len(history) > 1 {
fmt.Fprintf(w, "Prompt history (%d entries): %s\n", len(history), strings.Join(history, " -> "))
}
if notes := session.Notes(); len(notes) > 0 {
fmt.Fprintf(w, "Notes: %s\n", strings.Join(notes, "; "))
}
}
func confirmApply(reader *bufio.Reader, out io.Writer) (bool, error) {
fmt.Fprint(out, "Apply these changes now? (y/N): ")
line, err := reader.ReadString('\n')
if err != nil {
return false, err
}
choice := strings.TrimSpace(strings.ToLower(line))
switch choice {
case "y", "yes":
return true, nil
default:
return false, nil
}
}
func lookupBool(cmd *cobra.Command, name string) (bool, error) {
if flag := cmd.Flags().Lookup(name); flag != nil {
return cmd.Flags().GetBool(name)
}
if flag := cmd.InheritedFlags().Lookup(name); flag != nil {
return cmd.InheritedFlags().GetBool(name)
}
return false, nil
}
func init() {
rootCmd.AddCommand(newAICommand())
}

View File

@@ -49,6 +49,7 @@ func NewRootCommand() *cobra.Command {
cmd.AddCommand(NewReplaceCommand())
cmd.AddCommand(NewRemoveCommand())
cmd.AddCommand(NewExtensionCommand())
cmd.AddCommand(newAICommand())
cmd.AddCommand(newInsertCommand())
cmd.AddCommand(newRegexCommand())
cmd.AddCommand(newSequenceCommand())

View File

@@ -38,6 +38,13 @@ func newUndoCommand() *cobra.Command {
if sources, ok := entry.Metadata["sourceExtensions"].([]string); ok && len(sources) > 0 {
fmt.Fprintf(out, "Previous sources: %s\n", strings.Join(sources, ", "))
}
case "ai":
if prompt, ok := entry.Metadata["prompt"].(string); ok && prompt != "" {
fmt.Fprintf(out, "Reverted AI batch generated from prompt %q\n", prompt)
}
if warnings, ok := entry.Metadata["warnings"].([]string); ok && len(warnings) > 0 {
fmt.Fprintf(out, "Warnings during preview: %s\n", strings.Join(warnings, "; "))
}
case "insert":
insertText, _ := entry.Metadata["insertText"].(string)
positionToken, _ := entry.Metadata["positionToken"].(string)

View File

@@ -121,17 +121,22 @@ renamer extension <source-ext...> <target-ext> [flags]
- 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 Command Quick Reference
- AI vendor authentication tokens are read from the `.renamer` environment file located at `$HOME/.config/.renamer` by default (override with `RENAMER_CONFIG_DIR`). Each entry should follow the uppercase `<VENDOR>_TOKEN=...` naming convention; whitespace is trimmed automatically.
- See `.renamer.example` for a pre-populated template covering OpenAI, Anthropic, Google Gemini, Mistral, Cohere, Moonshot, Zhipu, Alibaba DashScope, Baidu Wenxin, MiniMax, ByteDance Doubao, DeepSeek, and xAI Grok tokens.
- Direct environment variables still take precedence over the config file, enabling CI/CD pipelines to inject secrets without touching the filesystem.
```bash
renamer ai [flags]
```
### AI Command Flags
- Generates AI rename suggestions using the embedded Genkit flow. Preview results can be applied immediately or inspected interactively first.
- Scope flags (`--path`, `-r`, `-d`, `--hidden`, `--extensions`) determine which files feed into the flow. Up to 200 entries are accepted per request.
- Provide user guidance via `--prompt "Describe naming scheme"`; when omitted the flow falls back to deterministic sequencing.
- Output renders in a tabular layout showing sequence number, original path, and proposed filename. Validation conflicts are surfaced inline and block continuation.
- After each preview choose `(r)` to regenerate, `(e)` to edit the prompt, `(q)` to exit, or press Enter with a clean preview to finish.
- Use `--yes` for non-interactive runs; the command applies the suggestions when the preview is conflict-free.
- Control numbering format with `--sequence-separator` (default `.`) to change the character(s) inserted between the sequence value and the generated name.
- `--genkit-model <id>` 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.
- Naming policies and sanitization are handled directly inside the AI workflow; no additional CLI flags are required.
- `--yes` applies the currently loaded plan. Without `--yes`, the command remains in preview mode even when a plan already exists.
### Credentials
> Tip: Running `renamer ai` writes or refreshes `renamer.plan.json` in the working directory. Edit that file as needed, then re-run `renamer ai --yes` to apply the reviewed plan once the preview looks good.
- Provide a Gemini API key via `GOOGLE_API_KEY` (recommended) or `GEMINI_API_KEY`. For backward compatibility `RENAMER_AI_KEY` is also accepted.
- Optional: override service endpoints using `GOOGLE_GEMINI_BASE_URL` (Gemini) and `GOOGLE_VERTEX_BASE_URL` (Vertex AI). These must be set **before** the command runs so the Genkit SDK can pick them up.
- If no key is detected the command exits with an error before calling the model.

23
go.mod
View File

@@ -6,38 +6,49 @@ toolchain go1.24.9
require (
github.com/firebase/genkit/go v1.1.0
github.com/joho/godotenv v1.5.1
github.com/openai/openai-go v1.8.2
github.com/spf13/cobra v1.10.1
github.com/spf13/pflag v1.0.9
)
require (
cloud.google.com/go v0.120.0 // indirect
cloud.google.com/go/auth v0.16.2 // indirect
cloud.google.com/go/compute/metadata v0.7.0 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/goccy/go-yaml v1.17.1 // indirect
github.com/google/dotprompt/go v0.0.0-20251014011017-8d056e027254 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
github.com/googleapis/gax-go/v2 v2.14.2 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/mbleigh/raymond v0.0.0-20250414171441-6b3a58ab9e0a // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.36.0 // indirect
go.opentelemetry.io/otel/metric v1.36.0 // indirect
go.opentelemetry.io/otel/sdk v1.36.0 // indirect
go.opentelemetry.io/otel/trace v1.36.0 // indirect
golang.org/x/crypto v0.40.0 // indirect
golang.org/x/net v0.41.0 // indirect
golang.org/x/sys v0.34.0 // indirect
golang.org/x/text v0.27.0 // indirect
google.golang.org/genai v1.30.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
google.golang.org/grpc v1.73.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

52
go.sum
View File

@@ -1,3 +1,9 @@
cloud.google.com/go v0.120.0 h1:wc6bgG9DHyKqF5/vQvX1CiZrtHnxJjBlKUyF9nP6meA=
cloud.google.com/go v0.120.0/go.mod h1:/beW32s8/pGRuj4IILWQNd4uuebeT4dkOhKmkfit64Q=
cloud.google.com/go/auth v0.16.2 h1:QvBAGFPLrDeoiNjyfVunhQ10HKNYuOwZ5noee0M5df4=
cloud.google.com/go/auth v0.16.2/go.mod h1:sRBas2Y1fB1vZTdurouM0AzuYQBMZinrUYL8EufhtEA=
cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU=
cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
@@ -6,6 +12,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/firebase/genkit/go v1.1.0 h1:SQqzQt19gEubvUUCFV98TARFAzD30zT3QhseF3oTKqo=
github.com/firebase/genkit/go v1.1.0/go.mod h1:ru1cIuxG1s3HeUjhnadVveDJ1yhinj+j+uUh0f0pyxE=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -15,18 +23,26 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/goccy/go-yaml v1.17.1 h1:LI34wktB2xEE3ONG/2Ar54+/HJVBriAGJ55PHls4YuY=
github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/dotprompt/go v0.0.0-20251014011017-8d056e027254 h1:okN800+zMJOGHLJCgry+OGzhhtH6YrjQh1rluHmOacE=
github.com/google/dotprompt/go v0.0.0-20251014011017-8d056e027254/go.mod h1:k8cjJAQWc//ac/bMnzItyOFbfT01tgRTZGgxELCuxEQ=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0=
github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -35,8 +51,6 @@ github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mbleigh/raymond v0.0.0-20250414171441-6b3a58ab9e0a h1:v2cBA3xWKv2cIOVhnzX/gNgkNXqiHfUgJtA3r61Hf7A=
github.com/mbleigh/raymond v0.0.0-20250414171441-6b3a58ab9e0a/go.mod h1:Y6ghKH+ZijXn5d9E7qGGZBmjitx7iitZdQiIW97EpTU=
github.com/openai/openai-go v1.8.2 h1:UqSkJ1vCOPUpz9Ka5tS0324EJFEuOvMc+lA/EarJWP8=
github.com/openai/openai-go v1.8.2/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -51,16 +65,6 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
@@ -74,18 +78,38 @@ github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zI
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
google.golang.org/genai v1.30.0 h1:7021aneIvl24nEBLbtQFEWleHsMbjzpcQvkT4WcJ1dc=
google.golang.org/genai v1.30.0/go.mod h1:7pAilaICJlQBonjKKJNhftDFv3SREhZcTe9F6nRcjbg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok=
google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

121
internal/ai/apply.go Normal file
View File

@@ -0,0 +1,121 @@
package ai
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"sort"
"github.com/rogeecn/renamer/internal/ai/flow"
"github.com/rogeecn/renamer/internal/history"
"github.com/rogeecn/renamer/internal/output"
)
// ApplyMetadata captures contextual information persisted alongside ledger entries.
type ApplyMetadata struct {
Prompt string
PromptHistory []string
Notes []string
Model string
SequenceSeparator string
}
// toMap converts metadata into a ledger-friendly map.
func (m ApplyMetadata) toMap(warnings []string) map[string]any {
data := history.BuildAIMetadata(m.Prompt, m.PromptHistory, m.Notes, m.Model, warnings)
if m.SequenceSeparator != "" {
data["sequenceSeparator"] = m.SequenceSeparator
}
return data
}
// Apply executes the rename suggestions, records a ledger entry, and emits progress updates.
func Apply(ctx context.Context, workingDir string, suggestions []flow.Suggestion, validation ValidationResult, meta ApplyMetadata, writer io.Writer) (history.Entry, error) {
entry := history.Entry{Command: "ai"}
if len(suggestions) == 0 {
return entry, nil
}
reporter := output.NewProgressReporter(writer, len(suggestions))
sort.SliceStable(suggestions, func(i, j int) bool {
return suggestions[i].Original > suggestions[j].Original
})
operations := make([]history.Operation, 0, len(suggestions))
revert := func() error {
for i := len(operations) - 1; i >= 0; i-- {
op := operations[i]
source := filepath.Join(workingDir, filepath.FromSlash(op.To))
destination := filepath.Join(workingDir, filepath.FromSlash(op.From))
if err := os.Rename(source, destination); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
}
return nil
}
for _, suggestion := range suggestions {
if err := ctx.Err(); err != nil {
_ = revert()
return history.Entry{}, err
}
fromRel := flowToKey(suggestion.Original)
toRel := flowToKey(suggestion.Suggested)
fromAbs := filepath.Join(workingDir, filepath.FromSlash(fromRel))
toAbs := filepath.Join(workingDir, filepath.FromSlash(toRel))
if fromAbs == toAbs {
continue
}
if err := ensureParentDir(toAbs); err != nil {
_ = revert()
return history.Entry{}, err
}
if err := os.Rename(fromAbs, toAbs); err != nil {
_ = revert()
return history.Entry{}, err
}
operations = append(operations, history.Operation{From: fromRel, To: toRel})
if err := reporter.Step(fromRel, toRel); err != nil {
_ = revert()
return history.Entry{}, err
}
}
if len(operations) == 0 {
return entry, reporter.Complete()
}
if err := reporter.Complete(); err != nil {
_ = revert()
return history.Entry{}, err
}
entry.Operations = operations
entry.Metadata = meta.toMap(validation.Warnings)
if err := history.Append(workingDir, entry); err != nil {
_ = revert()
return history.Entry{}, err
}
return entry, nil
}
func ensureParentDir(path string) error {
dir := filepath.Dir(path)
if dir == "." || dir == "" {
return nil
}
return os.MkdirAll(dir, 0o755)
}

59
internal/ai/client.go Normal file
View File

@@ -0,0 +1,59 @@
package ai
import (
"context"
"errors"
"github.com/rogeecn/renamer/internal/ai/flow"
)
// Runner executes the rename flow and returns structured suggestions.
type Runner func(ctx context.Context, input *flow.RenameFlowInput) (*flow.Output, error)
// Client orchestrates flow invocation for callers such as the CLI command.
type Client struct {
runner Runner
}
// ClientOption customises the AI client behaviour.
type ClientOption func(*Client)
// WithRunner overrides the flow runner implementation (useful for tests).
func WithRunner(r Runner) ClientOption {
return func(c *Client) {
c.runner = r
}
}
// NewClient constructs a Client with the default Genkit-backed runner.
func NewClient(opts ...ClientOption) *Client {
client := &Client{}
client.runner = func(ctx context.Context, input *flow.RenameFlowInput) (*flow.Output, error) {
creds, err := LoadCredentials()
if err != nil {
return nil, err
}
return runRenameFlow(ctx, input, creds)
}
for _, opt := range opts {
opt(client)
}
return client
}
// Suggest executes the rename flow and returns structured suggestions.
func (c *Client) Suggest(ctx context.Context, input *flow.RenameFlowInput) (*flow.Output, error) {
if c == nil {
return nil, ErrClientNotInitialized
}
if c.runner == nil {
return nil, ErrRunnerNotConfigured
}
return c.runner(ctx, input)
}
// ErrClientNotInitialized indicates the client receiver was nil.
var ErrClientNotInitialized = errors.New("ai client not initialized")
// ErrRunnerNotConfigured indicates the client runner is missing.
var ErrRunnerNotConfigured = errors.New("ai client runner not configured")

41
internal/ai/config.go Normal file
View File

@@ -0,0 +1,41 @@
package ai
import (
"errors"
"fmt"
"os"
)
var apiKeyEnvVars = []string{
"GOOGLE_API_KEY",
"GEMINI_API_KEY",
"RENAMER_AI_KEY",
}
// Credentials encapsulates the values required to authenticate with the AI provider.
type Credentials struct {
APIKey string
}
// LoadCredentials returns the AI credentials sourced from environment variables.
func LoadCredentials() (Credentials, error) {
for _, env := range apiKeyEnvVars {
if key, ok := os.LookupEnv(env); ok && key != "" {
return Credentials{APIKey: key}, nil
}
}
return Credentials{}, errors.New("AI provider key missing; set GOOGLE_API_KEY (recommended), GEMINI_API_KEY, or RENAMER_AI_KEY")
}
// MaskedCredentials returns a redacted view of the credentials for logging purposes.
func MaskedCredentials(creds Credentials) string {
if creds.APIKey == "" {
return "(empty)"
}
if len(creds.APIKey) <= 6 {
return "***"
}
return fmt.Sprintf("%s***", creds.APIKey[:3])
}

3
internal/ai/flow/doc.go Normal file
View File

@@ -0,0 +1,3 @@
package flow
// Package flow hosts the Genkit rename flow implementation.

View File

@@ -0,0 +1,7 @@
package flow_test
import "testing"
func TestRenameFlowStub(t *testing.T) {
t.Skip("rename flow implementation pending")
}

50
internal/ai/flow/json.go Normal file
View File

@@ -0,0 +1,50 @@
package flow
import (
"encoding/json"
"errors"
"fmt"
)
// Suggestion represents a single rename mapping emitted by the Genkit flow.
type Suggestion struct {
Original string `json:"original"`
Suggested string `json:"suggested"`
}
// Output wraps the list of suggestions returned by the flow.
type Output struct {
Suggestions []Suggestion `json:"suggestions"`
}
var (
errEmptyResponse = errors.New("genkit flow returned empty response")
errMissingSuggestions = errors.New("genkit flow response missing suggestions")
)
// ParseOutput converts the raw JSON payload into a structured Output.
func ParseOutput(raw []byte) (Output, error) {
if len(raw) == 0 {
return Output{}, errEmptyResponse
}
var out Output
if err := json.Unmarshal(raw, &out); err != nil {
return Output{}, fmt.Errorf("failed to decode genkit output: %w", err)
}
if len(out.Suggestions) == 0 {
return Output{}, errMissingSuggestions
}
return out, nil
}
// MarshalInput serialises the flow input for logging or replay.
func MarshalInput(input any) ([]byte, error) {
buf, err := json.Marshal(input)
if err != nil {
return nil, fmt.Errorf("failed to encode genkit input: %w", err)
}
return buf, nil
}

View File

@@ -0,0 +1,29 @@
你是一个智能文件重命名助手。你的任务是根据用户提供的文件名列表和命名指令,为每个文件生成一个清晰、统一的新名称。
规则:
1. 保持原始文件的扩展名不变。
2. 新文件名中不允许包含非法字符,如 / \\ : * ? \" < > |。
3. 如果需要添加序列号,请先按文件所在的目录维度分组,对每个目录内部的文件进行稳定排序(建议使用原始文件名自然序),序列号放在文件名的开头(例如 "01.假期照片.jpg"),不要放在结尾。序列号和名称之间默认使用句点 (.) 分隔,如果调用方提供了其他分隔符,则使用对应字符。
4. 严格按照以下 JSON 格式返回你的建议,不要包含任何额外的解释或 Markdown 标记。
[INPUT]
用户命名指令: "{{ .UserPrompt }}"
文件名列表:
{{- range .FileNames }}
- {{ . }}
{{- end }}
[OUTPUT]
请在这里输出你的 JSON 结果,格式如下:
{
"suggestions": [
{
"original": "原始文件名1.ext",
"suggested": "建议的新文件名1.ext"
},
{
"original": "原始文件名2.ext",
"suggested": "建议的新文件名2.ext"
}
]
}

View File

@@ -0,0 +1,34 @@
package flow_test
import (
"strings"
"testing"
"github.com/rogeecn/renamer/internal/ai/flow"
)
func TestRenderPromptIncludesFilesAndPrompt(t *testing.T) {
input := flow.RenameFlowInput{
FileNames: []string{"IMG_0001.jpg", "albums/Day 1.png"},
UserPrompt: "按地点重新命名",
}
rendered, err := flow.RenderPrompt(input)
if err != nil {
t.Fatalf("RenderPrompt error: %v", err)
}
for _, expected := range []string{"IMG_0001.jpg", "albums/Day 1.png"} {
if !strings.Contains(rendered, expected) {
t.Fatalf("prompt missing filename %q: %s", expected, rendered)
}
}
if !strings.Contains(rendered, "按地点重新命名") {
t.Fatalf("prompt missing user guidance: %s", rendered)
}
if !strings.Contains(rendered, "suggestions") {
t.Fatalf("prompt missing JSON structure guidance: %s", rendered)
}
}

View File

@@ -0,0 +1,197 @@
package flow
import (
"context"
_ "embed"
"errors"
"fmt"
"path"
"sort"
"strings"
"text/template"
"unicode"
"github.com/firebase/genkit/go/core"
"github.com/firebase/genkit/go/genkit"
)
//go:embed prompt.tmpl
var promptTemplateSource string
var (
promptTemplate = template.Must(template.New("renameFlowPrompt").Parse(promptTemplateSource))
)
// RenameFlowInput mirrors the JSON payload passed into the Genkit flow.
type RenameFlowInput struct {
FileNames []string `json:"fileNames"`
UserPrompt string `json:"userPrompt"`
SequenceSeparator string `json:"sequenceSeparator,omitempty"`
}
// Validate ensures the flow input is well formed.
func (in *RenameFlowInput) Validate() error {
if in == nil {
return errors.New("rename flow input cannot be nil")
}
if len(in.FileNames) == 0 {
return errors.New("no file names provided to rename flow")
}
if len(in.FileNames) > 200 {
return fmt.Errorf("rename flow supports up to 200 files per invocation (received %d)", len(in.FileNames))
}
normalized := make([]string, len(in.FileNames))
for i, name := range in.FileNames {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
return fmt.Errorf("file name at index %d is empty", i)
}
normalized[i] = toSlash(trimmed)
}
// Ensure no duplicates to simplify downstream validation.
if dup := firstDuplicate(normalized); dup != "" {
return fmt.Errorf("duplicate file name %q detected in flow input", dup)
}
in.FileNames = normalized
sep := strings.TrimSpace(in.SequenceSeparator)
if sep == "" {
sep = "."
}
if strings.ContainsAny(sep, "/\\") {
return fmt.Errorf("sequence separator %q cannot contain path separators", sep)
}
if strings.ContainsAny(sep, "\n\r") {
return errors.New("sequence separator cannot contain newline characters")
}
in.SequenceSeparator = sep
return nil
}
// RenderPrompt materialises the prompt template for the provided input.
func RenderPrompt(input RenameFlowInput) (string, error) {
if err := input.Validate(); err != nil {
return "", err
}
var builder strings.Builder
if err := promptTemplate.Execute(&builder, input); err != nil {
return "", fmt.Errorf("render rename prompt: %w", err)
}
return builder.String(), nil
}
// Define registers the rename flow on the supplied Genkit instance.
func Define(g *genkit.Genkit) *core.Flow[*RenameFlowInput, *Output, struct{}] {
if g == nil {
panic("genkit instance cannot be nil")
}
return genkit.DefineFlow(g, "renameFlow", flowFn)
}
func flowFn(ctx context.Context, input *RenameFlowInput) (*Output, error) {
if err := input.Validate(); err != nil {
return nil, err
}
prefix := slugify(input.UserPrompt)
suggestions := make([]Suggestion, 0, len(input.FileNames))
dirCounters := make(map[string]int)
for _, name := range input.FileNames {
suggestion := deterministicSuggestion(name, prefix, dirCounters, input.SequenceSeparator)
suggestions = append(suggestions, Suggestion{
Original: name,
Suggested: suggestion,
})
}
sort.SliceStable(suggestions, func(i, j int) bool {
return suggestions[i].Original < suggestions[j].Original
})
return &Output{Suggestions: suggestions}, nil
}
func deterministicSuggestion(rel string, promptPrefix string, dirCounters map[string]int, separator string) string {
rel = toSlash(rel)
dir := path.Dir(rel)
if dir == "." {
dir = ""
}
base := path.Base(rel)
ext := path.Ext(base)
name := strings.TrimSuffix(base, ext)
sanitizedName := slugify(name)
candidate := sanitizedName
if promptPrefix != "" {
switch {
case candidate == "":
candidate = promptPrefix
default:
candidate = fmt.Sprintf("%s-%s", promptPrefix, candidate)
}
}
if candidate == "" {
candidate = "renamed"
}
counterKey := dir
dirCounters[counterKey]++
seq := dirCounters[counterKey]
sep := separator
if sep == "" {
sep = "."
}
numbered := fmt.Sprintf("%02d%s%s", seq, sep, candidate)
proposed := numbered + ext
if dir != "" {
return path.Join(dir, proposed)
}
return proposed
}
func slugify(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
var b strings.Builder
b.Grow(len(value))
lastHyphen := false
for _, r := range value {
switch {
case unicode.IsLetter(r) || unicode.IsDigit(r):
b.WriteRune(unicode.ToLower(r))
lastHyphen = false
case r == ' ' || r == '-' || r == '_' || r == '.':
if !lastHyphen && b.Len() > 0 {
b.WriteRune('-')
lastHyphen = true
}
}
}
result := strings.Trim(b.String(), "-")
return result
}
func toSlash(pathStr string) string {
return strings.ReplaceAll(pathStr, "\\", "/")
}
func firstDuplicate(values []string) string {
seen := make(map[string]struct{}, len(values))
for _, v := range values {
lower := strings.ToLower(v)
if _, exists := seen[lower]; exists {
return v
}
seen[lower] = struct{}{}
}
return ""
}

55
internal/ai/preview.go Normal file
View File

@@ -0,0 +1,55 @@
package ai
import (
"fmt"
"io"
"github.com/rogeecn/renamer/internal/ai/flow"
"github.com/rogeecn/renamer/internal/output"
)
// PrintPreview renders suggestions in a tabular format alongside validation results.
func PrintPreview(w io.Writer, suggestions []flow.Suggestion, validation ValidationResult) error {
table := output.NewAIPlanTable()
if err := table.Begin(w); err != nil {
return err
}
for idx, suggestion := range suggestions {
if err := table.WriteRow(output.AIPlanRow{
Sequence: fmt.Sprintf("%02d", idx+1),
Original: suggestion.Original,
Proposed: suggestion.Suggested,
Sanitized: flowToKey(suggestion.Suggested),
}); err != nil {
return err
}
}
if err := table.End(w); err != nil {
return err
}
for _, warn := range validation.Warnings {
if _, err := fmt.Fprintf(w, "Warning: %s\n", warn); err != nil {
return err
}
}
if len(validation.Conflicts) > 0 {
if _, err := fmt.Fprintln(w, "Conflicts detected:"); err != nil {
return err
}
for _, conflict := range validation.Conflicts {
if _, err := fmt.Fprintf(w, " - %s -> %s (%s)\n", conflict.Original, conflict.Suggested, conflict.Reason); err != nil {
return err
}
}
}
if _, err := fmt.Fprintf(w, "Previewed %d suggestion(s)\n", len(suggestions)); err != nil {
return err
}
return nil
}

54
internal/ai/runtime.go Normal file
View File

@@ -0,0 +1,54 @@
package ai
import (
"context"
"os"
"sync"
"github.com/firebase/genkit/go/core"
"github.com/firebase/genkit/go/genkit"
"github.com/firebase/genkit/go/plugins/googlegenai"
"google.golang.org/genai"
"github.com/rogeecn/renamer/internal/ai/flow"
)
var (
runtimeOnce sync.Once
runtimeErr error
runtimeFlow *core.Flow[*flow.RenameFlowInput, *flow.Output, struct{}]
)
func ensureRuntime(creds Credentials) error {
runtimeOnce.Do(func() {
ctx := context.Background()
geminiBase := os.Getenv("GOOGLE_GEMINI_BASE_URL")
vertexBase := os.Getenv("GOOGLE_VERTEX_BASE_URL")
if geminiBase != "" || vertexBase != "" {
genai.SetDefaultBaseURLs(genai.BaseURLParameters{
GeminiURL: geminiBase,
VertexURL: vertexBase,
})
}
plugin := &googlegenai.GoogleAI{APIKey: creds.APIKey}
g := genkit.Init(ctx,
genkit.WithPlugins(plugin),
genkit.WithDefaultModel(defaultModelID),
)
runtimeFlow = flow.Define(g)
})
return runtimeErr
}
func runRenameFlow(ctx context.Context, input *flow.RenameFlowInput, creds Credentials) (*flow.Output, error) {
if err := ensureRuntime(creds); err != nil {
return nil, err
}
if runtimeFlow == nil {
return nil, runtimeErr
}
return runtimeFlow.Run(ctx, input)
}

138
internal/ai/session.go Normal file
View File

@@ -0,0 +1,138 @@
package ai
import (
"context"
"strings"
flowpkg "github.com/rogeecn/renamer/internal/ai/flow"
)
const defaultModelID = "googleai/gemini-1.5-flash"
// Session tracks prompt history and guidance notes for a single AI preview loop.
type Session struct {
files []string
client *Client
prompts []string
notes []string
model string
sequenceSeparator string
lastOutput *flowpkg.Output
lastValidation ValidationResult
}
// NewSession builds a session with the provided scope, initial prompt, and client.
func NewSession(files []string, initialPrompt string, sequenceSeparator string, client *Client) *Session {
prompts := []string{strings.TrimSpace(initialPrompt)}
if prompts[0] == "" {
prompts[0] = ""
}
if client == nil {
client = NewClient()
}
sep := strings.TrimSpace(sequenceSeparator)
if sep == "" {
sep = "."
}
return &Session{
files: append([]string(nil), files...),
client: client,
prompts: prompts,
notes: make([]string, 0),
model: defaultModelID,
sequenceSeparator: sep,
}
}
// Generate executes the flow and returns structured suggestions with validation.
func (s *Session) Generate(ctx context.Context) (*flowpkg.Output, ValidationResult, error) {
prompt := s.CurrentPrompt()
input := &flowpkg.RenameFlowInput{
FileNames: append([]string(nil), s.files...),
UserPrompt: prompt,
SequenceSeparator: s.sequenceSeparator,
}
output, err := s.client.Suggest(ctx, input)
if err != nil {
return nil, ValidationResult{}, err
}
validation := ValidateSuggestions(s.files, output.Suggestions)
s.lastOutput = output
s.lastValidation = validation
return output, validation, nil
}
// CurrentPrompt returns the most recent prompt in the session.
func (s *Session) CurrentPrompt() string {
if len(s.prompts) == 0 {
return ""
}
return s.prompts[len(s.prompts)-1]
}
// UpdatePrompt records a new prompt and adds a note for auditing.
func (s *Session) UpdatePrompt(prompt string) {
trimmed := strings.TrimSpace(prompt)
s.prompts = append(s.prompts, trimmed)
s.notes = append(s.notes, "prompt updated")
}
// RecordRegeneration appends an audit note for regenerations.
func (s *Session) RecordRegeneration() {
s.notes = append(s.notes, "regenerated suggestions")
}
// RecordAcceptance stores an audit note for accepted previews.
func (s *Session) RecordAcceptance() {
s.notes = append(s.notes, "accepted preview")
}
// PromptHistory returns a copy of the recorded prompts.
func (s *Session) PromptHistory() []string {
history := make([]string, len(s.prompts))
copy(history, s.prompts)
return history
}
// Notes returns audit notes collected during the session.
func (s *Session) Notes() []string {
copied := make([]string, len(s.notes))
copy(copied, s.notes)
return copied
}
// Files returns the original scoped filenames.
func (s *Session) Files() []string {
copied := make([]string, len(s.files))
copy(copied, s.files)
return copied
}
// SequenceSeparator returns the configured sequence separator.
func (s *Session) SequenceSeparator() string {
return s.sequenceSeparator
}
// LastOutput returns the most recent flow output.
func (s *Session) LastOutput() *flowpkg.Output {
return s.lastOutput
}
// LastValidation returns the validation result for the most recent output.
func (s *Session) LastValidation() ValidationResult {
return s.lastValidation
}
// Model returns the model identifier associated with the session.
func (s *Session) Model() string {
if s.model == "" {
return defaultModelID
}
return s.model
}

169
internal/ai/validation.go Normal file
View File

@@ -0,0 +1,169 @@
package ai
import (
"fmt"
"path"
"strings"
"github.com/rogeecn/renamer/internal/ai/flow"
)
var invalidCharacters = []rune{'/', '\\', ':', '*', '?', '"', '<', '>', '|'}
// Conflict captures a validation failure for a proposed rename.
type Conflict struct {
Original string
Suggested string
Reason string
}
// ValidationResult aggregates conflicts and warnings.
type ValidationResult struct {
Conflicts []Conflict
Warnings []string
}
// ValidateSuggestions enforces rename safety rules before applying suggestions.
func ValidateSuggestions(expected []string, suggestions []flow.Suggestion) ValidationResult {
result := ValidationResult{}
expectedSet := make(map[string]struct{}, len(expected))
for _, name := range expected {
expectedSet[strings.ToLower(flowToKey(name))] = struct{}{}
}
seenTargets := make(map[string]string)
for _, suggestion := range suggestions {
key := strings.ToLower(flowToKey(suggestion.Original))
if _, ok := expectedSet[key]; !ok {
result.Conflicts = append(result.Conflicts, Conflict{
Original: suggestion.Original,
Suggested: suggestion.Suggested,
Reason: "original file not present in scope",
})
continue
}
cleaned := strings.TrimSpace(suggestion.Suggested)
if cleaned == "" {
result.Conflicts = append(result.Conflicts, Conflict{
Original: suggestion.Original,
Suggested: suggestion.Suggested,
Reason: "suggested name is empty",
})
continue
}
normalizedOriginal := flowToKey(suggestion.Original)
normalizedSuggested := flowToKey(cleaned)
if strings.HasPrefix(normalizedSuggested, "/") {
result.Conflicts = append(result.Conflicts, Conflict{
Original: suggestion.Original,
Suggested: suggestion.Suggested,
Reason: "suggested name must be relative",
})
continue
}
if containsParentSegment(normalizedSuggested) {
result.Conflicts = append(result.Conflicts, Conflict{
Original: suggestion.Original,
Suggested: suggestion.Suggested,
Reason: "suggested name cannot traverse directories",
})
continue
}
base := path.Base(cleaned)
if containsInvalidCharacter(base) {
result.Conflicts = append(result.Conflicts, Conflict{
Original: suggestion.Original,
Suggested: suggestion.Suggested,
Reason: "suggested name contains invalid characters",
})
continue
}
if !extensionsMatch(suggestion.Original, cleaned) {
result.Conflicts = append(result.Conflicts, Conflict{
Original: suggestion.Original,
Suggested: suggestion.Suggested,
Reason: "file extension changed",
})
continue
}
if path.Dir(normalizedOriginal) != path.Dir(normalizedSuggested) {
result.Warnings = append(result.Warnings, fmt.Sprintf("suggestion for %q moves file to a different directory", suggestion.Original))
}
targetKey := strings.ToLower(normalizedSuggested)
if existing, ok := seenTargets[targetKey]; ok && existing != suggestion.Original {
result.Conflicts = append(result.Conflicts, Conflict{
Original: suggestion.Original,
Suggested: suggestion.Suggested,
Reason: "duplicate target generated",
})
continue
}
seenTargets[targetKey] = suggestion.Original
if normalizedOriginal == normalizedSuggested {
result.Warnings = append(result.Warnings, fmt.Sprintf("suggestion for %q does not change the filename", suggestion.Original))
}
}
if len(suggestions) != len(expected) {
result.Warnings = append(result.Warnings, fmt.Sprintf("expected %d suggestions but received %d", len(expected), len(suggestions)))
}
return result
}
func flowToKey(value string) string {
return strings.ReplaceAll(strings.TrimSpace(value), "\\", "/")
}
func containsInvalidCharacter(value string) bool {
for _, ch := range invalidCharacters {
if strings.ContainsRune(value, ch) {
return true
}
}
return false
}
func extensionsMatch(original, proposed string) bool {
origExt := strings.ToLower(path.Ext(original))
propExt := strings.ToLower(path.Ext(proposed))
return origExt == propExt
}
// SummarizeConflicts renders a human-readable summary of conflicts.
func SummarizeConflicts(conflicts []Conflict) string {
if len(conflicts) == 0 {
return ""
}
builder := strings.Builder{}
for _, c := range conflicts {
builder.WriteString(fmt.Sprintf("%s -> %s (%s); ", c.Original, c.Suggested, c.Reason))
}
return strings.TrimSpace(builder.String())
}
// SummarizeWarnings renders warnings as a delimited string.
func SummarizeWarnings(warnings []string) string {
return strings.Join(warnings, "; ")
}
func containsParentSegment(value string) bool {
parts := strings.Split(value, "/")
for _, part := range parts {
if part == ".." {
return true
}
}
return false
}

View File

@@ -0,0 +1,21 @@
package history
// BuildAIMetadata constructs ledger metadata for AI-driven rename batches.
func BuildAIMetadata(prompt string, promptHistory []string, notes []string, model string, warnings []string) map[string]any {
data := map[string]any{
"prompt": prompt,
"model": model,
"flow": "renameFlow",
"warnings": warnings,
}
if len(promptHistory) > 0 {
data["promptHistory"] = append([]string(nil), promptHistory...)
}
if len(notes) > 0 {
data["notes"] = append([]string(nil), notes...)
}
return data
}

View File

@@ -0,0 +1,40 @@
package output
import (
"fmt"
"io"
)
// ProgressReporter prints textual progress for rename operations.
type ProgressReporter struct {
writer io.Writer
total int
count int
}
// NewProgressReporter constructs a reporter bound to the supplied writer.
func NewProgressReporter(w io.Writer, total int) *ProgressReporter {
if w == nil {
w = io.Discard
}
return &ProgressReporter{writer: w, total: total}
}
// Step registers a completed operation and prints the progress.
func (r *ProgressReporter) Step(from, to string) error {
if r == nil {
return nil
}
r.count++
_, err := fmt.Fprintf(r.writer, "[%d/%d] %s -> %s\n", r.count, r.total, from, to)
return err
}
// Complete emits a summary line after all operations finish.
func (r *ProgressReporter) Complete() error {
if r == nil {
return nil
}
_, err := fmt.Fprintf(r.writer, "Completed %d rename(s).\n", r.count)
return err
}

View File

@@ -0,0 +1,34 @@
# Specification Quality Checklist: AI-Assisted Rename Command
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2025-11-05
**Feature**: [Link to spec.md](/home/yanghao/projects/renamer/specs/008-ai-rename-command/spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- All criteria satisfied.

View File

@@ -0,0 +1,93 @@
openapi: 3.1.0
info:
title: Genkit renameFlow Contract
version: 0.1.0
description: >
Contract for the `renameFlow` Genkit workflow that produces structured rename
suggestions consumed by the `renamer ai` CLI command.
servers:
- url: genkit://renameFlow
description: Logical identifier for local Genkit execution.
paths:
/renameFlow: # logical entry point (function invocation)
post:
summary: Generate rename suggestions for provided file names.
description: Mirrors `genkit.Run(ctx, renameFlow, input)` in the CLI integration.
operationId: runRenameFlow
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RenameFlowInput'
responses:
'200':
description: Successful rename suggestion payload.
content:
application/json:
schema:
$ref: '#/components/schemas/RenameFlowOutput'
'400':
description: Validation error (e.g., invalid filenames, mismatched counts).
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
components:
schemas:
RenameFlowInput:
type: object
required:
- fileNames
- userPrompt
properties:
fileNames:
type: array
description: Ordered list of basenames collected from CLI traversal.
minItems: 1
maxItems: 200
items:
type: string
pattern: '^[^\\/:*?"<>|]+$'
userPrompt:
type: string
description: Optional guidance supplied by the user.
minLength: 0
maxLength: 500
RenameFlowOutput:
type: object
required:
- suggestions
properties:
suggestions:
type: array
description: Suggested rename entries aligned with the input order.
minItems: 1
items:
$ref: '#/components/schemas/RenameSuggestion'
RenameSuggestion:
type: object
required:
- original
- suggested
properties:
original:
type: string
description: Original basename supplied in the request.
pattern: '^[^\\/:*?"<>|]+$'
suggested:
type: string
description: Proposed basename retaining original extension.
pattern: '^[^\\/:*?"<>|]+$'
ErrorResponse:
type: object
required:
- error
properties:
error:
type: string
description: Human-readable reason for failure.
remediation:
type: string
description: Suggested user action (e.g., adjust scope, reduce file count).

View File

@@ -0,0 +1,62 @@
# Data Model Genkit renameFlow & AI CLI
## Entity: RenameFlowInput
- **Fields**
- `fileNames []string` — Ordered list of basenames collected from scope traversal.
- `userPrompt string` — Optional user guidance merged into the prompt template.
- **Validation Rules**
- Require at least one filename; enforce maximum of 200 per invocation (soft limit before batching).
- Reject names containing path separators; traversal supplies basenames only.
- Trim whitespace from `userPrompt`; clamp length (e.g., 1500 characters) to guard against prompt injection.
- **Relationships**
- Serialized to JSON and passed into `genkit.Generate()` as the model input payload.
- Logged with invocation metadata to support replay/debugging.
## Entity: RenameFlowOutput
- **Fields**
- `suggestions []RenameSuggestion` — AI-produced rename pairs in same order as input list when possible.
- **Validation Rules**
- `len(suggestions)` MUST equal length of input `fileNames` before approval.
- Each suggestion MUST pass filename safety checks (see `RenameSuggestion`).
- JSON payload MUST parse cleanly with no additional top-level properties.
- **Relationships**
- Returned to the CLI bridge, transformed into preview rows and ledger entries.
## Entity: RenameSuggestion
- **Fields**
- `original string` — Original basename (must match an item from input list).
- `suggested string` — Proposed basename with identical extension as `original`.
- **Validation Rules**
- Preserve extension suffix (text after last `.`); fail if changed or removed.
- Disallow illegal filesystem characters: `/ \ : * ? " < > |` and control bytes.
- Enforce case-insensitive uniqueness across all `suggested` values to avoid collisions.
- Reject empty or whitespace-only suggestions; trim incidental spaces.
- **Relationships**
- Consumed by preview renderer to display mappings.
- Persisted in ledger metadata alongside user prompt and model ID.
## Entity: AISuggestionBatch (Go side)
- **Fields**
- `Scope traversal.ScopeResult` — Snapshot of files selected for AI processing.
- `Prompt string` — Rendered prompt sent to Genkit (stored for debugging).
- `ModelID string` — Identifier for the AI model used during generation.
- `Suggestions []RenameSuggestion` — Parsed results aligned with scope entries.
- `Warnings []string` — Issues detected during validation (duplicates, unchanged names, limit truncation).
- **Validation Rules**
- Warnings that correspond to hard failures (duplicate targets, invalid characters) block apply until resolved.
- Scope result order MUST align with suggestion order to keep preview deterministic.
- **Relationships**
- Passed into output renderer for table display.
- Written to ledger with `history.RecordBatch` for undo.
## Entity: FlowInvocationLog
- **Fields**
- `InvocationID string` — UUID tying output to ledger entry.
- `Timestamp time.Time` — Invocation time for audit trail.
- `Duration time.Duration` — Round-trip latency for success criteria tracking.
- `InputSize int` — Number of filenames processed (used for batching heuristics).
- `Errors []string` — Captured model or validation errors.
- **Validation Rules**
- Duration recorded only on successful completions; errors populated otherwise.
- **Relationships**
- Optional: appended to debug logs or analytics for performance monitoring (non-ledger).

View File

@@ -0,0 +1,90 @@
# Implementation Plan: AI-Assisted Rename Command
**Branch**: `008-ai-rename-command` | **Date**: 2025-11-05 | **Spec**: `specs/008-ai-rename-command/spec.md`
**Input**: Feature specification from `/specs/008-ai-rename-command/spec.md`
**Note**: This plan grounds the `/speckit.plan` prompt “Genkit Flow 设计 (Genkit Flow Design)” by detailing how the CLI and Genkit workflow collaborate to deliver structured AI rename suggestions.
## Summary
Design and implement a `renameFlow` Genkit workflow that produces deterministic, JSON-formatted rename suggestions and wire it into a new `renamer ai` CLI path. The plan covers prompt templating, JSON validation, scope handling parity with existing commands, preview/confirmation UX, ledger integration, and fallback/error flows to keep AI-generated batches auditable and undoable.
## Technical Context
**Language/Version**: Go 1.24 (CLI + Genkit workflow)
**Primary Dependencies**: `spf13/cobra`, `spf13/pflag`, internal traversal/history/output packages, `github.com/firebase/genkit/go`, OpenAI-compatible provider bridge
**Storage**: Local filesystem plus append-only `.renamer` ledger
**Testing**: `go test ./...` including flow unit tests for prompt/render/validation, contract + integration tests under `tests/`
**Target Platform**: Cross-platform CLI executed from local shells; Genkit workflow runs in-process via Go bindings
**Project Type**: Single Go CLI project with additional internal AI packages
**Performance Goals**: Generate rename suggestions for ≤200 files within 30 seconds end-to-end (per SC-001)
**Constraints**: Preview-first safety, undoable ledger entries, scope parity with existing commands, deterministic JSON responses, offline fallback excluded (network required)
**Scale/Scope**: Handles hundreds of files per invocation, with potential thousands when batched; assumes human-in-the-loop confirmation
## Constitution Check
- Preview flow MUST show deterministic rename mappings and require explicit confirmation (Preview-First Safety). ✅ `renamer ai` reuses preview renderer to display AI suggestions, blocks apply until `--yes` or interactive confirmation, and supports `--dry-run`.
- Undo strategy MUST describe how the `.renamer` ledger entry is written and reversed (Persistent Undo Ledger). ✅ Accepted batches append AI metadata (prompt, model, rationale) to ledger entries; undo replays via existing ledger service with no schema break.
- Planned rename rules MUST document their inputs, validations, and composing order (Composable Rule Engine). ✅ `renameFlow` enforces rename suggestion structure (original, suggested), keeps extensions intact, and CLI validates conflicts before applying.
- Scope handling MUST cover files vs directories (`-d`), recursion (`-r`), and extension filtering via `-e` without escaping the requested path (Scope-Aware Traversal). ✅ CLI gathers scope using shared traversal component, honoring existing flags before passing filenames to Genkit.
- CLI UX plan MUST confirm Cobra usage, flag naming, help text, and automated tests for preview/undo flows (Ergonomic CLI Stewardship). ✅ New `ai` command extends Cobra root with existing persistent flags, adds prompt/model overrides, and includes contract + integration coverage for preview/apply/undo.
## Project Structure
### Documentation (this feature)
```text
specs/008-ai-rename-command/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
└── spec.md
```
### Source Code (repository root)
```text
cmd/
├── root.go
├── ai.go # new Cobra command wiring + RunE
├── list.go
├── replace.go
├── remove.go
└── undo.go
internal/
├── ai/
│ ├── flow/
│ │ ├── rename_flow.go # Genkit flow definition using Go SDK
│ │ └── prompt.tmpl # prompt template with rules/formatting
│ ├── client.go # wraps Genkit invocation + response handling
│ ├── preview.go # maps RenameSuggestion -> preview rows
│ ├── validation.go # conflict + filename safety checks
│ └── session.go # manages user guidance refinements
├── traversal/
├── output/
├── history/
└── ...
tests/
├── contract/
│ └── ai_command_preview_test.go # ensures JSON contract adherence
├── integration/
│ └── ai_flow_apply_test.go # preview, confirm, undo happy path
└── fixtures/
└── ai/
└── sample_photos/ # test assets for AI rename flows
scripts/
└── smoke-test-ai.sh # optional future smoke harness (planned)
```
**Structure Decision**: Implement the Genkit `renameFlow` directly within Go (`internal/ai/flow`) while reusing shared traversal/output pipelines through the new `ai` command. Tests mirror existing command coverage with contract and integration suites.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|--------------------------------------|
| _None_ | — | — |

View File

@@ -0,0 +1,29 @@
# Quickstart AI Rename Command
1. **Preview AI suggestions before applying.**
```bash
renamer ai --path ./photos --prompt "Hawaii vacation album"
```
- Traverses `./photos` (non-recursive by default) and sends the collected basenames to `renameFlow`.
- Displays a preview table with original → suggested names and any validation warnings.
2. **Adjust scope or guidance and regenerate.**
```bash
renamer ai --path ./photos --recursive --hidden \
--prompt "Group by location, keep capture order"
```
- `--recursive` includes nested folders; `--hidden` opts in hidden files.
- Re-running the command with updated guidance regenerates suggestions without modifying files.
3. **Apply suggestions non-interactively when satisfied.**
```bash
renamer ai --path ./photos --prompt "Hawaii vacation" --yes
```
- `--yes` skips the interactive confirmation while still logging the preview.
- Use `--dry-run` to inspect output programmatically without touching the filesystem.
4. **Undo the most recent AI batch if needed.**
```bash
renamer undo
```
- Restores original filenames using the ledger entry created by the AI command.

View File

@@ -0,0 +1,21 @@
# Phase 0 Research Genkit renameFlow
## Decision: Enforce JSON-Only Responses via Prompt + Guardrails
- **Rationale**: The CLI must parse deterministic structures. Embedding an explicit JSON schema example, restating illegal character rules, and wrapping the Genkit call with `OutputJSON()` (or equivalent) reduces hallucinated prose and aligns with ledger needs.
- **Alternatives considered**: Post-processing free-form text was rejected because it increases parsing failures and weakens auditability. Relaxing constraints to “JSON preferred” was rejected to avoid brittle regex extraction.
## Decision: Keep Prompt Template as External File with Go Template Variables
- **Rationale**: Storing the prompt under `internal/ai/flow/prompt.tmpl` keeps localization and iteration separate from code. Using Go-style templating enables the flow to substitute the file list and user prompt consistently while making it easier to unit test rendered prompts.
- **Alternatives considered**: Hardcoding prompt strings inside the Go flow was rejected due to limited reuse and poor readability; using Markdown-based prompts was rejected because the model might echo formatting in its response.
## Decision: Invoke Genkit Flow In-Process via Go SDK
- **Rationale**: The spec emphasizes local filesystem workflows without network services. Using the Genkit Go SDK keeps execution in-process, avoids packaging a separate runtime, and fits CLI invocation patterns.
- **Alternatives considered**: Hosting a long-lived HTTP service was rejected because it complicates installation and violates the local-only assumption. Spawning an external Node process was rejected due to additional toolchain requirements.
## Decision: Validate Suggestions Against Existing Filename Rules Before Apply
- **Rationale**: Even with JSON enforcement, the model could suggest duplicates, rename directories, or remove extensions. Reusing internal validation logic ensures suggestions honor filesystem invariants and matches ledger expectations before touching disk.
- **Alternatives considered**: Trusting AI output without local validation was rejected due to risk of destructive renames. Silently auto-correcting invalid names was rejected because it obscures AI behavior from users.
## Decision: Align Testing with Contract + Golden Prompt Fixtures
- **Rationale**: Contract tests with fixed model responses (via canned JSON) allow deterministic verification, while golden prompt fixtures ensure template rendering matches expectations. This combo offers coverage without depending on live AI calls in CI.
- **Alternatives considered**: Live integration tests hitting the model were rejected due to cost, flakiness, and determinism concerns. Pure unit tests without prompt verification were rejected because prompt regressions directly impact model quality.

View File

@@ -0,0 +1,102 @@
# Feature Specification: AI-Assisted Rename Command
**Feature Branch**: `008-ai-rename-command`
**Created**: 2025-11-05
**Status**: Draft
**Input**: User description: "添加 ai 子命令使用go genkit 调用ai能力对文件列表进行重命名。"
## Clarifications
### Session 2025-11-05
- Q: How should the CLI handle filename privacy when calling the AI service? → A: Send raw filenames without masking.
- Q: How should the AI provider credential be supplied to the CLI? → A: Read from an environment variable (e.g., `RENAMER_AI_KEY`).
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Request AI rename plan (Priority: P1)
As a command-line user, I can request AI-generated rename suggestions for a set of files so that I get a consistent naming plan without defining rules manually.
**Why this priority**: This delivers the core value of leveraging AI to save time on naming decisions for large batches.
**Independent Test**: Execute the AI rename command against a sample directory and verify a preview of suggested names is produced without altering files.
**Acceptance Scenarios**:
1. **Given** a directory with mixed file names and optional user instructions, **When** the user runs the AI rename command, **Then** the tool returns a preview mapping each original name to a suggested name.
2. **Given** a scope that includes hidden files when the user enables the corresponding flag, **When** the AI rename command runs, **Then** the preview reflects only the files allowed by the selected scope options.
---
### User Story 2 - Refine and confirm suggestions (Priority: P2)
As a command-line user, I can review, adjust, or regenerate AI suggestions before applying them so that I have control over the final names.
**Why this priority**: Users need confidence and agency to ensure AI suggestions match their intent, reducing the risk of undesired renames.
**Independent Test**: Run the AI rename command, adjust the instruction text, regenerate suggestions, and confirm the tool updates the preview without applying changes until approval.
**Acceptance Scenarios**:
1. **Given** an initial AI preview, **When** the user supplies new guidance or rejects the batch, **Then** the tool allows a regeneration or cancellation without renaming any files.
2. **Given** highlighted conflicts or invalid suggestions in the preview, **When** the user attempts to accept the batch, **Then** the tool blocks execution and instructs the user to resolve the issues.
---
### User Story 3 - Apply and audit AI renames (Priority: P3)
As a command-line user, I can apply approved AI rename suggestions and rely on the existing history and undo mechanisms so that AI-driven batches are traceable and reversible.
**Why this priority**: Preserving auditability and undo aligns AI-driven actions with existing safety guarantees.
**Independent Test**: Accept an AI rename batch, verify files are renamed, the ledger records the operation, and the undo command restores originals.
**Acceptance Scenarios**:
1. **Given** an approved AI rename preview, **When** the user confirms execution, **Then** the files are renamed and the batch details are recorded in the ledger with AI-specific metadata.
2. **Given** an executed AI rename batch, **When** the user runs the undo command, **Then** all affected files return to their original names and the ledger reflects the reversal.
---
### Edge Cases
- AI service fails, times out, or returns an empty response; the command must preserve current filenames and surface actionable error guidance.
- The AI proposes duplicate, conflicting, or filesystem-invalid names; the preview must flag each item and prevent application until resolved.
- The selected scope includes more files than the AI request limit; the command must communicate limits and guide the user to narrow the scope or batch the request.
- The ledger already contains pending batches for the same files; the tool must clarify how the new AI batch interacts with existing history before proceeding.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: The CLI must gather the current file scope using existing flags and present the selected files and optional instructions to the AI suggestion service.
- **FR-002**: The system must generate a human-readable preview that pairs each original filename with the AI-proposed name and indicates the rationale or confidence when available.
- **FR-003**: The CLI must block application when the preview contains conflicts, invalid names, or missing suggestions and must explain the required corrective actions.
- **FR-004**: Users must be able to modify guidance and request a new set of AI suggestions without leaving the command until they accept or exit.
- **FR-005**: When users approve a preview, the tool must execute the rename batch, record it in the ledger with the user guidance and AI attribution, and support undo via the existing command.
- **FR-006**: The command must support dry-run mode that exercises the AI interaction and preview without writing to disk, clearly labeling the output as non-destructive.
- **FR-007**: The system must handle AI service errors gracefully by retaining current filenames, logging diagnostic information, and providing retry instructions.
### Key Entities
- **AISuggestionBatch**: Captures the scope summary, user guidance, timestamp, AI provider metadata, and the list of rename suggestions evaluated during a session.
- **RenameSuggestion**: Represents a single proposed change with original name, suggested name, validation status, and optional rationale.
- **UserGuidance**: Stores free-form instructions supplied by the user, including any follow-up refinements applied within the session.
## Assumptions
- AI rename suggestions are generated within existing rate limits; large directories may require the user to split the work manually.
- Users running the AI command have network access and credentials required to reach the AI service.
- Existing ledger and undo mechanisms remain unchanged and can store additional metadata without format migrations.
- AI requests transmit the original filenames without masking; users must avoid including sensitive names when invoking the command.
- The CLI reads AI provider credentials from environment variables (default `RENAMER_AI_KEY`); no interactive credential prompts are provided.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: 95% of AI rename previews for up to 200 files complete in under 30 seconds from command invocation.
- **SC-002**: 90% of accepted AI rename batches complete without conflicts or manual post-fix adjustments reported by users.
- **SC-003**: 100% of AI-driven rename batches remain fully undoable via the existing undo command.
- **SC-004**: In post-launch surveys, at least 80% of participating users report that AI suggestions improved their rename workflow efficiency.

View File

@@ -0,0 +1,119 @@
# Tasks: AI-Assisted Rename Command
**Input**: Design documents from `/specs/008-ai-rename-command/`
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
- Include exact file paths in descriptions
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Add Go-based Genkit dependency and scaffold AI flow package.
- [x] T001 Ensure Genkit Go module dependency (`github.com/firebase/genkit/go`) is present in `go.mod` / `go.sum`
- [x] T002 Create AI flow package directories in `internal/ai/flow/`
- [x] T003 [P] Add Go test harness scaffold for AI flow in `internal/ai/flow/flow_test.go`
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Provide shared assets and configuration required by all user stories.
- [x] T004 Author AI rename prompt template with JSON instructions in `internal/ai/flow/prompt.tmpl`
- [x] T005 Implement reusable JSON parsing helpers for Genkit responses in `internal/ai/flow/json.go`
- [x] T006 Implement AI credential loader reading `RENAMER_AI_KEY` in `internal/ai/config.go`
- [x] T007 Register the `ai` Cobra command scaffold in `cmd/root.go`
---
## Phase 3: User Story 1 - Request AI rename plan (Priority: P1) 🎯 MVP
**Goal**: Allow users to preview AI-generated rename suggestions for a scoped set of files.
**Independent Test**: Run `renamer ai --path <dir> --dry-run` and verify the preview table lists original → suggested names without renaming files.
### Tests for User Story 1
- [x] T008 [P] [US1] Add prompt rendering unit test covering file list formatting in `internal/ai/flow/prompt_test.go`
- [x] T009 [P] [US1] Create CLI preview contract test enforcing JSON schema in `tests/contract/ai_command_preview_test.go`
### Implementation for User Story 1
- [x] T010 [US1] Implement `renameFlow` Genkit workflow with JSON-only response in `internal/ai/flow/rename_flow.go`
- [x] T011 [P] [US1] Build Genkit client wrapper and response parser in `internal/ai/client.go`
- [x] T012 [P] [US1] Implement suggestion validation rules (extensions, duplicates, illegal chars) in `internal/ai/validation.go`
- [x] T013 [US1] Map AI suggestions to preview rows with rationale fields in `internal/ai/preview.go`
- [x] T014 [US1] Wire `renamer ai` command to gather scope, invoke AI flow, and render preview in `cmd/ai.go`
- [x] T015 [US1] Document preview usage and flags for `renamer ai` in `docs/cli-flags.md`
---
## Phase 4: User Story 2 - Refine and confirm suggestions (Priority: P2)
**Goal**: Let users iterate on AI guidance, regenerate suggestions, and resolve conflicts before applying changes.
**Independent Test**: Run `renamer ai` twice with updated prompts, confirm regenerated preview replaces the previous batch, and verify conflicting targets block approval with actionable warnings.
### Tests for User Story 2
- [x] T016 [P] [US2] Add integration test for preview regeneration and cancel flow in `tests/integration/ai_preview_regen_test.go`
### Implementation for User Story 2
- [x] T017 [US2] Extend interactive loop in `cmd/ai.go` to support prompt refinement and regeneration commands
- [x] T018 [P] [US2] Enhance conflict and warning annotations for regenerated suggestions in `internal/ai/validation.go`
- [x] T019 [US2] Persist per-session prompt history and guidance notes in `internal/ai/session.go`
---
## Phase 5: User Story 3 - Apply and audit AI renames (Priority: P3)
**Goal**: Execute approved AI rename batches, record them in the ledger, and ensure undo restores originals.
**Independent Test**: Accept an AI preview with `--yes`, verify files are renamed, ledger entry captures AI metadata, and `renamer undo` restores originals.
### Tests for User Story 3
- [X] T020 [P] [US3] Add integration test covering apply + undo lifecycle in `tests/integration/ai_flow_apply_test.go`
- [X] T021 [P] [US3] Add ledger contract test verifying AI metadata persistence in `tests/contract/ai_ledger_entry_test.go`
### Implementation for User Story 3
- [X] T022 [US3] Implement confirm/apply execution path with `--yes` handling in `cmd/ai.go`
- [X] T023 [P] [US3] Append AI batch metadata to ledger entries in `internal/history/ai_entry.go`
- [X] T024 [US3] Ensure undo replay reads AI ledger metadata in `internal/history/undo.go`
- [X] T025 [US3] Display progress and per-file outcomes during apply in `internal/output/progress.go`
---
## Phase 6: Polish & Cross-Cutting
**Purpose**: Final quality improvements, docs, and operational readiness.
- [ ] T026 Add smoke test script invoking `renamer ai` preview/apply flows in `scripts/smoke-test-ai.sh`
- [X] T027 Update top-level documentation with AI command overview and credential requirements in `README.md`
---
## Dependencies
- Complete Phases 1 → 2 before starting user stories.
- User Story order: US1 → US2 → US3 (each builds on prior capabilities).
- Polish tasks run after all user stories are feature-complete.
## Parallel Execution Opportunities
- US1: T011 and T012 can run in parallel after T010 completes.
- US2: T018 can run in parallel with T017 once session loop scaffolding exists.
- US3: T023 and T025 can proceed concurrently after T022 defines apply workflow.
## Implementation Strategy
1. Deliver User Story 1 as the MVP (preview-only experience).
2. Iterate on refinement workflow (User Story 2) to reduce risk of bad suggestions before apply.
3. Add apply + ledger integration (User Story 3) to complete end-to-end flow.
4. Finish with polish tasks to solidify operational readiness.

View File

@@ -0,0 +1,48 @@
package contract
import (
"bytes"
"path/filepath"
"strings"
"testing"
"github.com/rogeecn/renamer/cmd"
)
func TestAICommandPreviewTable(t *testing.T) {
t.Setenv("RENAMER_AI_KEY", "test-key")
tmp := t.TempDir()
createFile(t, filepath.Join(tmp, "IMG_0001.jpg"))
createFile(t, filepath.Join(tmp, "trip-notes.txt"))
root := cmd.NewRootCommand()
root.SetArgs([]string{"ai", "--path", tmp, "--prompt", "Travel Memories", "--dry-run"})
var buf bytes.Buffer
root.SetIn(strings.NewReader("\n"))
root.SetOut(&buf)
root.SetErr(&buf)
if err := root.Execute(); err != nil {
t.Fatalf("ai command returned error: %v\noutput: %s", err, buf.String())
}
output := buf.String()
if !strings.Contains(output, "IMG_0001.jpg") {
t.Fatalf("expected original filename in preview, got: %s", output)
}
if !strings.Contains(output, "trip-notes.txt") {
t.Fatalf("expected secondary filename in preview, got: %s", output)
}
if !strings.Contains(output, "01.travel-memories-img-0001.jpg") {
t.Fatalf("expected deterministic suggestion in preview, got: %s", output)
}
if !strings.Contains(output, "02.travel-memories-trip-notes.txt") {
t.Fatalf("expected sequential suggestion for second file, got: %s", output)
}
}

View File

@@ -0,0 +1,68 @@
package contract
import (
"context"
"io"
"path/filepath"
"testing"
"github.com/rogeecn/renamer/internal/ai"
"github.com/rogeecn/renamer/internal/ai/flow"
)
func TestAIMetadataPersistedInLedgerEntry(t *testing.T) {
t.Setenv("RENAMER_AI_KEY", "test-key")
tmp := t.TempDir()
createFile(t, filepath.Join(tmp, "clip.mov"))
suggestions := []flow.Suggestion{
{Original: "clip.mov", Suggested: "highlight-01.mov"},
}
validation := ai.ValidateSuggestions([]string{"clip.mov"}, suggestions)
if len(validation.Conflicts) != 0 {
t.Fatalf("expected no conflicts, got %#v", validation)
}
entry, err := ai.Apply(context.Background(), tmp, suggestions, validation, ai.ApplyMetadata{
Prompt: "Highlight Reel",
PromptHistory: []string{"Highlight Reel", "Celebration Cut"},
Notes: []string{"accepted preview"},
Model: "googleai/gemini-1.5-flash",
SequenceSeparator: "_",
}, io.Discard)
if err != nil {
t.Fatalf("apply error: %v", err)
}
if entry.Command != "ai" {
t.Fatalf("expected command 'ai', got %q", entry.Command)
}
if entry.Metadata == nil {
t.Fatalf("expected metadata to be recorded")
}
if got := entry.Metadata["prompt"]; got != "Highlight Reel" {
t.Fatalf("unexpected prompt metadata: %#v", got)
}
history, ok := entry.Metadata["promptHistory"].([]string)
if !ok || len(history) != 2 {
t.Fatalf("unexpected prompt history: %#v", entry.Metadata["promptHistory"])
}
model, _ := entry.Metadata["model"].(string)
if model == "" {
t.Fatalf("expected model metadata to be present")
}
if sep, ok := entry.Metadata["sequenceSeparator"].(string); !ok || sep != "_" {
t.Fatalf("expected sequence separator metadata, got %#v", entry.Metadata["sequenceSeparator"])
}
if _, err := ai.Apply(context.Background(), tmp, suggestions, validation, ai.ApplyMetadata{Prompt: "irrelevant"}, io.Discard); err == nil {
t.Fatalf("expected error when renaming non-existent file")
}
}

View File

@@ -0,0 +1,82 @@
package integration
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
renamercmd "github.com/rogeecn/renamer/cmd"
"github.com/rogeecn/renamer/internal/history"
)
func TestAIRenameApplyAndUndo(t *testing.T) {
t.Setenv("RENAMER_AI_KEY", "test-key")
tmp := t.TempDir()
createFile(t, filepath.Join(tmp, "IMG_2001.jpg"))
createFile(t, filepath.Join(tmp, "session-notes.txt"))
root := renamercmd.NewRootCommand()
root.SetArgs([]string{"ai", "--path", tmp, "--prompt", "Album Shots", "--yes"})
root.SetIn(strings.NewReader(""))
var output bytes.Buffer
root.SetOut(&output)
root.SetErr(&output)
if err := root.Execute(); err != nil {
t.Fatalf("ai command returned error: %v\noutput: %s", err, output.String())
}
ledgerPath := filepath.Join(tmp, ".renamer")
data, err := os.ReadFile(ledgerPath)
if err != nil {
t.Fatalf("read ledger: %v", err)
}
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
if len(lines) == 0 {
t.Fatalf("expected ledger entries")
}
var entry history.Entry
if err := json.Unmarshal([]byte(lines[len(lines)-1]), &entry); err != nil {
t.Fatalf("decode entry: %v", err)
}
if entry.Command != "ai" {
t.Fatalf("expected command 'ai', got %q", entry.Command)
}
if len(entry.Operations) != 2 {
t.Fatalf("expected 2 operations recorded, got %d", len(entry.Operations))
}
if entry.Metadata == nil || entry.Metadata["prompt"] != "Album Shots" {
t.Fatalf("expected prompt metadata recorded, got %#v", entry.Metadata)
}
if sep, ok := entry.Metadata["sequenceSeparator"].(string); !ok || sep != "." {
t.Fatalf("expected sequence separator metadata, got %#v", entry.Metadata["sequenceSeparator"])
}
for _, op := range entry.Operations {
dest := filepath.Join(tmp, filepath.FromSlash(op.To))
if _, err := os.Stat(dest); err != nil {
t.Fatalf("expected destination %q to exist: %v", dest, err)
}
}
undoCmd := renamercmd.NewRootCommand()
undoCmd.SetArgs([]string{"undo", "--path", tmp})
undoCmd.SetIn(strings.NewReader(""))
undoCmd.SetOut(&output)
undoCmd.SetErr(&output)
if err := undoCmd.Execute(); err != nil {
t.Fatalf("undo command error: %v\noutput: %s", err, output.String())
}
if _, err := os.Stat(filepath.Join(tmp, "IMG_2001.jpg")); err != nil {
t.Fatalf("expected original root file restored: %v", err)
}
if _, err := os.Stat(filepath.Join(tmp, "session-notes.txt")); err != nil {
t.Fatalf("expected original secondary file restored: %v", err)
}
}

View File

@@ -0,0 +1,46 @@
package integration
import (
"bytes"
"path/filepath"
"strings"
"testing"
"github.com/rogeecn/renamer/cmd"
)
func TestAICommandSupportsPromptRefinement(t *testing.T) {
t.Setenv("RENAMER_AI_KEY", "test-key")
tmp := t.TempDir()
createFile(t, filepath.Join(tmp, "IMG_1024.jpg"))
createFile(t, filepath.Join(tmp, "notes/day1.txt"))
root := cmd.NewRootCommand()
root.SetArgs([]string{"ai", "--path", tmp})
// Simulate editing the prompt then quitting.
var output bytes.Buffer
input := strings.NewReader("e\nVacation Highlights\nq\n")
root.SetIn(input)
root.SetOut(&output)
root.SetErr(&output)
if err := root.Execute(); err != nil {
t.Fatalf("ai command returned error: %v\noutput: %s", err, output.String())
}
got := output.String()
if !strings.Contains(got, "Current prompt: \"Vacation Highlights\"") {
t.Fatalf("expected updated prompt in output, got: %s", got)
}
if !strings.Contains(got, "01.vacation-highlights-img-1024.jpg") {
t.Fatalf("expected regenerated suggestion with new prefix, got: %s", got)
}
if !strings.Contains(got, "Session ended without applying changes.") {
t.Fatalf("expected session completion message, got: %s", got)
}
}