Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.1.1] - 2025-09-15

### Added
- A `--single-process` flag and `single_process` configuration option to run file analysis in a single thread, ensuring compatibility with environments like Celery that restrict child process creation.
- A `--copy-only` flag to generate the project map and copy it directly to the clipboard without creating an output file.

### Changed
- Refactored the internal configuration management from a dictionary to a `dataclass` (`ScriberConfig`). This improves type safety, code readability, and makes programmatic configuration more intuitive and less error-prone.
- Enhanced the `exclude` configuration option to support `.gitignore`-style pattern matching. This allows for more precise rules, such as matching directories only (e.g., `build/`) or root-level files (e.g., `/config.yaml`).


## [1.1.0] - 2025-09-15

### Added
Expand Down
285 changes: 148 additions & 137 deletions README.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions src/scriber/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@
ProjectScriber. The main `Scriber` class can be imported directly for
programmatic use.
"""
from .core import Scriber
from .core import Scriber, ScriberConfig

__all__ = ["Scriber"]
__all__ = ["Scriber", "ScriberConfig"]
89 changes: 62 additions & 27 deletions src/scriber/cli.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import argparse
import io
import json
import os
import re
Expand All @@ -21,6 +22,7 @@
from rich.prompt import Confirm, Prompt
from rich.table import Table
from rich.text import Text

RICH_AVAILABLE = True
except ImportError:
RICH_AVAILABLE = False
Expand Down Expand Up @@ -121,17 +123,23 @@ def handle_init(args: argparse.Namespace, console: Any, rich_available: bool):

if rich_available:
config["use_gitignore"] = Confirm.ask("✨ Would you like to respect `.gitignore` rules?", default=True)
default_exclude = ", ".join(DEFAULT_CONFIG.get("exclude", []))
default_exclude = ", ".join(DEFAULT_CONFIG.exclude)
exclude_str = Prompt.ask("📂 Enter patterns to exclude (comma-separated)", default=default_exclude)
include_str = Prompt.ask("📄 Enter patterns to include (optional, comma-separated)", default="")
hidden_str = Prompt.ask("🙈 Enter patterns to hide content for (e.g., lock files, optional, comma-separated)", default="")
hidden_str = Prompt.ask("🙈 Enter patterns to hide content for (e.g., lock files, optional, comma-separated)",
default="")
config["single_process"] = Confirm.ask("⚙️ Run in a single process? (for Celery or similar environments)",
default=False)
else:
answer = input("✨ Would you like to respect `.gitignore` rules? (Y/n) ").strip().lower()
config["use_gitignore"] = answer not in ['n', 'no']
default_exclude = ", ".join(DEFAULT_CONFIG.get("exclude", []))
exclude_str = input(f"📂 Enter patterns to exclude (comma-separated, default: {default_exclude}): ") or default_exclude
default_exclude = ", ".join(DEFAULT_CONFIG.exclude)
exclude_str = input(
f"📂 Enter patterns to exclude (comma-separated, default: {default_exclude}): ") or default_exclude
include_str = input("📄 Enter patterns to include (optional, comma-separated): ")
hidden_str = input("🙈 Enter patterns to hide content for (e.g., lock files, optional, comma-separated): ")
answer = input("⚙️ Run in a single process? (for Celery or similar environments) (y/N) ").strip().lower()
config["single_process"] = answer in ['y', 'yes']

config["exclude"] = [item.strip() for item in exclude_str.split(',') if item.strip()]
include_patterns = [item.strip() for item in include_str.split(',') if item.strip()]
Expand Down Expand Up @@ -167,53 +175,62 @@ def run_scriber(args: argparse.Namespace, console: Any, version: str, rich_avail
"""
if rich_available:
title_text = Text(f"Scriber v{version}", justify="center", style="bold magenta")
subtitle_text = Text("An intelligent tool to map, analyze, and compile project source code for LLM context.", justify="center", style="cyan")
subtitle_text = Text("An intelligent tool to map, analyze, and compile project source code for LLM context.",
justify="center", style="cyan")
console.print(Panel(Text.assemble(title_text, "\n", subtitle_text), expand=False, border_style="blue"))
else:
console.print(f"--- Scriber v{version} ---")

scriber = Scriber(args.root_path.resolve(), config_path=args.config)
output_filename = args.output or scriber.config.get("output", "project_structure.txt")
if args.single_process:
scriber.single_process = True

scriber.map_project()

progress = None
task_id = None
if rich_available:
progress_manager = Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"), BarColumn(), TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), console=console, transient=True)
progress_manager = Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"),
BarColumn(), TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
console=console, transient=True)
total_files = scriber.get_file_count()
if total_files > 0 and not args.tree_only:
task_id = progress_manager.add_task("[green]Processing files...", total=total_files)
progress = progress_manager
else:
console.print("Processing files...")

output_content = ""
if progress:
with progress:
scriber.generate_output_file(output_filename, tree_only=args.tree_only, progress=progress, task_id=task_id)
output_content = scriber.get_output_as_string(tree_only=args.tree_only, progress=progress, task_id=task_id)
else:
scriber.generate_output_file(output_filename, tree_only=args.tree_only)
output_content = scriber.get_output_as_string(tree_only=args.tree_only)

stats = scriber.get_stats()
config_file_display = str(scriber.config_path_used) if scriber.config_path_used else "Defaults"

if rich_available:
summary_table = Table(box=rich.box.ROUNDED, show_header=False, title="[bold]Run Summary[/]", title_justify="left")
summary_table = Table(box=rich.box.ROUNDED, show_header=False, title="[bold]Run Summary[/]",
title_justify="left")
summary_table.add_column("Parameter", style="cyan", no_wrap=True)
summary_table.add_column("Value", style="magenta")
summary_table.add_row("Project Path", str(args.root_path.resolve()))
summary_table.add_row("Config File", config_file_display)
summary_table.add_row("Output File", output_filename)
if not args.copy_only:
summary_table.add_row("Output File", args.output or scriber.config.output)
console.print(summary_table)
else:
console.print("\n--- Run Summary ---")
console.print(f"Project Path: {str(args.root_path.resolve())}")
console.print(f"Config File: {config_file_display}")
console.print(f"Output File: {output_filename}")
if not args.copy_only:
console.print(f"Output File: {args.output or scriber.config.output}")

if stats['total_files'] > 0:
if rich_available:
results_table = Table(box=rich.box.ROUNDED, show_header=False, title="[bold]📊 Analysis Results[/]", title_justify="left")
results_table = Table(box=rich.box.ROUNDED, show_header=False, title="[bold]📊 Analysis Results[/]",
title_justify="left")
results_table.add_column("Metric", style="cyan", no_wrap=True)
results_table.add_column("Value", style="magenta", justify="right")
results_table.add_row("Files Mapped", str(stats['total_files']))
Expand Down Expand Up @@ -243,16 +260,24 @@ def run_scriber(args: argparse.Namespace, console: Any, version: str, rich_avail
else:
console.print("No files were mapped based on the current configuration.")

output_location = Path(args.root_path).resolve() / output_filename
console.print("\n✅ [green]Success! Output saved to:[/green]")
console.print(str(output_location))

if args.copy:
if not args.copy_only:
output_filename = args.output or scriber.config.output
output_location = Path(args.root_path).resolve() / output_filename
try:
with open(output_location, 'w', encoding='utf-8') as f:
f.write(output_content)
console.print("\n✅ [green]Success! Output saved to:[/green]")
console.print(str(output_location))
except IOError as e:
console.print(f"\n❌ [bold red]Error saving output file:[/] {e}")

if args.copy or args.copy_only:
try:
with open(output_location, 'r', encoding='utf-8') as f:
content = f.read()
pyperclip.copy(content)
console.print("📋 [green]Content copied to clipboard.[/green]")
pyperclip.copy(output_content)
if args.copy_only:
console.print("\n✅ [green]Success! Output copied to clipboard.[/green]")
else:
console.print("📋 [green]Content copied to clipboard.[/green]")
except Exception as e:
console.print(f"❌ [bold red]Could not copy to clipboard: {e}[/bold red]")

Expand All @@ -269,8 +294,10 @@ def main() -> None:
except metadata.PackageNotFoundError:
version = "1.0.0 (local)"

parser = argparse.ArgumentParser(description="Scriber: An intelligent tool to map, analyze, and compile project source code for LLM context.")
parser.add_argument("-v", "--version", action="version", version=f"%(prog)s v{version}", help="Show the version number and exit.")
parser = argparse.ArgumentParser(
description="Scriber: An intelligent tool to map, analyze, and compile project source code for LLM context.")
parser.add_argument("-v", "--version", action="version", version=f"%(prog)s v{version}",
help="Show the version number and exit.")
subparsers = parser.add_subparsers(dest="command", title="Commands")

init_parser = subparsers.add_parser("init", help="Create a new configuration file interactively.")
Expand All @@ -282,11 +309,19 @@ def main() -> None:
if exec_mode == 'RUN_PY':
del os.environ['SCRIBER_EXEC_MODE']

run_parser.add_argument("root_path", nargs="?", default=os.environ.get("PROJECT_SCRIBER_ROOT", default_path), type=Path, help="The root directory of the project to map. Defaults to the current directory.")
run_parser.add_argument("root_path", nargs="?", default=os.environ.get("PROJECT_SCRIBER_ROOT", default_path),
type=Path,
help="The root directory of the project to map. Defaults to the current directory.")
run_parser.add_argument("-o", "--output", help="The name of the output file. Overrides config file settings.")
run_parser.add_argument("--config", default=os.environ.get("PROJECT_SCRIBER_CONFIG"), type=Path, help="Path to a custom configuration file.")
run_parser.add_argument("--config", default=os.environ.get("PROJECT_SCRIBER_CONFIG"), type=Path,
help="Path to a custom configuration file.")
run_parser.add_argument("-c", "--copy", action="store_true", help="Copy the final output to the clipboard.")
run_parser.add_argument("--tree-only", action="store_true", help="Generate only the file tree structure without file content.")
run_parser.add_argument("--copy-only", action="store_true",
help="Generate the output and copy it to the clipboard without saving to a file.")
run_parser.add_argument("--tree-only", action="store_true",
help="Generate only the file tree structure without file content.")
run_parser.add_argument("--single-process", action="store_true",
help="Run in a single process to avoid issues in daemonic environments.")
run_parser.set_defaults(func=lambda args: run_scriber(args, console, version, RICH_AVAILABLE))

args_to_parse = sys.argv[1:]
Expand Down
46 changes: 46 additions & 0 deletions src/scriber/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""
Configuration data structure for the Scriber application.
"""
from dataclasses import asdict, dataclass, field
from typing import Any, Dict, List, Set

_DEFAULT_OUTPUT_FILENAME = "scriber_output.txt"
_CONFIG_FILE_NAME = ".scriber.json"


@dataclass
class ScriberConfig:
"""
A dataclass to hold all configuration settings for Scriber.

This provides a structured, type-safe way to manage configuration,
replacing the previous dictionary-based approach. It includes methods
for easy conversion to and from dictionaries.
"""
use_gitignore: bool = True
exclude: List[str] = field(default_factory=lambda: [
"LICENSE",
".git/",
".idea/", ".vscode/", ".project/", ".settings/", ".classpath/",
"__pycache__/", "*.pyc", ".venv/", "venv/", ".pytest_cache/", "uv.lock",
"node_modules/", "npm-debug.log*", "yarn-error.log",
"build/", "dist/", "target/", "bin/", "obj/", "out/",
"vendor/", "bower_components/",
"*.log", "*.lock", "*.tmp", "temp/", "tmp/",
".DS_Store", "Thumbs.db", "*~", "*.swp", "*.swo",
_DEFAULT_OUTPUT_FILENAME, _CONFIG_FILE_NAME
])
include: List[str] = field(default_factory=list)
hidden: List[str] = field(default_factory=list)
exclude_map: Dict[str, List[str]] = field(default_factory=dict)
output: str = _DEFAULT_OUTPUT_FILENAME
single_process: bool = False

def to_dict(self) -> Dict[str, Any]:
"""
Converts the configuration dataclass to a dictionary.

Returns:
A dictionary representation of the configuration settings.
"""
return asdict(self)
Loading