refactor-cli 0.1.2__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {refactor_cli-0.1.2 → refactor_cli-0.2.0}/.gitignore +4 -0
  2. refactor_cli-0.2.0/PKG-INFO +107 -0
  3. refactor_cli-0.2.0/README.md +95 -0
  4. {refactor_cli-0.1.2 → refactor_cli-0.2.0}/pyproject.toml +1 -1
  5. refactor_cli-0.2.0/refactor/cli.py +291 -0
  6. refactor_cli-0.2.0/refactor/config.py +77 -0
  7. refactor_cli-0.2.0/refactor/context_store.py +48 -0
  8. refactor_cli-0.2.0/refactor/gemini.py +103 -0
  9. refactor_cli-0.2.0/refactor/hardware.py +86 -0
  10. refactor_cli-0.2.0/refactor/image_scanner.py +39 -0
  11. refactor_cli-0.2.0/refactor/imagga.py +186 -0
  12. refactor_cli-0.2.0/refactor/organizer.py +111 -0
  13. refactor_cli-0.2.0/refactor/setup.py +175 -0
  14. refactor_cli-0.2.0/refactor/shim.py +73 -0
  15. refactor_cli-0.2.0/refactor_cli.egg-info/PKG-INFO +107 -0
  16. {refactor_cli-0.1.2 → refactor_cli-0.2.0}/refactor_cli.egg-info/SOURCES.txt +11 -0
  17. refactor_cli-0.2.0/refactor_cli.egg-info/requires.txt +3 -0
  18. refactor_cli-0.2.0/refactor_cli.egg-info/scm_file_list.json +26 -0
  19. refactor_cli-0.2.0/refactor_cli.egg-info/scm_version.json +8 -0
  20. refactor_cli-0.1.2/PKG-INFO +0 -11
  21. refactor_cli-0.1.2/README.md +0 -1
  22. refactor_cli-0.1.2/refactor/cli.py +0 -56
  23. refactor_cli-0.1.2/refactor_cli.egg-info/PKG-INFO +0 -11
  24. refactor_cli-0.1.2/refactor_cli.egg-info/requires.txt +0 -1
  25. {refactor_cli-0.1.2 → refactor_cli-0.2.0}/.github/workflows/publish.yml +0 -0
  26. {refactor_cli-0.1.2 → refactor_cli-0.2.0}/.refactorignore +0 -0
  27. {refactor_cli-0.1.2 → refactor_cli-0.2.0}/LICENSE +0 -0
  28. {refactor_cli-0.1.2 → refactor_cli-0.2.0}/refactor/__init__.py +0 -0
  29. {refactor_cli-0.1.2 → refactor_cli-0.2.0}/refactor/ignore.py +0 -0
  30. {refactor_cli-0.1.2 → refactor_cli-0.2.0}/refactor/metadata.py +0 -0
  31. {refactor_cli-0.1.2 → refactor_cli-0.2.0}/refactor/scanner.py +0 -0
  32. {refactor_cli-0.1.2 → refactor_cli-0.2.0}/refactor/tree.py +0 -0
  33. {refactor_cli-0.1.2 → refactor_cli-0.2.0}/refactor/validation.py +0 -0
  34. {refactor_cli-0.1.2 → refactor_cli-0.2.0}/refactor_cli.egg-info/dependency_links.txt +0 -0
  35. {refactor_cli-0.1.2 → refactor_cli-0.2.0}/refactor_cli.egg-info/entry_points.txt +0 -0
  36. {refactor_cli-0.1.2 → refactor_cli-0.2.0}/refactor_cli.egg-info/top_level.txt +0 -0
  37. {refactor_cli-0.1.2 → refactor_cli-0.2.0}/setup.cfg +0 -0
@@ -1,3 +1,5 @@
1
+ notes.md
2
+ testing-dir
1
3
  # Byte-compiled / optimized / DLL files
2
4
  __pycache__/
3
5
  *.py[codz]
@@ -138,6 +140,8 @@ celerybeat.pid
138
140
  .env
139
141
  .envrc
140
142
  .venv
143
+ .venv-pypi
144
+
141
145
  env/
142
146
  venv/
143
147
  ENV/
@@ -0,0 +1,107 @@
1
+ Metadata-Version: 2.4
2
+ Name: refactor-cli
3
+ Version: 0.2.0
4
+ Summary: CLI tool to organize files using AI
5
+ Requires-Python: >=3.8
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENSE
8
+ Requires-Dist: typer
9
+ Requires-Dist: requests
10
+ Requires-Dist: python-dotenv
11
+ Dynamic: license-file
12
+
13
+ # refactor.ai
14
+
15
+ `refactor.ai` is a Python CLI for inspecting local directories before automating file organization. The package is published as [`refactor-cli`](https://pypi.org/project/refactor-cli/) and targets Python 3.8+.
16
+
17
+ ## Current scope
18
+
19
+ The CLI entry point in [refactor/cli.py](refactor/cli.py) provides:
20
+
21
+ - `version` — prints the installed package version.
22
+ - `tree` — prints a recursive directory tree through [refactor/tree.py](refactor/tree.py).
23
+ - `scan` — collects basic file and directory metadata through [refactor/scanner.py](refactor/scanner.py) and [refactor/metadata.py](refactor/metadata.py).
24
+ - `setup` — configures a multimodal backend (`hosted` or `local`), persisted to `~/.refactor/config.json`.
25
+ - `get-image-context` — obtains Imagga V3 tags/captions, asks Gemini to choose a
26
+ destination category, and writes JSON context.
27
+ - `refactor-images` — automatically refreshes image context, then moves images
28
+ into Gemini-selected existing or new category folders (`--copy` is available
29
+ when the originals must be retained).
30
+ - Path checks through [refactor/validation.py](refactor/validation.py), including existence, directory type, and read access.
31
+ - Optional filename exclusions through [refactor/ignore.py](refactor/ignore.py).
32
+
33
+ On first CLI run, users are prompted to run setup. They can rerun setup anytime with `refactor setup` or edit `~/.refactor/config.json` directly.
34
+
35
+ ## Setup workflow
36
+
37
+ After installing `refactor-cli`, the first CLI run prompts:
38
+
39
+ 1. **Hosted model (bring your own API keys)**
40
+ - Prompts for `IMAGGA_API_KEY`, `IMAGGA_API_SECRET`, and `GEMINI_API_KEY`
41
+ - Saves credentials to `~/.refactor/.env` with restricted file permissions
42
+ 2. **Local multimodal recommendation (stub)**
43
+ - Detects basic hardware (CPU, memory, NVIDIA GPU/VRAM if available)
44
+ - Recommends an OSS multimodal model and records it in config
45
+ - Uses a local shim stub today (full local runtime integration is a follow-up)
46
+
47
+ For the hosted workflow, local images are uploaded to Imagga and classified with
48
+ the V3 `/tags` endpoint using `model=light` and `include_caption=true`. The
49
+ resulting tags/caption and the destination root's current folders are sent to
50
+ Gemini. Gemini selects one safe category name per image, reusing a suitable
51
+ folder where possible and naming a new category otherwise. The organizer creates
52
+ that directory as needed. The shim layer provides one interface for image
53
+ classification, regardless of hosted/local backend, so future modalities can
54
+ reuse the same abstraction.
55
+
56
+ Run `refactor refactor-images ./incoming --output-dir ./organized` to generate
57
+ fresh context and move the images. Add `--dry-run` to inspect destinations or
58
+ `--copy` to retain the originals.
59
+
60
+ ## Techniques used
61
+
62
+ - **Recursive traversal with `pathlib`** — [refactor/scanner.py](refactor/scanner.py) uses [`Path.rglob()`](https://docs.python.org/3/library/pathlib.html#pathlib.Path.rglob) to discover nested paths. [refactor/tree.py](refactor/tree.py) recursively walks directory entries to render a text tree.
63
+
64
+ - **Filesystem metadata from stat data** — [refactor/metadata.py](refactor/metadata.py) reads [`Path.stat()`](https://docs.python.org/3/library/pathlib.html#pathlib.Path.stat) and combines it with path properties such as `name`, `suffix`, and `is_dir()`.
65
+
66
+ - **Glob-based ignore matching** — [refactor/ignore.py](refactor/ignore.py) loads patterns from an optional `.refactorignore` file and evaluates them with Python’s [`fnmatch`](https://docs.python.org/3/library/fnmatch.html). This keeps ignore rules lightweight while supporting familiar wildcard patterns.
67
+
68
+ - **Fail-fast input validation** — [refactor/validation.py](refactor/validation.py) validates paths before traversal and checks read permissions with [`os.access()`](https://docs.python.org/3/library/os.html#os.access). CLI commands convert validation failures into clear non-zero exits.
69
+
70
+ - **Declarative command registration** — [refactor/cli.py](refactor/cli.py) uses decorators to register commands on a Typer application. The package exports the `refactor` command through the console-script entry point in [pyproject.toml](pyproject.toml).
71
+
72
+ - **Versioning from Git tags** — [pyproject.toml](pyproject.toml) delegates package version discovery to [`setuptools-scm`](https://setuptools-scm.readthedocs.io/). Release versions can come from repository tags instead of being duplicated in source files.
73
+
74
+ ## Technologies and libraries
75
+
76
+ - [Typer](https://typer.tiangolo.com/) — CLI framework built around Python type hints. It provides command dispatch, help output, and exit handling.
77
+ - [setuptools](https://setuptools.pypa.io/) — package build backend.
78
+ - [setuptools-scm](https://setuptools-scm.readthedocs.io/) — derives the distribution version from source-control metadata.
79
+ - [pathlib](https://docs.python.org/3/library/pathlib.html) — standard-library object model for portable path operations.
80
+ - [importlib.metadata](https://docs.python.org/3/library/importlib.metadata.html) — reads the installed package version at runtime.
81
+
82
+ The project currently has no web UI, image assets, or bundled fonts.
83
+
84
+ ## Project structure
85
+
86
+ ```text
87
+ .
88
+ ├── LICENSE
89
+ ├── README.md
90
+ ├── pyproject.toml
91
+ └── refactor/
92
+ ```
93
+
94
+ - [refactor/](refactor/) contains the CLI and filesystem logic.
95
+ - [pyproject.toml](pyproject.toml) defines packaging metadata, the `refactor` command, supported Python version, and build configuration.
96
+ - [LICENSE](LICENSE) contains the Apache License 2.0 terms.
97
+
98
+ ## Next steps
99
+
100
+ The package metadata describes the broader goal as AI-assisted file organization. The next development work can build on the current inspection layer:
101
+
102
+ - Add an AI integration that proposes organization decisions from scanned metadata.
103
+ - Add a review step so users can inspect proposed changes before any write operation.
104
+ - Implement explicit rename and move operations with dry-run support.
105
+ - Expand ignore handling beyond filename-only matching where needed.
106
+ - Add automated tests for traversal, ignore patterns, validation, and planned write operations.
107
+ - Provide structured scan output for scripts and other tooling.
@@ -0,0 +1,95 @@
1
+ # refactor.ai
2
+
3
+ `refactor.ai` is a Python CLI for inspecting local directories before automating file organization. The package is published as [`refactor-cli`](https://pypi.org/project/refactor-cli/) and targets Python 3.8+.
4
+
5
+ ## Current scope
6
+
7
+ The CLI entry point in [refactor/cli.py](refactor/cli.py) provides:
8
+
9
+ - `version` — prints the installed package version.
10
+ - `tree` — prints a recursive directory tree through [refactor/tree.py](refactor/tree.py).
11
+ - `scan` — collects basic file and directory metadata through [refactor/scanner.py](refactor/scanner.py) and [refactor/metadata.py](refactor/metadata.py).
12
+ - `setup` — configures a multimodal backend (`hosted` or `local`), persisted to `~/.refactor/config.json`.
13
+ - `get-image-context` — obtains Imagga V3 tags/captions, asks Gemini to choose a
14
+ destination category, and writes JSON context.
15
+ - `refactor-images` — automatically refreshes image context, then moves images
16
+ into Gemini-selected existing or new category folders (`--copy` is available
17
+ when the originals must be retained).
18
+ - Path checks through [refactor/validation.py](refactor/validation.py), including existence, directory type, and read access.
19
+ - Optional filename exclusions through [refactor/ignore.py](refactor/ignore.py).
20
+
21
+ On first CLI run, users are prompted to run setup. They can rerun setup anytime with `refactor setup` or edit `~/.refactor/config.json` directly.
22
+
23
+ ## Setup workflow
24
+
25
+ After installing `refactor-cli`, the first CLI run prompts:
26
+
27
+ 1. **Hosted model (bring your own API keys)**
28
+ - Prompts for `IMAGGA_API_KEY`, `IMAGGA_API_SECRET`, and `GEMINI_API_KEY`
29
+ - Saves credentials to `~/.refactor/.env` with restricted file permissions
30
+ 2. **Local multimodal recommendation (stub)**
31
+ - Detects basic hardware (CPU, memory, NVIDIA GPU/VRAM if available)
32
+ - Recommends an OSS multimodal model and records it in config
33
+ - Uses a local shim stub today (full local runtime integration is a follow-up)
34
+
35
+ For the hosted workflow, local images are uploaded to Imagga and classified with
36
+ the V3 `/tags` endpoint using `model=light` and `include_caption=true`. The
37
+ resulting tags/caption and the destination root's current folders are sent to
38
+ Gemini. Gemini selects one safe category name per image, reusing a suitable
39
+ folder where possible and naming a new category otherwise. The organizer creates
40
+ that directory as needed. The shim layer provides one interface for image
41
+ classification, regardless of hosted/local backend, so future modalities can
42
+ reuse the same abstraction.
43
+
44
+ Run `refactor refactor-images ./incoming --output-dir ./organized` to generate
45
+ fresh context and move the images. Add `--dry-run` to inspect destinations or
46
+ `--copy` to retain the originals.
47
+
48
+ ## Techniques used
49
+
50
+ - **Recursive traversal with `pathlib`** — [refactor/scanner.py](refactor/scanner.py) uses [`Path.rglob()`](https://docs.python.org/3/library/pathlib.html#pathlib.Path.rglob) to discover nested paths. [refactor/tree.py](refactor/tree.py) recursively walks directory entries to render a text tree.
51
+
52
+ - **Filesystem metadata from stat data** — [refactor/metadata.py](refactor/metadata.py) reads [`Path.stat()`](https://docs.python.org/3/library/pathlib.html#pathlib.Path.stat) and combines it with path properties such as `name`, `suffix`, and `is_dir()`.
53
+
54
+ - **Glob-based ignore matching** — [refactor/ignore.py](refactor/ignore.py) loads patterns from an optional `.refactorignore` file and evaluates them with Python’s [`fnmatch`](https://docs.python.org/3/library/fnmatch.html). This keeps ignore rules lightweight while supporting familiar wildcard patterns.
55
+
56
+ - **Fail-fast input validation** — [refactor/validation.py](refactor/validation.py) validates paths before traversal and checks read permissions with [`os.access()`](https://docs.python.org/3/library/os.html#os.access). CLI commands convert validation failures into clear non-zero exits.
57
+
58
+ - **Declarative command registration** — [refactor/cli.py](refactor/cli.py) uses decorators to register commands on a Typer application. The package exports the `refactor` command through the console-script entry point in [pyproject.toml](pyproject.toml).
59
+
60
+ - **Versioning from Git tags** — [pyproject.toml](pyproject.toml) delegates package version discovery to [`setuptools-scm`](https://setuptools-scm.readthedocs.io/). Release versions can come from repository tags instead of being duplicated in source files.
61
+
62
+ ## Technologies and libraries
63
+
64
+ - [Typer](https://typer.tiangolo.com/) — CLI framework built around Python type hints. It provides command dispatch, help output, and exit handling.
65
+ - [setuptools](https://setuptools.pypa.io/) — package build backend.
66
+ - [setuptools-scm](https://setuptools-scm.readthedocs.io/) — derives the distribution version from source-control metadata.
67
+ - [pathlib](https://docs.python.org/3/library/pathlib.html) — standard-library object model for portable path operations.
68
+ - [importlib.metadata](https://docs.python.org/3/library/importlib.metadata.html) — reads the installed package version at runtime.
69
+
70
+ The project currently has no web UI, image assets, or bundled fonts.
71
+
72
+ ## Project structure
73
+
74
+ ```text
75
+ .
76
+ ├── LICENSE
77
+ ├── README.md
78
+ ├── pyproject.toml
79
+ └── refactor/
80
+ ```
81
+
82
+ - [refactor/](refactor/) contains the CLI and filesystem logic.
83
+ - [pyproject.toml](pyproject.toml) defines packaging metadata, the `refactor` command, supported Python version, and build configuration.
84
+ - [LICENSE](LICENSE) contains the Apache License 2.0 terms.
85
+
86
+ ## Next steps
87
+
88
+ The package metadata describes the broader goal as AI-assisted file organization. The next development work can build on the current inspection layer:
89
+
90
+ - Add an AI integration that proposes organization decisions from scanned metadata.
91
+ - Add a review step so users can inspect proposed changes before any write operation.
92
+ - Implement explicit rename and move operations with dry-run support.
93
+ - Expand ignore handling beyond filename-only matching where needed.
94
+ - Add automated tests for traversal, ignore patterns, validation, and planned write operations.
95
+ - Provide structured scan output for scripts and other tooling.
@@ -4,7 +4,7 @@ dynamic = ["version"]
4
4
  description = "CLI tool to organize files using AI"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.8"
7
- dependencies = ["typer"]
7
+ dependencies = ["typer", "requests", "python-dotenv"]
8
8
 
9
9
  [project.scripts]
10
10
  refactor = "refactor.cli:app"
@@ -0,0 +1,291 @@
1
+ import typer
2
+ from importlib.metadata import version as get_version
3
+
4
+ from refactor.context_store import DEFAULT_CACHE_FILE, save_context
5
+ from refactor.ignore import load_ignore_patterns
6
+ from refactor.image_scanner import scan_images
7
+ from refactor.metadata import get_metadata
8
+ from refactor.organizer import (
9
+ execute_moves,
10
+ managed_category_dirs,
11
+ plan_moves,
12
+ save_managed_category_dirs,
13
+ )
14
+ from refactor.scanner import scan_directory
15
+ from refactor.setup import command_setup, ensure_backend_configured, maybe_run_first_time_setup
16
+ from refactor.shim import build_image_classifier, choose_category_folders
17
+ from refactor.tree import print_tree
18
+ from refactor.validation import validate_path
19
+
20
+ app = typer.Typer(no_args_is_help=True)
21
+
22
+
23
+ @app.callback()
24
+ def bootstrap() -> None:
25
+ maybe_run_first_time_setup()
26
+
27
+
28
+ @app.command()
29
+ def version() -> None:
30
+ """Print version information"""
31
+ print(get_version("refactor-cli"))
32
+
33
+
34
+ @app.command()
35
+ def setup() -> None:
36
+ """Configure hosted or local multimodal backend."""
37
+ command_setup()
38
+
39
+
40
+ @app.command()
41
+ def tree(path: str = ".") -> None:
42
+ """Display folder structure of given path"""
43
+ try:
44
+ validate_path(path)
45
+ print_tree(path)
46
+ except ValueError as e:
47
+ typer.echo(f"Error: {e}")
48
+ raise typer.Exit(code=1)
49
+
50
+
51
+ @app.command()
52
+ def scan(path: str = ".") -> None:
53
+ """Scan directory and display metadata"""
54
+ try:
55
+ validate_path(path)
56
+ patterns = load_ignore_patterns()
57
+ items = scan_directory(path, patterns)
58
+
59
+ for item in items:
60
+ print(get_metadata(item))
61
+
62
+ except ValueError as e:
63
+ typer.echo(f"Error: {e}")
64
+ raise typer.Exit(code=1)
65
+
66
+
67
+ @app.command(name="get-image-context")
68
+ def get_image_context(
69
+ path: str = typer.Argument(".", help="Directory to scan for images"),
70
+ output: str = typer.Option(
71
+ ".refactor_image_context.json",
72
+ "--output",
73
+ "-o",
74
+ help="Path to write the JSON context cache",
75
+ ),
76
+ min_confidence: float = typer.Option(
77
+ 40.0,
78
+ "--min-confidence",
79
+ help="Minimum tag confidence to include (hosted backend only)",
80
+ ),
81
+ verbose: bool = typer.Option(
82
+ False,
83
+ "--verbose",
84
+ "-v",
85
+ help="Print per-image tag results to stdout",
86
+ ),
87
+ category_root: str = typer.Option(
88
+ ".",
89
+ "--category-root",
90
+ help="Directory whose existing folders Gemini may reuse",
91
+ ),
92
+ write_context: bool = typer.Option(
93
+ True,
94
+ "--write-context/--no-write-context",
95
+ hidden=True,
96
+ ),
97
+ ) -> list:
98
+ """
99
+ Scan directory images using configured backend shim and save
100
+ tags/captions to JSON context cache.
101
+ """
102
+ from pathlib import Path
103
+
104
+ try:
105
+ validate_path(path)
106
+ if not Path(category_root).exists() or not Path(category_root).is_dir():
107
+ raise ValueError(f"Category root is not a directory: {category_root}")
108
+ config = ensure_backend_configured()
109
+ except (ValueError, EnvironmentError) as e:
110
+ typer.echo(f"Error: {e}", err=True)
111
+ raise typer.Exit(code=1)
112
+
113
+ patterns = load_ignore_patterns()
114
+ source_root = Path(path).resolve()
115
+ destination_root = Path(category_root).resolve()
116
+ excluded_dirs = (
117
+ managed_category_dirs(destination_root)
118
+ if source_root == destination_root else set()
119
+ )
120
+ images = scan_images(path, patterns, excluded_dirs)
121
+
122
+ if not images:
123
+ typer.echo("No image files found in the specified directory.")
124
+ raise typer.Exit(code=0)
125
+
126
+ typer.echo(f"Found {len(images)} image(s) in '{path}'")
127
+
128
+ try:
129
+ classifier = build_image_classifier(config, min_confidence=min_confidence)
130
+ except EnvironmentError as e:
131
+ typer.echo(f"Error: {e}", err=True)
132
+ raise typer.Exit(code=1)
133
+
134
+ results = []
135
+ failed = 0
136
+
137
+ with typer.progressbar(images, label="Analysing images") as progress:
138
+ for img_path in progress:
139
+ try:
140
+ ctx = classifier.classify_image(img_path)
141
+ results.append(ctx)
142
+
143
+ except Exception as exc:
144
+ typer.echo(f"\n [!] Skipping {img_path.name}: {exc}", err=True)
145
+ failed += 1
146
+
147
+ if results:
148
+ try:
149
+ results = choose_category_folders(config, results, Path(category_root))
150
+ except Exception as exc:
151
+ # Folder selection is an essential part of the hosted workflow; do
152
+ # not silently fall back to an Imagga top-tag directory.
153
+ typer.echo(f"Error: Gemini folder selection failed: {exc}", err=True)
154
+ raise typer.Exit(code=1)
155
+
156
+ if verbose:
157
+ for ctx in results:
158
+ typer.echo(f"\n {Path(ctx.file_path).name}")
159
+ typer.echo(f" Folder : {ctx.primary_folder}")
160
+ typer.echo(f" Tags : {', '.join(ctx.tags[:5])}")
161
+ typer.echo(f" Caption : {ctx.caption}")
162
+
163
+ output_path = Path(output)
164
+ if write_context:
165
+ save_context(results, path, output_path)
166
+
167
+ typer.echo(f"\n{'─' * 52}")
168
+ typer.echo(f" Processed : {len(results)} image(s)")
169
+ if failed:
170
+ typer.echo(f" Failed : {failed} image(s)")
171
+ if write_context:
172
+ typer.echo(f" Cache : {output_path.resolve()}")
173
+ typer.echo(f"{'─' * 52}")
174
+
175
+ if results:
176
+ typer.echo(f"\n{'Filename':<30} {'Folder':<20} {'Top tags'}")
177
+ typer.echo("─" * 80)
178
+ for ctx in results:
179
+ fname = Path(ctx.file_path).name
180
+ top_tags = ", ".join(ctx.tags[:3])
181
+ typer.echo(f" {fname:<28} {ctx.primary_folder:<20} {top_tags}")
182
+
183
+ return results
184
+
185
+
186
+ @app.command(name="refactor-images")
187
+ def refactor_images(
188
+ path: str = typer.Argument(".", help="Directory containing images to organize"),
189
+ context: str = typer.Option(
190
+ ".refactor_image_context.json",
191
+ "--context",
192
+ "-c",
193
+ help="Path to context cache (defaults to the source directory)",
194
+ ),
195
+ output_dir: str = typer.Option(
196
+ ".",
197
+ "--output-dir",
198
+ "-d",
199
+ help="Root directory where tag-named folders will be created",
200
+ ),
201
+ dry_run: bool = typer.Option(
202
+ False,
203
+ "--dry-run",
204
+ help="Print the planned moves without executing them",
205
+ ),
206
+ move: bool = typer.Option(
207
+ True,
208
+ "--move/--copy",
209
+ help="Move files into categories. Use --copy to preserve source files.",
210
+ ),
211
+ min_confidence: float = typer.Option(
212
+ 40.0,
213
+ "--min-confidence",
214
+ help="Minimum Imagga tag confidence to retain",
215
+ ),
216
+ ) -> None:
217
+ """
218
+ Generate fresh Imagga and Gemini context, then organise images into
219
+ Gemini-selected subdirectories. The context cache is refreshed on every run.
220
+
221
+ By default, images are MOVED. Use --copy to preserve source files.
222
+ """
223
+ from pathlib import Path
224
+ try:
225
+ validate_path(path)
226
+ out = Path(output_dir)
227
+ if not out.exists() or not out.is_dir():
228
+ raise ValueError(f"Output directory is not a directory: {output_dir}")
229
+ except ValueError as e:
230
+ typer.echo(f"Error: {e}", err=True)
231
+ raise typer.Exit(code=1)
232
+
233
+ context_path = Path(context)
234
+ if context == DEFAULT_CACHE_FILE:
235
+ context_path = Path(path) / DEFAULT_CACHE_FILE
236
+
237
+ excluded = managed_category_dirs(out) if Path(path).resolve() == out.resolve() else set()
238
+ if excluded:
239
+ typer.echo(f"Skipping {len(excluded)} folder(s) created by an earlier Refactor run.")
240
+
241
+ typer.echo("Generating image context with Imagga and Gemini...")
242
+ contexts = get_image_context(
243
+ path=path,
244
+ output=str(context_path),
245
+ min_confidence=min_confidence,
246
+ verbose=False,
247
+ category_root=output_dir,
248
+ write_context=not dry_run,
249
+ )
250
+
251
+ if not contexts:
252
+ typer.echo("Context cache is empty. Nothing to organize.")
253
+ raise typer.Exit(code=0)
254
+
255
+ moves = plan_moves(contexts, out)
256
+
257
+ action_word = "Move" if move else "Copy"
258
+
259
+ if dry_run:
260
+ verb_plural = "moves" if move else "copies"
261
+ verb_past = "moved" if move else "copied"
262
+ typer.echo(f"\n[Dry Run] Planned {verb_plural} ({len(moves)} files):\n")
263
+ typer.echo(f" {'Source':<40} {'Destination'}")
264
+ typer.echo(" " + "─" * 76)
265
+ for src, dst in moves:
266
+ typer.echo(f" {src.name:<40} {dst}")
267
+ typer.echo(f"\n No files were {verb_past}.")
268
+ raise typer.Exit(code=0)
269
+
270
+ folders = {dst.parent.name for _, dst in moves}
271
+ typer.echo(f"\nWill {action_word.lower()} {len(moves)} image(s) into {len(folders)} folder(s):")
272
+ for folder in sorted(folders):
273
+ count = sum(1 for _, dst in moves if dst.parent.name == folder)
274
+ typer.echo(f" {out / folder} ({count} file(s))")
275
+
276
+ summary = execute_moves(moves, copy=not move)
277
+ if not summary["failed"]:
278
+ save_managed_category_dirs(out, folders)
279
+
280
+ typer.echo(f"\n{'─' * 52}")
281
+ count = summary["moved"] if move else summary["copied"]
282
+ verb_past = "moved" if move else "copied"
283
+ typer.echo(f" {verb_past.capitalize():<8}: {count} file(s)")
284
+ if summary["failed"]:
285
+ typer.echo(f" Failed : {summary['failed']} file(s)")
286
+ typer.echo(f" Output : {out.resolve()}")
287
+ typer.echo(f"{'─' * 52}")
288
+
289
+
290
+ if __name__ == "__main__":
291
+ app()
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ CONFIG_DIR = Path.home() / ".refactor"
8
+ CONFIG_FILE = CONFIG_DIR / "config.json"
9
+ ENV_FILE = CONFIG_DIR / ".env"
10
+
11
+
12
+ def _ensure_config_dir() -> None:
13
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
14
+
15
+
16
+ def default_config() -> dict[str, Any]:
17
+ return {
18
+ "version": 1,
19
+ "backend": "unconfigured",
20
+ "setup_skipped": False,
21
+ "hosted": {
22
+ "provider": "imagga_gemini",
23
+ "imagga_api_key_env": "IMAGGA_API_KEY",
24
+ "imagga_api_secret_env": "IMAGGA_API_SECRET",
25
+ "gemini_api_key_env": "GEMINI_API_KEY",
26
+ },
27
+ "local": {
28
+ "runtime": "ollama",
29
+ "recommended_model": "",
30
+ "status": "stub",
31
+ },
32
+ }
33
+
34
+
35
+ def config_exists() -> bool:
36
+ return CONFIG_FILE.exists()
37
+
38
+
39
+ def load_config() -> dict[str, Any]:
40
+ if not CONFIG_FILE.exists():
41
+ return default_config()
42
+
43
+ with open(CONFIG_FILE, "r", encoding="utf-8") as f:
44
+ payload = json.load(f)
45
+
46
+ merged = default_config()
47
+ merged.update(payload)
48
+ return merged
49
+
50
+
51
+ def save_config(config: dict[str, Any]) -> None:
52
+ _ensure_config_dir()
53
+
54
+ with open(CONFIG_FILE, "w", encoding="utf-8") as f:
55
+ json.dump(config, f, indent=2, ensure_ascii=False)
56
+
57
+
58
+ def save_env_vars(values: dict[str, str]) -> None:
59
+ _ensure_config_dir()
60
+
61
+ existing: dict[str, str] = {}
62
+ if ENV_FILE.exists():
63
+ for line in ENV_FILE.read_text(encoding="utf-8").splitlines():
64
+ if "=" not in line or line.strip().startswith("#"):
65
+ continue
66
+ key, value = line.split("=", 1)
67
+ existing[key.strip()] = value.strip()
68
+
69
+ existing.update(values)
70
+
71
+ lines = [f"{k}={v}" for k, v in existing.items()]
72
+ ENV_FILE.write_text("\n".join(lines) + "\n", encoding="utf-8")
73
+
74
+ try:
75
+ ENV_FILE.chmod(0o600)
76
+ except OSError:
77
+ pass
@@ -0,0 +1,48 @@
1
+ """
2
+ refactor/context_store.py
3
+ ~~~~~~~~~~~~~~~~~~~~~~~~~
4
+ Manages reading and writing the JSON context cache produced by get-image-context.
5
+
6
+ Default cache filename: .refactor_image_context.json
7
+ """
8
+
9
+ from __future__ import annotations
10
+ import json
11
+ from dataclasses import asdict
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+
15
+ from refactor.imagga import ImageContext
16
+
17
+ DEFAULT_CACHE_FILE = ".refactor_image_context.json"
18
+
19
+
20
+ def save_context(
21
+ results: list[ImageContext],
22
+ source_dir: str,
23
+ output_path: Path,
24
+ ) -> None:
25
+ """Serialise a list of ImageContext objects to a JSON cache file."""
26
+ payload = {
27
+ "generated_at": datetime.now(timezone.utc).isoformat(),
28
+ "source_dir": str(Path(source_dir).resolve()),
29
+ "images": [asdict(ctx) for ctx in results],
30
+ }
31
+
32
+ output_path.parent.mkdir(parents=True, exist_ok=True)
33
+ with open(output_path, "w", encoding="utf-8") as f:
34
+ json.dump(payload, f, indent=2, ensure_ascii=False)
35
+
36
+
37
+ def load_context(input_path: Path) -> list[ImageContext]:
38
+ """Load ImageContext objects from a JSON cache file."""
39
+ if not input_path.exists():
40
+ raise FileNotFoundError(
41
+ f"Context cache not found: {input_path}\n"
42
+ "Run 'refactor get-image-context <path>' first."
43
+ )
44
+
45
+ with open(input_path, "r", encoding="utf-8") as f:
46
+ payload = json.load(f)
47
+
48
+ return [ImageContext(**img) for img in payload.get("images", [])]